From f144a7cc8bc9a3940b7bf85e124bb1c88a9c88ac Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Fri, 3 Apr 2026 15:15:20 +0200 Subject: [PATCH] groupware: add addressbook and calendar creation APIs * add Groupware APIs for creating and deleting addressbooks * add Groupware APIs for creating and deleting calendars * add JMAP APIs for creating and deleting addressbooks, calendars * add JMAP APIs to retrieve Principals * fix API tagging * move addressbook JMAP APIs into its own file * move addressbook Groupware APIs into its own file --- pkg/jmap/api_addressbook.go | 118 +++++ pkg/jmap/api_calendar.go | 37 ++ pkg/jmap/api_changes.go | 2 + pkg/jmap/api_contact.go | 76 +-- pkg/jmap/api_email.go | 5 +- pkg/jmap/api_identity.go | 2 + pkg/jmap/api_mailbox.go | 3 +- pkg/jmap/api_objects.go | 2 + pkg/jmap/api_principal.go | 37 ++ pkg/jmap/api_quota.go | 2 + pkg/jmap/integration_contact_test.go | 116 +++++ pkg/jmap/integration_test.go | 39 +- pkg/jmap/model.go | 453 +++++++++++++++++- pkg/jmap/model_examples.go | 284 ++++++++++- pkg/jmap/templates.go | 8 +- services/groupware/apidoc.yml | 6 + services/groupware/package.json | 2 +- .../pkg/groupware/api_addressbooks.go | 155 ++++++ services/groupware/pkg/groupware/api_blob.go | 7 +- .../groupware/pkg/groupware/api_calendars.go | 149 +----- .../groupware/pkg/groupware/api_changes.go | 23 +- .../groupware/pkg/groupware/api_contacts.go | 83 ---- .../groupware/pkg/groupware/api_emails.go | 14 +- .../groupware/pkg/groupware/api_events.go | 184 +++++++ .../groupware/pkg/groupware/api_mailbox.go | 2 +- .../groupware/pkg/groupware/api_objects.go | 2 + services/groupware/pkg/groupware/error.go | 14 + .../groupware/pkg/groupware/examples_test.go | 14 + services/groupware/pkg/groupware/route.go | 10 +- services/groupware/pnpm-lock.yaml | 36 +- 30 files changed, 1539 insertions(+), 346 deletions(-) create mode 100644 pkg/jmap/api_addressbook.go create mode 100644 pkg/jmap/api_principal.go create mode 100644 services/groupware/pkg/groupware/api_addressbooks.go create mode 100644 services/groupware/pkg/groupware/api_events.go diff --git a/pkg/jmap/api_addressbook.go b/pkg/jmap/api_addressbook.go new file mode 100644 index 0000000000..beab91e810 --- /dev/null +++ b/pkg/jmap/api_addressbook.go @@ -0,0 +1,118 @@ +package jmap + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/log" +) + +var NS_ADDRESSBOOKS = ns(JmapContacts) + +type AddressBooksResponse struct { + AddressBooks []AddressBook `json:"addressbooks"` + NotFound []string `json:"notFound,omitempty"` +} + +func (j *Client) GetAddressbooks(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (AddressBooksResponse, SessionState, State, Language, Error) { + logger = j.logger("GetAddressbooks", session, logger) + + cmd, err := j.request(session, logger, NS_ADDRESSBOOKS, + invocation(CommandAddressBookGet, AddressBookGetCommand{AccountId: accountId, Ids: ids}, "0"), + ) + if err != nil { + return AddressBooksResponse{}, "", "", "", err + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (AddressBooksResponse, State, Error) { + var response AddressBookGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandAddressBookGet, "0", &response) + if err != nil { + return AddressBooksResponse{}, response.State, err + } + return AddressBooksResponse{ + AddressBooks: response.List, + NotFound: response.NotFound, + }, response.State, nil + }) +} + +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", NS_ADDRESSBOOKS, + 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) CreateAddressBook(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create AddressBookChange) (*AddressBook, SessionState, State, Language, Error) { + return createTemplate(j, "CreateAddressBook", NS_ADDRESSBOOKS, AddressBookType, CommandAddressBookSet, CommandAddressBookGet, + func(accountId string, create map[string]AddressBookChange) AddressBookSetCommand { + return AddressBookSetCommand{AccountId: accountId, Create: create} + }, + func(accountId string, ref string) AddressBookGetCommand { + return AddressBookGetCommand{AccountId: accountId, Ids: []string{ref}} + }, + func(resp AddressBookSetResponse) map[string]*AddressBook { + return resp.Created + }, + func(resp AddressBookSetResponse) map[string]SetError { + return resp.NotCreated + }, + func(resp AddressBookGetResponse) []AddressBook { + return resp.List + }, + func(resp AddressBookSetResponse) State { + return resp.NewState + }, + accountId, session, ctx, logger, acceptLanguage, create, + ) +} + +func (j *Client) DeleteAddressBook(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) { + return deleteTemplate(j, "DeleteAddressBook", NS_ADDRESSBOOKS, CommandAddressBookSet, + func(accountId string, destroy []string) AddressBookSetCommand { + return AddressBookSetCommand{AccountId: accountId, Destroy: destroy} + }, + func(resp AddressBookSetResponse) map[string]SetError { return resp.NotDestroyed }, + func(resp AddressBookSetResponse) State { return resp.NewState }, + accountId, destroy, session, ctx, logger, acceptLanguage, + ) +} diff --git a/pkg/jmap/api_calendar.go b/pkg/jmap/api_calendar.go index 47d895f34c..7260fda306 100644 --- a/pkg/jmap/api_calendar.go +++ b/pkg/jmap/api_calendar.go @@ -161,6 +161,8 @@ type CalendarEventChanges struct { Destroyed []string `json:"destroyed,omitempty"` } +// Retrieve the changes in Calendar Events since a given State. +// @api:tags event,changes 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", NS_CALENDARS, @@ -229,3 +231,38 @@ func (j *Client) DeleteCalendarEvent(accountId string, destroy []string, session func(resp CalendarEventSetResponse) State { return resp.NewState }, accountId, destroy, session, ctx, logger, acceptLanguage) } + +func (j *Client) CreateCalendar(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create CalendarChange) (*Calendar, SessionState, State, Language, Error) { + return createTemplate(j, "CreateCalendar", NS_CALENDARS, CalendarType, CommandAddressBookSet, CommandAddressBookGet, + func(accountId string, create map[string]CalendarChange) CalendarSetCommand { + return CalendarSetCommand{AccountId: accountId, Create: create} + }, + func(accountId string, ref string) CalendarGetCommand { + return CalendarGetCommand{AccountId: accountId, Ids: []string{ref}} + }, + func(resp CalendarSetResponse) map[string]*Calendar { + return resp.Created + }, + func(resp CalendarSetResponse) map[string]SetError { + return resp.NotCreated + }, + func(resp CalendarGetResponse) []Calendar { + return resp.List + }, + func(resp CalendarSetResponse) State { + return resp.NewState + }, + accountId, session, ctx, logger, acceptLanguage, create, + ) +} + +func (j *Client) DeleteCalendar(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) { + return deleteTemplate(j, "DeleteCalendar", NS_ADDRESSBOOKS, CommandAddressBookSet, + func(accountId string, destroy []string) CalendarSetCommand { + return CalendarSetCommand{AccountId: accountId, Destroy: destroy} + }, + func(resp CalendarSetResponse) map[string]SetError { return resp.NotDestroyed }, + func(resp CalendarSetResponse) State { return resp.NewState }, + accountId, destroy, session, ctx, logger, acceptLanguage, + ) +} diff --git a/pkg/jmap/api_changes.go b/pkg/jmap/api_changes.go index 7c763e7216..369f79410d 100644 --- a/pkg/jmap/api_changes.go +++ b/pkg/jmap/api_changes.go @@ -74,6 +74,8 @@ func (s StateMap) MarshalZerologObject(e *zerolog.Event) { // if s.Quotas != nil { e.Str("quotas", string(*s.Quotas)) } } +// Retrieve the changes in any type of objects at once since a given State. +// @api:tags changes 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)) diff --git a/pkg/jmap/api_contact.go b/pkg/jmap/api_contact.go index 0095a9c171..d872a97f89 100644 --- a/pkg/jmap/api_contact.go +++ b/pkg/jmap/api_contact.go @@ -11,80 +11,6 @@ import ( var NS_CONTACTS = ns(JmapContacts) -type AddressBooksResponse struct { - AddressBooks []AddressBook `json:"addressbooks"` - NotFound []string `json:"notFound,omitempty"` -} - -func (j *Client) GetAddressbooks(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (AddressBooksResponse, SessionState, State, Language, Error) { - logger = j.logger("GetAddressbooks", session, logger) - - cmd, err := j.request(session, logger, NS_CONTACTS, - invocation(CommandAddressBookGet, AddressBookGetCommand{AccountId: accountId, Ids: ids}, "0"), - ) - if err != nil { - return AddressBooksResponse{}, "", "", "", err - } - - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (AddressBooksResponse, State, Error) { - var response AddressBookGetResponse - err = retrieveResponseMatchParameters(logger, body, CommandAddressBookGet, "0", &response) - if err != nil { - return AddressBooksResponse{}, response.State, err - } - return AddressBooksResponse{ - AddressBooks: response.List, - NotFound: response.NotFound, - }, response.State, nil - }) -} - -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", NS_CONTACTS, - 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) @@ -132,6 +58,8 @@ type ContactCardChanges struct { Destroyed []string `json:"destroyed,omitempty"` } +// Retrieve the changes in Contact Cards since a given State. +// @api:tags contact,changes 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", NS_CONTACTS, diff --git a/pkg/jmap/api_email.go b/pkg/jmap/api_email.go index bc8b2a960a..26261a64b9 100644 --- a/pkg/jmap/api_email.go +++ b/pkg/jmap/api_email.go @@ -204,7 +204,8 @@ type EmailChanges struct { Destroyed []string `json:"destroyed,omitempty"` } -// Get all the Emails that have been created, updated or deleted since a given state. +// Retrieve the changes in Emails since a given State. +// @api:tags email,changes func (j *Client) GetEmailChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, fetchBodies bool, maxBodyValueBytes uint, maxChanges uint) (EmailChanges, SessionState, State, Language, Error) { //NOSONAR logger = j.loggerParams("GetEmailChanges", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies).Str(logSinceState, string(sinceState)) @@ -1080,6 +1081,8 @@ type EmailSubmissionChanges struct { Destroyed []string `json:"destroyed,omitempty"` } +// Retrieve the changes in Email Submissions since a given State. +// @api:tags email,changes func (j *Client) GetEmailSubmissionChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, maxChanges uint) (EmailSubmissionChanges, SessionState, State, Language, Error) { return changesTemplate(j, "GetEmailSubmissionChanges", NS_MAIL_SUBMISSION, diff --git a/pkg/jmap/api_identity.go b/pkg/jmap/api_identity.go index 3387daa1ce..eb183d37c3 100644 --- a/pkg/jmap/api_identity.go +++ b/pkg/jmap/api_identity.go @@ -180,6 +180,8 @@ type IdentityChanges struct { Destroyed []string `json:"destroyed,omitempty"` } +// Retrieve the changes in Email Identities since a given State. +// @api:tags email,changes func (j *Client) GetIdentityChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, maxChanges uint) (IdentityChanges, SessionState, State, Language, Error) { return changesTemplate(j, "GetIdentityChanges", NS_IDENTITY, diff --git a/pkg/jmap/api_mailbox.go b/pkg/jmap/api_mailbox.go index 58426179c4..2a2ef23a13 100644 --- a/pkg/jmap/api_mailbox.go +++ b/pkg/jmap/api_mailbox.go @@ -13,7 +13,7 @@ var NS_MAILBOX = ns(JmapMail) type MailboxesResponse struct { Mailboxes []Mailbox `json:"mailboxes"` - NotFound []any `json:"notFound"` + NotFound []string `json:"notFound"` } func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (MailboxesResponse, SessionState, State, Language, Error) { @@ -172,6 +172,7 @@ func (j *Client) GetMailboxChanges(accountId string, session *Session, ctx conte } // Retrieve Mailbox changes of multiple Accounts. +// @api:tags email,changes 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", NS_MAILBOX, accountIds, sinceStateMap, CommandMailboxChanges, CommandMailboxGet, diff --git a/pkg/jmap/api_objects.go b/pkg/jmap/api_objects.go index 9f507256da..04ef44ec7f 100644 --- a/pkg/jmap/api_objects.go +++ b/pkg/jmap/api_objects.go @@ -20,6 +20,8 @@ type Objects struct { EmailSubmissions *EmailSubmissionGetResponse `json:"submissions,omitempty"` } +// Retrieve objects of all types by their identifiers in a single batch. +// @api:tags changes 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, diff --git a/pkg/jmap/api_principal.go b/pkg/jmap/api_principal.go new file mode 100644 index 0000000000..2a741720e5 --- /dev/null +++ b/pkg/jmap/api_principal.go @@ -0,0 +1,37 @@ +package jmap + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/log" +) + +var NS_PRINCIPALS = ns(JmapPrincipals) + +type PrincipalsResponse struct { + Principals []Principal `json:"principals"` + NotFound []string `json:"notFound,omitempty"` +} + +func (j *Client) GetPrincipals(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (PrincipalsResponse, SessionState, State, Language, Error) { + logger = j.logger("GetPrincipals", session, logger) + + cmd, err := j.request(session, logger, NS_PRINCIPALS, + invocation(CommandPrincipalGet, PrincipalGetCommand{AccountId: accountId, Ids: ids}, "0"), + ) + if err != nil { + return PrincipalsResponse{}, "", "", "", err + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (PrincipalsResponse, State, Error) { + var response PrincipalGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandPrincipalGet, "0", &response) + if err != nil { + return PrincipalsResponse{}, response.State, err + } + return PrincipalsResponse{ + Principals: response.List, + NotFound: response.NotFound, + }, response.State, nil + }) +} diff --git a/pkg/jmap/api_quota.go b/pkg/jmap/api_quota.go index 367d152384..1c9946af20 100644 --- a/pkg/jmap/api_quota.go +++ b/pkg/jmap/api_quota.go @@ -29,6 +29,8 @@ type QuotaChanges struct { Destroyed []string `json:"destroyed,omitempty"` } +// Retrieve the changes in Quotas since a given State. +// @api:tags quota,changes func (j *Client) GetQuotaChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, maxChanges uint) (QuotaChanges, SessionState, State, Language, Error) { return changesTemplate(j, "GetQuotaChanges", NS_QUOTA, diff --git a/pkg/jmap/integration_contact_test.go b/pkg/jmap/integration_contact_test.go index 33c636e273..9c062058d3 100644 --- a/pkg/jmap/integration_contact_test.go +++ b/pkg/jmap/integration_contact_test.go @@ -26,6 +26,55 @@ const ( EnableMediaWithBlobId = false ) +type AddressBooksBoxes struct { + sharedReadOnly bool + sharedReadWrite bool + sharedDelete bool + sortOrdered bool +} + +func TestAddressBooks(t *testing.T) { + if skip(t) { + return + } + + require := require.New(t) + + s, err := newStalwartTest(t, withDirectoryQueries(true)) + require.NoError(err) + defer s.Close() + + user := pickUser() + session := s.Session(user.name) + + principalIds := []string{} + { + principals, _, _, _, err := s.client.GetPrincipals(session.PrimaryAccounts.Mail, session, s.ctx, s.logger, "", []string{}) + require.NoError(err) + require.NotEmpty(principals.Principals) + principalIds = structs.Map(principals.Principals, func(p Principal) string { return p.Id }) + } + + num := uint(5 + rand.Intn(30)) + { + accountId := "" + a, boxes, abooks, err := s.fillAddressBook(t, num, session, user, principalIds) + require.NoError(err) + require.NotEmpty(a) + require.Len(abooks, int(num)) + accountId = a + + ids := structs.Map(abooks, func(a AddressBook) string { return a.Id }) + { + errMap, _, _, _, err := s.client.DeleteAddressBook(accountId, ids, session, s.ctx, s.logger, "") + require.NoError(err) + require.Empty(errMap) + } + + allBoxesAreTicked(t, boxes) + } +} + func TestContacts(t *testing.T) { if skip(t) { return @@ -97,6 +146,73 @@ type ContactsBoxes struct { var streetNumberRegex = regexp.MustCompile(`^(\d+)\s+(.+)$`) +func (s *StalwartTest) fillAddressBook( + t *testing.T, + count uint, + session *Session, + _ User, + principalIds []string, +) (string, AddressBooksBoxes, []AddressBook, error) { + require := require.New(t) + + accountId := session.PrimaryAccounts.Contacts + require.NotEmpty(accountId, "no primary account for contacts in session") + + boxes := AddressBooksBoxes{} + created := []AddressBook{} + + printer := func(s string) { log.Println(s) } + + for i := range count { + name := gofakeit.Company() + description := gofakeit.SentenceSimple() + subscribed := gofakeit.Bool() + abook := AddressBookChange{ + Name: &name, + Description: &description, + IsSubscribed: &subscribed, + } + if i%2 == 0 { + abook.SortOrder = posUIntPtr(gofakeit.Uint()) + boxes.sortOrdered = true + } + var sharing *AddressBookRights = nil + switch i % 4 { + default: + // no sharing + case 1: + sharing = &AddressBookRights{MayRead: true, MayWrite: true, MayAdmin: false, MayDelete: false} + boxes.sharedReadWrite = true + case 2: + sharing = &AddressBookRights{MayRead: true, MayWrite: false, MayAdmin: false, MayDelete: false} + boxes.sharedReadOnly = true + case 3: + sharing = &AddressBookRights{MayRead: true, MayWrite: true, MayAdmin: false, MayDelete: true} + boxes.sharedDelete = true + } + if sharing != nil { + numPrincipals := 1 + rand.Intn(len(principalIds)-1) + m := make(map[string]AddressBookRights, numPrincipals) + for _, p := range pickRandomN(numPrincipals, principalIds...) { + m[p] = *sharing + } + abook.ShareWith = m + } + + a, sessionState, state, _, err := s.client.CreateAddressBook(accountId, session, s.ctx, s.logger, "", abook) + if err != nil { + return accountId, boxes, created, err + } + require.NotEmpty(sessionState) + require.NotEmpty(state) + require.NotNil(a) + created = append(created, *a) + + printer(fmt.Sprintf("📔 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, a.Id)) + } + return accountId, boxes, created, nil +} + func (s *StalwartTest) fillContacts( //NOSONAR t *testing.T, count uint, diff --git a/pkg/jmap/integration_test.go b/pkg/jmap/integration_test.go index 73fb53fffa..fb3c7d6577 100644 --- a/pkg/jmap/integration_test.go +++ b/pkg/jmap/integration_test.go @@ -17,6 +17,7 @@ import ( "reflect" "regexp" "slices" + "strconv" "strings" "testing" "text/template" @@ -120,7 +121,7 @@ tracer.log.level = "trace" tracer.log.lossy = false tracer.log.multiline = false tracer.log.type = "stdout" -sharing.allow-directory-query = false +sharing.allow-directory-query = {{.dirquery}} auth.dkim.sign = false auth.dkim.verify = "disable" auth.spf.verify.ehlo = "disable" @@ -134,11 +135,11 @@ auth.iprev.verify = "disable" func skip(t *testing.T) bool { if os.Getenv("CI") == "woodpecker" { - t.Skip("Skipping tests because CI==wookpecker") + t.Skip("Skipping tests because CI==woodpecker") return true } if os.Getenv("CI_SYSTEM_NAME") == "woodpecker" { - t.Skip("Skipping tests because CI_SYSTEM_NAME==wookpecker") + t.Skip("Skipping tests because CI_SYSTEM_NAME==woodpecker") return true } if os.Getenv("USE_TESTCONTAINERS") == "false" { @@ -206,7 +207,13 @@ func (lc *stalwartTestLogConsumer) Accept(l testcontainers.Log) { fmt.Print("STALWART: " + string(l.Content)) } -func newStalwartTest(t *testing.T) (*StalwartTest, error) { //NOSONAR +func withDirectoryQueries(allowDirectoryQueries bool) func(map[string]any) { + return func(m map[string]any) { + m["dirquery"] = strconv.FormatBool(allowDirectoryQueries) + } +} + +func newStalwartTest(t *testing.T, options ...func(map[string]any)) (*StalwartTest, error) { //NOSONAR ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) var _ context.CancelFunc = cancel // ignore context leak warning: it is passed in the struct and called in Close() @@ -235,14 +242,20 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) { //NOSONAR hostname := "localhost" - configBuf := bytes.NewBufferString("") - template.Must(template.New("").Parse(configTemplate)).Execute(configBuf, map[string]any{ + settings := map[string]any{ "hostname": hostname, "masterusername": masterUsername, "masterpassword": masterPasswordHash, "httpPort": httpPort, "imapsPort": imapsPort, - }) + "dirquery": "false", + } + for _, option := range options { + option(settings) + } + + configBuf := bytes.NewBufferString("") + template.Must(template.New("").Parse(configTemplate)).Execute(configBuf, settings) config := configBuf.String() configReader := strings.NewReader(config) @@ -1128,7 +1141,10 @@ func pickUser() User { } func pickRandoms[T any](s ...T) []T { - n := rand.Intn(len(s)) + return pickRandomN[T](rand.Intn(len(s)), s...) +} + +func pickRandomN[T any](n int, s ...T) []T { if n == 0 { return []T{} } @@ -1165,17 +1181,18 @@ func pickLocale() string { func allBoxesAreTicked[S any](t *testing.T, s S, exceptions ...string) { v := reflect.ValueOf(s) typ := v.Type() + tname := typ.Name() for i := range v.NumField() { name := typ.Field(i).Name if slices.Contains(exceptions, name) { - log.Printf("(/) %s\n", name) + log.Printf("%s[🍒] %s\n", tname, name) continue } value := v.Field(i).Bool() if value { - log.Printf("(X) %s\n", name) + log.Printf("%s[✅] %s\n", tname, name) } else { - log.Printf("( ) %s\n", name) + log.Printf("%s[❌] %s\n", tname, name) } require.True(t, value, "should be true: %v", name) } diff --git a/pkg/jmap/model.go b/pkg/jmap/model.go index 7a9efaf715..1e1de8503a 100644 --- a/pkg/jmap/model.go +++ b/pkg/jmap/model.go @@ -2861,7 +2861,7 @@ type MailboxGetResponse struct { // This array contains the ids passed to the method for records that do not exist. // The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array. - NotFound []any `json:"notFound"` + NotFound []string `json:"notFound"` } type MailboxChangesCommand struct { @@ -4037,6 +4037,141 @@ type Calendar struct { MyRights *CalendarRights `json:"myRights,omitempty"` } +type CalendarChange struct { + // The user-visible name of the calendar. + // + // This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size. + Name string `json:"name"` + + // An optional longer-form description of the calendar, to provide context in shared environments + // where users need more than just the name. + Description string `json:"description,omitempty"` + + // A color to be used when displaying events associated with the calendar. + // + // If not null, the value MUST be a case-insensitive color name taken from the set of names + // defined in Section 4.3 of CSS Color Module Level 3 COLORS, or an RGB value in hexadecimal + // notation, as defined in Section 4.2.1 of CSS Color Module Level 3. + // + // The color SHOULD have sufficient contrast to be used as text on a white background. + Color string `json:"color,omitempty"` + + // Defines the sort order of calendars when presented in the client’s UI, so it is consistent + // between devices. + // + // The number MUST be an integer in the range 0 <= sortOrder < 2^31. + // + // A calendar with a lower order should be displayed before a calendar with a higher order in any + // list of calendars in the client’s UI. + // + // Calendars with equal order SHOULD be sorted in alphabetical order by name. + // + // The sorting should take into account locale-specific character order convention. + SortOrder uint `json:"sortOrder,omitzero"` + + // True if the user has indicated they wish to see this Calendar in their client. + // + // This SHOULD default to `false` for Calendars in shared accounts the user has access to and `true` + // for any new Calendars created by the user themself. + // + // If false, the calendar SHOULD only be displayed when the user explicitly requests it or to offer + // it for the user to subscribe to. + // + // For example, a company may have a large number of shared calendars which all employees have + // permission to access, but you would only subscribe to the ones you care about and want to be able + // to have normally accessible. + IsSubscribed bool `json:"isSubscribed"` + + // Should the calendar’s events be displayed to the user at the moment? + // + // Clients MUST ignore this property if `isSubscribed` is false. + // + // If an event is in multiple calendars, it should be displayed if `isVisible` is `true` + // for any of those calendars. + IsVisible bool `json:"isVisible" default:"true" doc:"opt"` + + // This SHOULD be true for exactly one calendar in any account, and MUST NOT be true for more + // than one calendar within an account (server-set). + // + // The default calendar should be used by clients whenever they need to choose a calendar + // for the user within this account, and they do not have any other information on which to make + // a choice. + // + // For example, if the user creates a new event, the client may automatically set the event as + // belonging to the default calendar from the user’s primary account. + IsDefault bool `json:"isDefault,omitzero"` + + // Should the calendar’s events be used as part of availability calculation? + // + // This MUST be one of: + // * `all`: all events are considered. + // * `attending`: events the user is a confirmed or tentative participant of are considered. + // * `none`: all events are ignored (but may be considered if also in another calendar). + // + // This should default to “all” for the calendars in the user’s own account, and “none” for calendars shared with the user. + IncludeInAvailability IncludeInAvailability `json:"includeInAvailability,omitempty"` + + // A map of alert ids to Alert objects (see [@!RFC8984], Section 4.5.2) to apply for events + // where `showWithoutTime` is `false` and `useDefaultAlerts` is `true`. + // + // Ids MUST be unique across all default alerts in the account, including those in other + // calendars; a UUID is recommended. + // + // The "trigger" MUST NOT be an `AbsoluteTrigger`, as this would fire for every event at the same + // time and so does not make sense for a default alert. + // + // If omitted on creation, the default is server dependent. + // + // For example, servers may choose to always default to null, or may copy the alerts from the default calendar. + DefaultAlertsWithTime map[string]jscalendar.Alert `json:"defaultAlertsWithTime,omitempty"` + + // A map of alert ids to Alert objects (see [@!RFC8984], Section 4.5.2) to apply for events where + // `showWithoutTime` is `true` and `useDefaultAlerts` is `true`. + // + // Ids MUST be unique across all default alerts in the account, including those in other + // calendars; a UUID is recommended. + // + // The "trigger" MUST NOT be an `AbsoluteTrigger`, as this would fire for every event at the + // same time and so does not make sense for a default alert. + // + // If omitted on creation, the default is server dependent. + // + // For example, servers may choose to always default to null, or may copy the alerts from the default calendar. + DefaultAlertsWithoutTime map[string]jscalendar.Alert `json:"defaultAlertsWithoutTime,omitempty"` + + // The time zone to use for events without a time zone when the server needs to resolve them into + // absolute time, e.g., for alerts or availability calculation. + // + // The value MUST be a time zone id from the IANA Time Zone Database TZDB. + // + // If null, the `timeZone` of the account’s associated `Principal` will be used. + // + // Clients SHOULD use this as the default for new events in this calendar if set. + TimeZone string `json:"timeZone,omitempty"` + + // A map of `Principal` id to rights for principals this calendar is shared with. + // + // The principal to which this calendar belongs MUST NOT be in this set. + // + // This is null if the calendar is not shared with anyone. + // + // May be modified only if the user has the `mayAdmin` right. + // + // The account id for the principals may be found in the `urn:ietf:params:jmap:principals:owner` + // capability of the `Account` to which the calendar belongs. + ShareWith map[string]CalendarRights `json:"shareWith,omitempty"` + + // The set of access rights the user has in relation to this Calendar. + // + // If any event is in multiple calendars, the user has the following rights: + // * The user may fetch the event if they have the mayReadItems right on any calendar the event is in. + // * The user may remove an event from a calendar (by modifying the event’s “calendarIds” property) if the user + // has the appropriate permission for that calendar. + // * The user may make other changes to the event if they have the right to do so in all calendars to which the + // event belongs. + MyRights *CalendarRights `json:"myRights,omitempty"` +} + // A CalendarEvent object contains information about an event, or recurring series of events, // that takes place at a particular time. // @@ -5150,6 +5285,122 @@ type AddressBookGetResponse struct { NotFound []string `json:"notFound,omitempty"` } +type AddressBookChange struct { + // The user-visible name of the AddressBook. + // + // This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size. + Name *string `json:"name"` + + // An optional longer-form description of the AddressBook, to provide context in shared environments + // where users need more than just the name. + Description *string `json:"description,omitempty"` + + // Defines the sort order of AddressBooks when presented in the client’s UI, so it is consistent between devices. + // + // The number MUST be an integer in the range 0 <= sortOrder < 2^31. + // + // An AddressBook with a lower order should be displayed before a AddressBook with a higher order in any list + // of AddressBooks in the client’s UI. + // + // AddressBooks with equal order SHOULD be sorted in alphabetical order by name. + // + // The sorting should take into account locale-specific character order convention. + SortOrder *uint `json:"sortOrder,omitzero" default:"0" doc:"opt"` + + // True if the user has indicated they wish to see this AddressBook in their client. + // + // This SHOULD default to false for AddressBooks in shared accounts the user has access to and true for any + // new AddressBooks created by the user themself. + // + // If false, the AddressBook and its contents SHOULD only be displayed when the user explicitly requests it + // or to offer it for the user to subscribe to. + IsSubscribed *bool `json:"isSubscribed"` + + // A map of Principal id to rights for principals this AddressBook is shared with. + // + // The principal to which this AddressBook belongs MUST NOT be in this set. + // + // This is null if the AddressBook is not shared with anyone. + // + // May be modified only if the user has the mayAdmin right. + // + // The account id for the principals may be found in the urn:ietf:params:jmap:principals:owner capability + // of the Account to which the AddressBook belongs. + ShareWith map[string]AddressBookRights `json:"shareWith,omitempty"` +} + +func (a AddressBookChange) AsPatch() PatchObject { + p := PatchObject{} + if a.Name != nil { + p["name"] = *a.Name + } + if a.Description != nil { + p["description"] = *a.Description + } + if a.IsSubscribed != nil { + p["isSubscribed"] = *a.IsSubscribed + } + if a.ShareWith != nil { + p["shareWith"] = a.ShareWith + } + return p +} + +type AddressBookSetCommand struct { + AccountId string `json:"accountId"` + IfInState string `json:"ifInState,omitempty"` + Create map[string]AddressBookChange `json:"create,omitempty"` + Update map[string]PatchObject `json:"update,omitempty"` + Destroy []string `json:"destroy,omitempty"` +} + +type AddressBookSetResponse struct { + // The id of the account used for the call. + AccountId string `json:"accountId"` + + // The state string that would have been returned by AddressBook/get before making the + // requested changes, or null if the server doesn’t know what the previous state + // string was. + OldState State `json:"oldState,omitempty"` + + // The state string that will now be returned by Email/get. + NewState State `json:"newState"` + + // A map of the creation id to an object containing any properties of the created Email object + // that were not sent by the client. + // + // This includes all server-set properties (such as the id in most object types) and any properties + // that were omitted by the client and thus set to a default by the server. + // + // This argument is null if no ContactCard objects were successfully created. + Created map[string]*AddressBook `json:"created,omitempty"` + + // The keys in this map are the ids of all AddressBooks that were successfully updated. + // + // The value for each id is an AddressBook object containing any property that changed in a way not + // explicitly requested by the PatchObject sent to the server, or null if none. + // + // This lets the client know of any changes to server-set or computed properties. + // + // This argument is null if no ContactCard objects were successfully updated. + Updated map[string]*AddressBook `json:"updated,omitempty"` + + // A list of ContactCard ids for records that were successfully destroyed, or null if none. + Destroyed []string `json:"destroyed,omitempty"` + + // A map of the creation id to a SetError object for each record that failed to be created, + // or null if all successful. + NotCreated map[string]SetError `json:"notCreated,omitempty"` + + // A map of the ContactCard id to a SetError object for each record that failed to be updated, + // or null if all successful. + NotUpdated map[string]SetError `json:"notUpdated,omitempty"` + + // A map of the ContactCard id to a SetError object for each record that failed to be destroyed, + // or null if all successful. + NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"` +} + type AddressBookChangesCommand struct { // The id of the account to use. AccountId string `json:"accountId"` @@ -5547,7 +5798,7 @@ type ContactCardGetResponse struct { // This array contains the ids passed to the method for records that do not exist. // // The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array. - NotFound []any `json:"notFound"` + NotFound []string `json:"notFound"` } type ContactCardChangesCommand struct { @@ -5739,6 +5990,61 @@ type CalendarGetResponse struct { NotFound []string `json:"notFound,omitempty"` } +type CalendarSetCommand struct { + AccountId string `json:"accountId"` + IfInState string `json:"ifInState,omitempty"` + Create map[string]CalendarChange `json:"create,omitempty"` + Update map[string]PatchObject `json:"update,omitempty"` + Destroy []string `json:"destroy,omitempty"` +} + +type CalendarSetResponse struct { + // The id of the account used for the call. + AccountId string `json:"accountId"` + + // The state string that would have been returned by Calendar/get before making the + // requested changes, or null if the server doesn’t know what the previous state + // string was. + OldState State `json:"oldState,omitempty"` + + // The state string that will now be returned by Email/get. + NewState State `json:"newState"` + + // A map of the creation id to an object containing any properties of the created Email object + // that were not sent by the client. + // + // This includes all server-set properties (such as the id in most object types) and any properties + // that were omitted by the client and thus set to a default by the server. + // + // This argument is null if no Calendar objects were successfully created. + Created map[string]*Calendar `json:"created,omitempty"` + + // The keys in this map are the ids of all Calendars that were successfully updated. + // + // The value for each id is an Calendar object containing any property that changed in a way not + // explicitly requested by the PatchObject sent to the server, or null if none. + // + // This lets the client know of any changes to server-set or computed properties. + // + // This argument is null if no Calendar objects were successfully updated. + Updated map[string]*Calendar `json:"updated,omitempty"` + + // A list of Calendar ids for records that were successfully destroyed, or null if none. + Destroyed []string `json:"destroyed,omitempty"` + + // A map of the creation id to a SetError object for each record that failed to be created, + // or null if all successful. + NotCreated map[string]SetError `json:"notCreated,omitempty"` + + // A map of the Calendar id to a SetError object for each record that failed to be updated, + // or null if all successful. + NotUpdated map[string]SetError `json:"notUpdated,omitempty"` + + // A map of the Calendar id to a SetError object for each record that failed to be destroyed, + // or null if all successful. + NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"` +} + type CalendarChangesCommand struct { // The id of the account to use. AccountId string `json:"accountId"` @@ -6087,7 +6393,7 @@ type CalendarEventGetResponse struct { // This array contains the ids passed to the method for records that do not exist. // // The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array. - NotFound []any `json:"notFound"` + NotFound []string `json:"notFound"` } type CalendarEventChangesCommand struct { @@ -6239,6 +6545,139 @@ type CalendarEventSetResponse struct { NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"` } +type PrincipalGetCommand struct { + AccountId string `json:"accountId"` + Ids []string `json:"ids,omitempty"` +} + +type PrincipalGetRefCommand struct { + AccountId string `json:"accountId"` + IdsRef *ResultReference `json:"#ids,omitempty"` +} + +type PrincipalGetResponse struct { + // The id of the account used for the call. + AccountId string `json:"accountId"` + + // A (preferably short) string representing the state on the server for all the data of this type in the account + // (not just the objects returned in this call). + // If the data changes, this string MUST change. + // If the Principal data is unchanged, servers SHOULD return the same state string on subsequent requests for this data type. + // When a client receives a response with a different state string to a previous call, it MUST either throw away all currently + // cached objects for the type or call Principal/changes to get the exact changes. + State State `json:"state"` + + // An array of the Principal objects requested. + // This is the empty array if no objects were found or if the ids argument passed in was also an empty array. + // The results MAY be in a different order to the ids in the request arguments. + // If an identical id is included more than once in the request, the server MUST only include it once in either + // the list or the notFound argument of the response. + List []Principal `json:"list"` + + // This array contains the ids passed to the method for records that do not exist. + // The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array. + NotFound []string `json:"notFound"` +} + +type PrincipalFilterElement interface { + _isAPrincipalFilterElement() // marker method +} + +type PrincipalFilterCondition struct { + // A list of Account ids. + // The Principal matches if any of the ids in this list are keys in the Principal's "accounts" property (i.e., if any of the Account ids belong to the Principal). + AccountIds []string `json:"accountIds,omitempty"` + // The email property of the Principal contains the given string. + Email string `json:"email,omitempty"` + // The name property of the Principal contains the given string. + Name string `json:"name,omitempty"` + // The name, email, or description property of the Principal contains the given string. + Text string `json:"text,omitempty"` + // The type must be exactly as given to match the condition. + Type PrincipalTypeOption `json:"type,omitempty"` + // The timeZone must be exactly as given to match the condition. + TimeZone string `json:"timeZone,omitempty"` +} + +func (c PrincipalFilterCondition) _isAPrincipalFilterElement() { //NOSONAR + // marker interface method, does not need to do anything +} + +var _ PrincipalFilterElement = &PrincipalFilterCondition{} + +type PrincipalFilterOperator struct { + Operator FilterOperatorTerm `json:"operator"` + Conditions []PrincipalFilterElement `json:"conditions,omitempty"` +} + +func (c PrincipalFilterOperator) _isAPrincipalFilterElement() { //NOSONAR + // marker interface method, does not need to do anything +} + +var _ PrincipalFilterElement = &PrincipalFilterOperator{} + +type PrincipalComparator struct { + Property string `json:"property"` + IsAscending bool `json:"isAscending,omitempty"` + Limit int `json:"limit,omitzero"` + CalculateTotal bool `json:"calculateTotal,omitempty"` +} + +type PrincipalQueryCommand struct { + AccountId string `json:"accountId"` + Filter PrincipalFilterElement `json:"filter,omitempty"` + Sort []PrincipalComparator `json:"sort,omitempty"` + SortAsTree bool `json:"sortAsTree,omitempty"` + FilterAsTree bool `json:"filterAsTree,omitempty"` +} + +type PrincipalQueryResponse struct { + // The id of the account used for the call. + AccountId string `json:"accountId"` + + // A string encoding the current state of the query on the server. + // + // This string MUST change if the results of the query (i.e., the matching ids and their sort order) have changed. + // The queryState string MAY change if something has changed on the server, which means the results may have + // changed but the server doesn’t know for sure. + // + // The queryState string only represents the ordered list of ids that match the particular query (including its + // sort/filter). There is no requirement for it to change if a property on an object matching the query changes + // but the query results are unaffected (indeed, it is more efficient if the queryState string does not change + // in this case). The queryState string only has meaning when compared to future responses to a query with the + // same type/sort/filter or when used with /queryChanges to fetch changes. + // + // Should a client receive back a response with a different queryState string to a previous call, it MUST either + // throw away the currently cached query and fetch it again (note, this does not require fetching the records + // again, just the list of ids) or call Mailbox/queryChanges to get the difference. + QueryState State `json:"queryState"` + + // This is true if the server supports calling Mailbox/queryChanges with these filter/sort parameters. + // + // Note, this does not guarantee that the Mailbox/queryChanges call will succeed, as it may only be possible for + // a limited time afterwards due to server internal implementation details. + CanCalculateChanges bool `json:"canCalculateChanges"` + + // The zero-based index of the first result in the ids array within the complete list of query results. + Position int `json:"position"` + + // The list of ids for each Mailbox in the query results, starting at the index given by the position argument + // of this response and continuing until it hits the end of the results or reaches the limit number of ids. + // + // If position is >= total, this MUST be the empty list. + Ids []string `json:"ids"` + + // The total number of Mailbox in the results (given the filter) (only if requested). + // + // This argument MUST be omitted if the calculateTotal request argument is not true. + Total int `json:"total,omitzero"` + + // The limit enforced by the server on the maximum number of results to return (if set by the server). + // + // This is only returned if the server set a limit or used a different limit than that given in the request. + Limit int `json:"limit,omitzero"` +} + type ErrorResponse struct { Type string `json:"type"` Description string `json:"description,omitempty"` @@ -6270,6 +6709,7 @@ const ( CommandQuotaGet Command = "Quota/get" CommandQuotaChanges Command = "Quota/changes" CommandAddressBookGet Command = "AddressBook/get" + CommandAddressBookSet Command = "AddressBook/set" CommandAddressBookChanges Command = "AddressBook/changes" CommandContactCardQuery Command = "ContactCard/query" CommandContactCardGet Command = "ContactCard/get" @@ -6277,11 +6717,14 @@ const ( CommandContactCardSet Command = "ContactCard/set" CommandCalendarEventParse Command = "CalendarEvent/parse" CommandCalendarGet Command = "Calendar/get" + CommandCalendarSet Command = "Calendar/set" CommandCalendarChanges Command = "Calendar/changes" CommandCalendarEventQuery Command = "CalendarEvent/query" CommandCalendarEventGet Command = "CalendarEvent/get" CommandCalendarEventSet Command = "CalendarEvent/set" CommandCalendarEventChanges Command = "CalendarEvent/changes" + CommandPrincipalGet Command = "Principal/get" + CommandPrincipalQuery Command = "Principal/query" ) var CommandResponseTypeMap = map[Command]func() any{ @@ -6309,6 +6752,7 @@ var CommandResponseTypeMap = map[Command]func() any{ CommandQuotaGet: func() any { return QuotaGetResponse{} }, CommandQuotaChanges: func() any { return QuotaChangesResponse{} }, CommandAddressBookGet: func() any { return AddressBookGetResponse{} }, + CommandAddressBookSet: func() any { return AddressBookSetResponse{} }, CommandAddressBookChanges: func() any { return AddressBookChangesResponse{} }, CommandContactCardQuery: func() any { return ContactCardQueryResponse{} }, CommandContactCardGet: func() any { return ContactCardGetResponse{} }, @@ -6316,9 +6760,12 @@ var CommandResponseTypeMap = map[Command]func() any{ CommandContactCardSet: func() any { return ContactCardSetResponse{} }, CommandCalendarEventParse: func() any { return CalendarEventParseResponse{} }, CommandCalendarGet: func() any { return CalendarGetResponse{} }, + CommandCalendarSet: func() any { return CalendarSetResponse{} }, 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{} }, + CommandPrincipalGet: func() any { return PrincipalGetResponse{} }, + CommandPrincipalQuery: func() any { return PrincipalQueryResponse{} }, } diff --git a/pkg/jmap/model_examples.go b/pkg/jmap/model_examples.go index bcb3e274c1..42cecb03a2 100644 --- a/pkg/jmap/model_examples.go +++ b/pkg/jmap/model_examples.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/opencloud-eu/opencloud/pkg/jscalendar" "github.com/opencloud-eu/opencloud/pkg/jscontact" c "github.com/opencloud-eu/opencloud/pkg/jscontact" ) @@ -491,6 +492,17 @@ func (e Exemplar) Quotas() []Quota { } } +func (e Exemplar) QuotaGetResponse() QuotaGetResponse { + return QuotaGetResponse{ + AccountId: e.AccountId, + State: "oroomoh1", + NotFound: []string{"aab2n", "aab8f"}, + List: []Quota{ + e.Quota(), + }, + } +} + func (e Exemplar) Identity() Identity { return Identity{ Id: e.IdentityId, @@ -530,6 +542,15 @@ func (e Exemplar) Identity_req() Identity { //NOSONAR } } +func (e Exemplar) IdentityGetResponse() IdentityGetResponse { + return IdentityGetResponse{ + AccountId: e.AccountId, + State: "geechae0", + NotFound: []string{"eea2"}, + List: e.Identities(), + } +} + func (e Exemplar) Thread() Thread { return Thread{ Id: e.ThreadId, @@ -697,6 +718,15 @@ func (e Exemplar) Mailboxes() []Mailbox { return []Mailbox{a, b, c, d, f, g} } +func (e Exemplar) MailboxGetResponse() MailboxGetResponse { + return MailboxGetResponse{ + AccountId: e.AccountId, + State: "aesh2ahj", + List: e.Mailboxes(), + NotFound: []string{"ah"}, + } +} + func (e Exemplar) MailboxChanges() MailboxChanges { a, _, _ := e.MailboxInbox() return MailboxChanges{ @@ -757,10 +787,10 @@ func (e Exemplar) Email() Email { }, }, TextBody: []EmailBodyPart{ - {PartId: "1", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnebdw", Size: 115, Type: "text/plain", Charset: "utf-8"}, + {PartId: "1", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnebdw", Size: 115, Type: "text/plain", Charset: "utf-8"}, //NOSONAR }, HtmlBody: []EmailBodyPart{ - {PartId: "2", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnsbvjae", Size: 163, Type: "text/html", Charset: "utf-8"}, + {PartId: "2", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnsbvjae", Size: 163, Type: "text/html", Charset: "utf-8"}, //NOSONAR }, Preview: "The Canterbury was destroyed while investigating a false distress call from the Scopuli.", } @@ -786,6 +816,49 @@ func (e Exemplar) Emails() Emails { } } +func (e Exemplar) EmailGetResponse() EmailGetResponse { + return EmailGetResponse{ + AccountId: e.AccountId, + State: "aesh2ahj", + NotFound: []string{"ahx"}, + List: e.Emails().Emails, + } +} + +func (e Exemplar) EmailSubmission() EmailSubmission { + sendAt, err := time.Parse(time.RFC3339, "2026-04-08T14:00:00.000Z") + if err != nil { + panic(err) + } + return EmailSubmission{ + Id: "cea1ae", + IdentityId: e.IdentityId, + EmailId: e.EmailId, + ThreadId: e.ThreadId, + Envelope: &Envelope{ + MailFrom: Address{ + Email: "camina@opa.org.example.com", + }, + RcptTo: []Address{ + {Email: "crissy@earth.gov.example.com"}, + }, + }, + SendAt: sendAt, + UndoStatus: UndoStatusPending, + } +} + +func (e Exemplar) EmailSubmissionGetResponse() EmailSubmissionGetResponse { + return EmailSubmissionGetResponse{ + AccountId: e.AccountId, + State: "eiph2pha", + NotFound: []string{"zfa92bn"}, + List: []EmailSubmission{ + e.EmailSubmission(), + }, + } +} + func (e Exemplar) VacationResponse() VacationResponse { from, _ := time.Parse(time.RFC3339, "20260101T00:00:00.000Z") to, _ := time.Parse(time.RFC3339, "20260114T23:59:59.999Z") @@ -1011,6 +1084,16 @@ func (e Exemplar) CalendarsResponse() CalendarsResponse { } } +func (e Exemplar) CalendarGetResponse() CalendarGetResponse { + a := e.Calendar() + return CalendarGetResponse{ + AccountId: e.AccountId, + State: "aesh2ahj", + List: []Calendar{a}, + NotFound: []string{"eehn", "eehz"}, + } +} + func (e Exemplar) Link() c.Link { return c.Link{ Type: c.LinkType, @@ -1786,6 +1869,178 @@ func (e Exemplar) ContactCard() c.ContactCard { } } +func (e Exemplar) ContactCardGetResponse() ContactCardGetResponse { + a := e.ContactCard() + b, _, _ := e.DesignContactCard() + return ContactCardGetResponse{ + AccountId: e.AccountId, + State: "ewohl8ie", + NotFound: []string{"eeaa2"}, + List: []c.ContactCard{a, b}, + } +} + +func (e Exemplar) CalendarEvent() CalendarEvent { + cal := e.Calendar() + return CalendarEvent{ + Id: "aeZaik2faash", + CalendarIds: map[string]bool{cal.Id: true}, + IsDraft: false, + IsOrigin: true, + Event: jscalendar.Event{ + Type: jscalendar.EventType, + Object: jscalendar.Object{ + CommonObject: jscalendar.CommonObject{ + Uid: "dda22c7e-7674-4811-ae2e-2cc1ac605f5c", + ProdId: "Groupware//1.0", + Created: "2026-04-01T15:29:12.912Z", + Updated: "2026-04-01T15:35:44.091Z", + Title: "James Holden's Intronisation Ceremony", + Description: "James Holden will be confirmed as the President of the Transport Union, in room 2201 on station TSL-5.", + DescriptionContentType: "text/plain", + Links: map[string]jscalendar.Link{ + "aig1oh": jscalendar.Link{ + Type: jscalendar.LinkType, + Href: "https://expanse.fandom.com/wiki/TSL-5", + ContentType: "text/html", + Display: "TSL-5", + Title: "TSL-5", + }, + }, + Locale: "en-US", + Keywords: map[string]bool{"union": true}, + Categories: map[string]bool{ + "meeting": true, + }, + Color: "#ff0000", + }, + ShowWithoutTime: false, + Locations: map[string]jscalendar.Location{ + "eigha6": jscalendar.Location{ + Type: jscalendar.LocationType, + Name: "Room 2201", + LocationTypes: map[jscalendar.LocationTypeOption]bool{ + jscalendar.LocationTypeOptionOffice: true, + }, + Coordinates: "geo:40.7495,-73.9681", + Links: map[string]jscalendar.Link{ + "ohb6qu": jscalendar.Link{ + Type: jscalendar.LinkType, + Href: "https://nss.org/what-is-l5/", + ContentType: "text/html", + Display: "Lagrange Point 5", + Title: "Lagrange Point 5", + }, + }, + }, + }, + Sequence: 0, + MainLocationId: "eigha6", + VirtualLocations: map[string]jscalendar.VirtualLocation{ + "eec4ei": jscalendar.VirtualLocation{ + Type: jscalendar.VirtualLocationType, + Name: "OpenTalk", + Uri: "https://earth.gov.example.com/opentalk/l5/2022", + Features: map[jscalendar.VirtualLocationFeature]bool{ + jscalendar.VirtualLocationFeatureVideo: true, + jscalendar.VirtualLocationFeatureScreen: true, + jscalendar.VirtualLocationFeatureAudio: true, + }, + }, + }, + Priority: 1, + FreeBusyStatus: jscalendar.FreeBusyStatusBusy, + Privacy: jscalendar.PrivacyPublic, + SentBy: "avasarala@earth.gov.example.com", + Participants: map[string]jscalendar.Participant{ + "xaku3f": jscalendar.Participant{ + Type: jscalendar.ParticipantType, + Name: "Christjen Avasarala", + Email: "crissy@earth.gov.example.com", + Kind: jscalendar.ParticipantKindIndividual, + Roles: map[jscalendar.Role]bool{ + jscalendar.RoleRequired: true, + jscalendar.RoleChair: true, + jscalendar.RoleOwner: true, + }, + ParticipationStatus: jscalendar.ParticipationStatusAccepted, + }, + "chao1a": jscalendar.Participant{ + Type: jscalendar.ParticipantType, + Name: "Camina Drummer", + Email: "camina@opa.org.example.com", + Kind: jscalendar.ParticipantKindIndividual, + Roles: map[jscalendar.Role]bool{ + jscalendar.RoleRequired: true, + }, + ParticipationStatus: jscalendar.ParticipationStatusAccepted, + ParticipationComment: "I'll definitely be there", + ExpectReply: true, + InvitedBy: "xaku3f", + }, + "ees0oo": jscalendar.Participant{ + Type: jscalendar.ParticipantType, + Name: "James Holden", + Email: "james.holden@rocinante.space", + Kind: jscalendar.ParticipantKindIndividual, + Roles: map[jscalendar.Role]bool{ + jscalendar.RoleRequired: true, + }, + ParticipationStatus: jscalendar.ParticipationStatusAccepted, + ExpectReply: true, + InvitedBy: "xaku3f", + }, + }, + Alerts: map[string]jscalendar.Alert{ + "kus9fa": jscalendar.Alert{ + Type: jscalendar.AlertType, + Action: jscalendar.AlertActionDisplay, + Trigger: jscalendar.OffsetTrigger{ + Type: jscalendar.OffsetTriggerType, + Offset: "-PT1H", + RelativeTo: jscalendar.RelativeToStart, + }, + }, + "lohve9": jscalendar.Alert{ + Type: jscalendar.AlertType, + Action: jscalendar.AlertActionDisplay, + Trigger: jscalendar.OffsetTrigger{ + Type: jscalendar.OffsetTriggerType, + Offset: "-PT10M", + RelativeTo: jscalendar.RelativeToStart, + }, + }, + }, + MayInviteOthers: true, + HideAttendees: false, + }, + }, + } +} + +func (e Exemplar) CalendarEventGetResponse() CalendarEventGetResponse { + ev := e.CalendarEvent() + return CalendarEventGetResponse{ + AccountId: e.AccountId, + State: "zah1ooj0", + NotFound: []string{"eea9"}, + List: []CalendarEvent{ + ev, + }, + } +} + +func (e Exemplar) AddressBookChanges() AddressBookChanges { + a := e.AddressBook() + return AddressBookChanges{ + OldState: "eebees6o", + NewState: "gae1iey0", + HasMoreChanges: true, + Created: []AddressBook{a}, + Destroyed: []string{"l9fn"}, + } +} + func (e Exemplar) ContactCardChanges() (ContactCardChanges, string, string) { c := e.ContactCard() return ContactCardChanges{ @@ -1818,7 +2073,7 @@ func (e Exemplar) EmailChanges() EmailChanges { } } -func (e Exemplar) Changes() Changes { +func (e Exemplar) Changes() (Changes, string, string) { return Changes{ MaxChanges: 3, Mailboxes: &MailboxChangesResponse{ @@ -1862,5 +2117,28 @@ func (e Exemplar) Changes() Changes { HasMoreChanges: true, Created: []string{"fq", "fr", "fs"}, }, + }, "A set of changes to objects", "changes" +} + +func (e Exemplar) Objects() Objects { + mailboxes := e.MailboxGetResponse() + emails := e.EmailGetResponse() + calendars := e.CalendarGetResponse() + events := e.CalendarEventGetResponse() + addressbooks := e.AddressBookGetResponse() + contacts := e.ContactCardGetResponse() + quotas := e.QuotaGetResponse() + identities := e.IdentityGetResponse() + emailSubmissions := e.EmailSubmissionGetResponse() + return Objects{ + Mailboxes: &mailboxes, + Emails: &emails, + Calendars: &calendars, + Events: &events, + Addressbooks: &addressbooks, + Contacts: &contacts, + Quotas: "as, + Identities: &identities, + EmailSubmissions: &emailSubmissions, } } diff --git a/pkg/jmap/templates.go b/pkg/jmap/templates.go index 3fb02805c3..f2fb8021b6 100644 --- a/pkg/jmap/templates.go +++ b/pkg/jmap/templates.go @@ -76,19 +76,19 @@ func getTemplateN[GETREQ any, GETRESP any, ITEM any, RESP any]( //NOSONAR }) } -func createTemplate[T any, SETREQ any, GETREQ any, SETRESP any, GETRESP any]( //NOSONAR +func createTemplate[T any, C any, SETREQ any, GETREQ any, SETRESP any, GETRESP any]( //NOSONAR client *Client, name string, using []JmapNamespace, t ObjectType, setCommand Command, getCommand Command, - setCommandFactory func(string, map[string]T) SETREQ, + setCommandFactory func(string, map[string]C) SETREQ, getCommandFactory func(string, string) GETREQ, createdMapper func(SETRESP) map[string]*T, notCreatedMapper func(SETRESP) map[string]SetError, listMapper func(GETRESP) []T, stateMapper func(SETRESP) State, - accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create T) (*T, SessionState, State, Language, Error) { + accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create C) (*T, SessionState, State, Language, Error) { logger = client.logger(name, session, logger) - createMap := map[string]T{"c": create} + createMap := map[string]C{"c": create} cmd, err := client.request(session, logger, using, invocation(setCommand, setCommandFactory(accountId, createMap), "0"), invocation(getCommand, getCommandFactory(accountId, "#c"), "1"), diff --git a/services/groupware/apidoc.yml b/services/groupware/apidoc.yml index 09f71ff7b0..8b198633d1 100644 --- a/services/groupware/apidoc.yml +++ b/services/groupware/apidoc.yml @@ -58,6 +58,9 @@ tags: - name: vacation x-displayName: Vacation Responses description: APIs about vacation responses + - name: blob + x-displayName: BLOBs + description: APIs about binary large objects - name: changes x-displayName: Changes description: APIs for retrieving changes to objects @@ -89,6 +92,9 @@ x-tagGroups: - name: Quotas tags: - quota + - name: Blobs + tags: + - blob - name: changes tags: - changes diff --git a/services/groupware/package.json b/services/groupware/package.json index 50d9211297..f526f4ebd3 100644 --- a/services/groupware/package.json +++ b/services/groupware/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "@redocly/cli": "^2.25.2", + "@redocly/cli": "^2.25.3", "@types/js-yaml": "^4.0.9", "cheerio": "^1.2.0", "js-yaml": "^4.1.1", diff --git a/services/groupware/pkg/groupware/api_addressbooks.go b/services/groupware/pkg/groupware/api_addressbooks.go new file mode 100644 index 0000000000..462d2ad45f --- /dev/null +++ b/services/groupware/pkg/groupware/api_addressbooks.go @@ -0,0 +1,155 @@ +package groupware + +import ( + "net/http" + + "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/log" +) + +// Get all addressbooks of an account. +func (g *Groupware) GetAddressbooks(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + ok, accountId, resp := req.needContactWithAccount() + if !ok { + return resp + } + + addressbooks, sessionState, state, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, req.logger, req.language(), nil) + if jerr != nil { + return req.jmapError(accountId, jerr, sessionState, lang) + } + + var body jmap.AddressBooksResponse = addressbooks + return req.respond(accountId, body, sessionState, AddressBookResponseObjectType, state) + }) +} + +// Get an addressbook of an account by its identifier. +func (g *Groupware) GetAddressbook(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() + + addressBookId, err := req.PathParam(UriParamAddressBookId) + if err != nil { + return req.error(accountId, err) + } + l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId)) + + logger := log.From(l) + addressbooks, sessionState, state, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, logger, req.language(), []string{addressBookId}) + if jerr != nil { + return req.jmapError(accountId, jerr, sessionState, lang) + } + + if len(addressbooks.NotFound) > 0 { + return req.notFound(accountId, sessionState, AddressBookResponseObjectType, state) + } else { + return req.respond(accountId, addressbooks.AddressBooks[0], sessionState, AddressBookResponseObjectType, state) + } + }) +} + +// Get the changes to Address Books since a certain State. +// @api:tags addressbook,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) + }) +} + +func (g *Groupware) CreateAddressBook(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() + + var create jmap.AddressBookChange + err := req.bodydoc(&create, "The address book to create") + if err != nil { + return req.error(accountId, err) + } + + logger := log.From(l) + created, sessionState, state, lang, jerr := g.jmap.CreateAddressBook(accountId, req.session, req.ctx, logger, req.language(), create) + if jerr != nil { + return req.jmapError(accountId, jerr, sessionState, lang) + } + return req.respond(accountId, created, sessionState, ContactResponseObjectType, state) + }) +} + +func (g *Groupware) DeleteAddressBook(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().Str(accountId, log.SafeString(accountId)) + + addressBookId, err := req.PathParam(UriParamAddressBookId) + if err != nil { + return req.error(accountId, err) + } + l.Str(UriParamAddressBookId, log.SafeString(addressBookId)) + + logger := log.From(l) + + deleted, sessionState, state, lang, jerr := g.jmap.DeleteAddressBook(accountId, []string{addressBookId}, req.session, req.ctx, logger, req.language()) + if jerr != nil { + return req.jmapError(accountId, jerr, sessionState, lang) + } + + for _, e := range deleted { + desc := e.Description + if desc != "" { + return req.error(accountId, apiError( + req.errorId(), + ErrorFailedToDeleteAddressBook, + withDetail(e.Description), + )) + } else { + return req.error(accountId, apiError( + req.errorId(), + ErrorFailedToDeleteAddressBook, + )) + } + } + return req.noContent(accountId, sessionState, AddressBookResponseObjectType, state) + }) +} diff --git a/services/groupware/pkg/groupware/api_blob.go b/services/groupware/pkg/groupware/api_blob.go index bf41842e48..556e496684 100644 --- a/services/groupware/pkg/groupware/api_blob.go +++ b/services/groupware/pkg/groupware/api_blob.go @@ -67,17 +67,18 @@ func (g *Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) { }) } +// Download a BLOB by its identifier. func (g *Groupware) DownloadBlob(w http.ResponseWriter, r *http.Request) { g.stream(w, r, func(req Request, w http.ResponseWriter) *Error { - blobId, err := req.PathParam(UriParamBlobId) + blobId, err := req.PathParam(UriParamBlobId) // the unique identifier of the blob to download if err != nil { return err } - name, err := req.PathParam(UriParamBlobName) + name, err := req.PathParam(UriParamBlobName) // the filename of the blob to download, which is then used in the response and may be arbitrary if unknown if err != nil { return err } - typ, _ := req.getStringParam(QueryParamBlobType, "") + typ, _ := req.getStringParam(QueryParamBlobType, "") // optionally, the Content-Type of the blob, which is then used in the response accountId, gwerr := req.GetAccountIdForBlob() if gwerr != nil { diff --git a/services/groupware/pkg/groupware/api_calendars.go b/services/groupware/pkg/groupware/api_calendars.go index 22858776b0..e9cd7a991f 100644 --- a/services/groupware/pkg/groupware/api_calendars.go +++ b/services/groupware/pkg/groupware/api_calendars.go @@ -2,7 +2,6 @@ package groupware import ( "net/http" - "strings" "github.com/opencloud-eu/opencloud/pkg/jmap" "github.com/opencloud-eu/opencloud/pkg/log" @@ -55,8 +54,8 @@ 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 +// Get the changes to Calendars since a certain State. +// @api:tags calendar,changes func (g *Groupware) GetCalendarChanges(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { ok, accountId, resp := req.needCalendarWithAccount() @@ -90,131 +89,47 @@ func (g *Groupware) GetCalendarChanges(w http.ResponseWriter, r *http.Request) { }) } -// Get all the events in a calendar of an account by its identifier. -func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request) { //NOSONAR +func (g *Groupware) CreateCalendar(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - ok, accountId, resp := req.needCalendarWithAccount() + ok, accountId, resp := req.needContactWithAccount() if !ok { return resp } l := req.logger.With() - calendarId, err := req.PathParam(UriParamCalendarId) - if err != nil { - return req.error(accountId, err) - } - l = l.Str(UriParamCalendarId, log.SafeString(calendarId)) - - offset, ok, err := req.parseUIntParam(QueryParamOffset, 0) - if err != nil { - return req.error(accountId, err) - } - if ok { - l = l.Uint(QueryParamOffset, offset) - } - - limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.contactLimit) - if err != nil { - return req.error(accountId, err) - } - if ok { - l = l.Uint(QueryParamLimit, limit) - } - - filter := jmap.CalendarEventFilterCondition{ - InCalendar: calendarId, - } - sortBy := []jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyUpdated, IsAscending: false}} - - logger := log.From(l) - eventsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryCalendarEvents(single(accountId), req.session, req.ctx, logger, req.language(), filter, sortBy, offset, limit) - if jerr != nil { - return req.jmapError(accountId, jerr, sessionState, lang) - } - - if events, ok := eventsByAccountId[accountId]; ok { - return req.respond(accountId, events, sessionState, EventResponseObjectType, state) - } else { - return req.notFound(accountId, sessionState, EventResponseObjectType, state) - } - }) -} - -// 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() - if !ok { - return resp - } - - l := req.logger.With() - - var create jmap.CalendarEvent - err := req.body(&create) + var create jmap.CalendarChange + err := req.bodydoc(&create, "The calendar to create") if err != nil { return req.error(accountId, err) } logger := log.From(l) - created, sessionState, state, lang, jerr := g.jmap.CreateCalendarEvent(accountId, req.session, req.ctx, logger, req.language(), create) + created, sessionState, state, lang, jerr := g.jmap.CreateCalendar(accountId, req.session, req.ctx, logger, req.language(), create) if jerr != nil { return req.jmapError(accountId, jerr, sessionState, lang) } - return req.respond(accountId, created, sessionState, EventResponseObjectType, state) + return req.respond(accountId, created, sessionState, ContactResponseObjectType, state) }) } -func (g *Groupware) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) { +func (g *Groupware) DeleteCalendar(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - ok, accountId, resp := req.needCalendarWithAccount() + ok, accountId, resp := req.needContactWithAccount() if !ok { return resp } l := req.logger.With().Str(accountId, log.SafeString(accountId)) - eventId, err := req.PathParam(UriParamEventId) + calendarId, err := req.PathParam(UriParamCalendarId) if err != nil { return req.error(accountId, err) } - l.Str(UriParamEventId, log.SafeString(eventId)) + l.Str(UriParamCalendarId, log.SafeString(calendarId)) logger := log.From(l) - deleted, sessionState, state, lang, jerr := g.jmap.DeleteCalendarEvent(accountId, []string{eventId}, req.session, req.ctx, logger, req.language()) + deleted, sessionState, state, lang, jerr := g.jmap.DeleteCalendar(accountId, []string{calendarId}, req.session, req.ctx, logger, req.language()) if jerr != nil { return req.jmapError(accountId, jerr, sessionState, lang) } @@ -222,42 +137,18 @@ func (g *Groupware) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) for _, e := range deleted { desc := e.Description if desc != "" { - return req.errorS(accountId, apiError( + return req.error(accountId, apiError( req.errorId(), - ErrorFailedToDeleteContact, + ErrorFailedToDeleteCalendar, withDetail(e.Description), - ), sessionState) + )) } else { - return req.errorS(accountId, apiError( + return req.error(accountId, apiError( req.errorId(), - ErrorFailedToDeleteContact, - ), sessionState) + ErrorFailedToDeleteCalendar, + )) } } - return req.noContent(accountId, sessionState, EventResponseObjectType, state) - }) -} - -func (g *Groupware) ParseIcalBlob(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) Response { - accountId, err := req.GetAccountIdForBlob() - if err != nil { - return req.error(accountId, err) - } - - blobId, err := req.PathParam(UriParamBlobId) - if err != nil { - return req.error(accountId, err) - } - - blobIds := strings.Split(blobId, ",") - l := req.logger.With().Array(UriParamBlobId, log.SafeStringArray(blobIds)) - logger := log.From(l) - - resp, sessionState, state, lang, jerr := g.jmap.ParseICalendarBlob(accountId, req.session, req.ctx, logger, req.language(), blobIds) - if jerr != nil { - return req.jmapError(accountId, jerr, sessionState, lang) - } - return req.respond(accountId, resp, sessionState, EventResponseObjectType, state) + return req.noContent(accountId, sessionState, CalendarResponseObjectType, state) }) } diff --git a/services/groupware/pkg/groupware/api_changes.go b/services/groupware/pkg/groupware/api_changes.go index cec3d3f6a7..83d0ab8770 100644 --- a/services/groupware/pkg/groupware/api_changes.go +++ b/services/groupware/pkg/groupware/api_changes.go @@ -13,6 +13,7 @@ import ( // 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 @@ -24,6 +25,8 @@ import ( // 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. +// +// @api:tags changes func (g *Groupware) GetChanges(w http.ResponseWriter, r *http.Request) { //NOSONAR g.respond(w, r, func(req Request) Response { l := req.logger.With() @@ -34,7 +37,7 @@ func (g *Groupware) GetChanges(w http.ResponseWriter, r *http.Request) { //NOSON l = l.Str(logAccountId, accountId) var maxChanges uint = 0 - if v, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0); err != nil { + if v, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0); err != nil { // The maximum amount of changes to emit for each type of object. return req.error(accountId, err) } else if ok { maxChanges = v @@ -43,33 +46,33 @@ func (g *Groupware) GetChanges(w http.ResponseWriter, r *http.Request) { //NOSON sinceState := jmap.StateMap{} { - if state, ok := req.getStringParam(QueryParamMailboxes, ""); ok { + if state, ok := req.getStringParam(QueryParamMailboxes, ""); ok { // The state of Mailboxes from which to determine changes. sinceState.Mailboxes = ptr(toState(state)) } - if state, ok := req.getStringParam(QueryParamEmails, ""); ok { + if state, ok := req.getStringParam(QueryParamEmails, ""); ok { // The state of Emails from which to determine changes. sinceState.Emails = ptr(toState(state)) } - if state, ok := req.getStringParam(QueryParamAddressbooks, ""); ok { + if state, ok := req.getStringParam(QueryParamAddressbooks, ""); ok { // The state of Address Books from which to determine changes. sinceState.Addressbooks = ptr(toState(state)) } - if state, ok := req.getStringParam(QueryParamContacts, ""); ok { + if state, ok := req.getStringParam(QueryParamContacts, ""); ok { // The state of Contact Cards from which to determine changes. sinceState.Contacts = ptr(toState(state)) } - if state, ok := req.getStringParam(QueryParamCalendars, ""); ok { + if state, ok := req.getStringParam(QueryParamCalendars, ""); ok { // The state of Calendars from which to determine changes. sinceState.Calendars = ptr(toState(state)) } - if state, ok := req.getStringParam(QueryParamEvents, ""); ok { + if state, ok := req.getStringParam(QueryParamEvents, ""); ok { // The state of Calendar Events from which to determine changes. sinceState.Events = ptr(toState(state)) } - if state, ok := req.getStringParam(QueryParamIdentities, ""); ok { + if state, ok := req.getStringParam(QueryParamIdentities, ""); ok { // The state of Identities from which to determine changes. sinceState.Identities = ptr(toState(state)) } - if state, ok := req.getStringParam(QueryParamEmailSubmissions, ""); ok { + if state, ok := req.getStringParam(QueryParamEmailSubmissions, ""); ok { // The state of Email Submissions from which to determine changes. sinceState.EmailSubmissions = ptr(toState(state)) } //if state, ok := req.getStringParam(QueryParamQuotas, ""); ok { sinceState.Quotas = ptr(toState(state)) } if sinceState.IsZero() { - return req.noop(accountId) + return req.noop(accountId) // No content response if no object IDs were requested. } } diff --git a/services/groupware/pkg/groupware/api_contacts.go b/services/groupware/pkg/groupware/api_contacts.go index d82d530f79..47f1d78f5a 100644 --- a/services/groupware/pkg/groupware/api_contacts.go +++ b/services/groupware/pkg/groupware/api_contacts.go @@ -41,89 +41,6 @@ var ( } ) -// Get all addressbooks of an account. -func (g *Groupware) GetAddressbooks(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) Response { - ok, accountId, resp := req.needContactWithAccount() - if !ok { - return resp - } - - addressbooks, sessionState, state, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, req.logger, req.language(), nil) - if jerr != nil { - return req.jmapError(accountId, jerr, sessionState, lang) - } - - var body jmap.AddressBooksResponse = addressbooks - return req.respond(accountId, body, sessionState, AddressBookResponseObjectType, state) - }) -} - -// Get an addressbook of an account by its identifier. -func (g *Groupware) GetAddressbook(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() - - addressBookId, err := req.PathParam(UriParamAddressBookId) - if err != nil { - return req.error(accountId, err) - } - l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId)) - - logger := log.From(l) - addressbooks, sessionState, state, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, logger, req.language(), []string{addressBookId}) - if jerr != nil { - return req.jmapError(accountId, jerr, sessionState, lang) - } - - if len(addressbooks.NotFound) > 0 { - return req.notFound(accountId, sessionState, AddressBookResponseObjectType, state) - } else { - return req.respond(accountId, addressbooks.AddressBooks[0], sessionState, AddressBookResponseObjectType, state) - } - }) -} - -// 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 { diff --git a/services/groupware/pkg/groupware/api_emails.go b/services/groupware/pkg/groupware/api_emails.go index e91afe8803..47bf6d25f2 100644 --- a/services/groupware/pkg/groupware/api_emails.go +++ b/services/groupware/pkg/groupware/api_emails.go @@ -21,8 +21,8 @@ import ( "github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics" ) -// Get the changes that occured in a given mailbox since a certain state. -// @api:tags mailbox,changes +// Get the changes tp Emails since a certain State. +// @api:tags email,changes func (g *Groupware) GetEmailChanges(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { l := req.logger.With() @@ -230,6 +230,9 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NO } } +// Get the attachments of an email by its identifier. +// +// @api:tags email func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) { //NOSONAR contextAppender := func(l zerolog.Context) zerolog.Context { return l } q := r.URL.Query() @@ -941,6 +944,9 @@ func (e emailKeywordUpdates) IsEmpty() bool { return len(e.Add) == 0 && len(e.Remove) == 0 } +// Update the keywords of an email by its identifier. +// +// @api:tags email func (g *Groupware) UpdateEmailKeywords(w http.ResponseWriter, r *http.Request) { //NOSONAR g.respond(w, r, func(req Request) Response { l := req.logger.With() @@ -1000,6 +1006,8 @@ func (g *Groupware) UpdateEmailKeywords(w http.ResponseWriter, r *http.Request) } // Add keywords to an email by its unique identifier. +// +// @api:tags email func (g *Groupware) AddEmailKeywords(w http.ResponseWriter, r *http.Request) { //NOSONAR g.respond(w, r, func(req Request) Response { l := req.logger.With() @@ -1060,6 +1068,8 @@ func (g *Groupware) AddEmailKeywords(w http.ResponseWriter, r *http.Request) { / } // Remove keywords of an email by its unique identifier. +// +// @api:tags email func (g *Groupware) RemoveEmailKeywords(w http.ResponseWriter, r *http.Request) { //NOSONAR g.respond(w, r, func(req Request) Response { l := req.logger.With() diff --git a/services/groupware/pkg/groupware/api_events.go b/services/groupware/pkg/groupware/api_events.go new file mode 100644 index 0000000000..fd4feb9301 --- /dev/null +++ b/services/groupware/pkg/groupware/api_events.go @@ -0,0 +1,184 @@ +package groupware + +import ( + "net/http" + "strings" + + "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/log" +) + +// 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 { + ok, accountId, resp := req.needCalendarWithAccount() + if !ok { + return resp + } + + l := req.logger.With() + + calendarId, err := req.PathParam(UriParamCalendarId) + if err != nil { + return req.error(accountId, err) + } + l = l.Str(UriParamCalendarId, log.SafeString(calendarId)) + + offset, ok, err := req.parseUIntParam(QueryParamOffset, 0) + if err != nil { + return req.error(accountId, err) + } + if ok { + l = l.Uint(QueryParamOffset, offset) + } + + limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.contactLimit) + if err != nil { + return req.error(accountId, err) + } + if ok { + l = l.Uint(QueryParamLimit, limit) + } + + filter := jmap.CalendarEventFilterCondition{ + InCalendar: calendarId, + } + sortBy := []jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyUpdated, IsAscending: false}} + + logger := log.From(l) + eventsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryCalendarEvents(single(accountId), req.session, req.ctx, logger, req.language(), filter, sortBy, offset, limit) + if jerr != nil { + return req.jmapError(accountId, jerr, sessionState, lang) + } + + if events, ok := eventsByAccountId[accountId]; ok { + return req.respond(accountId, events, sessionState, EventResponseObjectType, state) + } else { + return req.notFound(accountId, sessionState, EventResponseObjectType, state) + } + }) +} + +// 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() + if !ok { + return resp + } + + l := req.logger.With() + + var create jmap.CalendarEvent + err := req.body(&create) + if err != nil { + return req.error(accountId, err) + } + + logger := log.From(l) + created, sessionState, state, lang, jerr := g.jmap.CreateCalendarEvent(accountId, req.session, req.ctx, logger, req.language(), create) + if jerr != nil { + return req.jmapError(accountId, jerr, sessionState, lang) + } + return req.respond(accountId, created, sessionState, EventResponseObjectType, state) + }) +} + +func (g *Groupware) DeleteCalendarEvent(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().Str(accountId, log.SafeString(accountId)) + + eventId, err := req.PathParam(UriParamEventId) + if err != nil { + return req.error(accountId, err) + } + l.Str(UriParamEventId, log.SafeString(eventId)) + + logger := log.From(l) + + deleted, sessionState, state, lang, jerr := g.jmap.DeleteCalendarEvent(accountId, []string{eventId}, req.session, req.ctx, logger, req.language()) + if jerr != nil { + return req.jmapError(accountId, jerr, sessionState, lang) + } + + for _, e := range deleted { + desc := e.Description + if desc != "" { + return req.errorS(accountId, apiError( + req.errorId(), + ErrorFailedToDeleteContact, + withDetail(e.Description), + ), sessionState) + } else { + return req.errorS(accountId, apiError( + req.errorId(), + ErrorFailedToDeleteContact, + ), sessionState) + } + } + return req.noContent(accountId, sessionState, EventResponseObjectType, state) + }) +} + +// Parse a blob that contains an iCal file and return it as JSCalendar. +// +// @api:tags calendar,blob +func (g *Groupware) ParseIcalBlob(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + accountId, err := req.GetAccountIdForBlob() + if err != nil { + return req.error(accountId, err) + } + + blobId, err := req.PathParam(UriParamBlobId) + if err != nil { + return req.error(accountId, err) + } + + blobIds := strings.Split(blobId, ",") + l := req.logger.With().Array(UriParamBlobId, log.SafeStringArray(blobIds)) + logger := log.From(l) + + resp, sessionState, state, lang, jerr := g.jmap.ParseICalendarBlob(accountId, req.session, req.ctx, logger, req.language(), blobIds) + if jerr != nil { + return req.jmapError(accountId, jerr, sessionState, lang) + } + return req.respond(accountId, resp, sessionState, EventResponseObjectType, state) + }) +} diff --git a/services/groupware/pkg/groupware/api_mailbox.go b/services/groupware/pkg/groupware/api_mailbox.go index 0e5d5061fe..1dac5ba515 100644 --- a/services/groupware/pkg/groupware/api_mailbox.go +++ b/services/groupware/pkg/groupware/api_mailbox.go @@ -176,7 +176,7 @@ func (g *Groupware) GetMailboxByRoleForAllAccounts(w http.ResponseWriter, r *htt }) } -// Get the changes that occured in a given mailbox since a certain state. +// Get the changes tp Mailboxes since a certain State. // @api:tags mailbox,changes func (g *Groupware) GetMailboxChanges(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { diff --git a/services/groupware/pkg/groupware/api_objects.go b/services/groupware/pkg/groupware/api_objects.go index c93d1b3a40..cead96685b 100644 --- a/services/groupware/pkg/groupware/api_objects.go +++ b/services/groupware/pkg/groupware/api_objects.go @@ -36,6 +36,8 @@ type ObjectsRequest struct { // 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. +// +// @api:tags mailbox,email,addressbook,contact,calendar,event,quota,identity func (g *Groupware) GetObjects(w http.ResponseWriter, r *http.Request) { //NOSONAR g.respond(w, r, func(req Request) Response { l := req.logger.With() diff --git a/services/groupware/pkg/groupware/error.go b/services/groupware/pkg/groupware/error.go index 1f1cbdb19b..3aeec680df 100644 --- a/services/groupware/pkg/groupware/error.go +++ b/services/groupware/pkg/groupware/error.go @@ -200,6 +200,8 @@ const ( ErrorCodeFailedToDeleteEmail = "DELEML" ErrorCodeFailedToDeleteSomeIdentities = "DELSID" ErrorCodeFailedToSanitizeEmail = "FSANEM" + ErrorCodeFailedToDeleteAddressBook = "DELABK" + ErrorCodeFailedToDeleteCalendar = "DELCAL" ErrorCodeFailedToDeleteContact = "DELCNT" ErrorCodeNoMailboxWithDraftRole = "NMBXDR" ErrorCodeNoMailboxWithSentRole = "NMBXSE" @@ -443,12 +445,24 @@ var ( Title: "Failed to sanitize an email", Detail: "Email content sanitization failed.", } + ErrorFailedToDeleteAddressBook = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeFailedToDeleteAddressBook, + Title: "Failed to delete address books", + Detail: "One or more address books could not be deleted.", + } ErrorFailedToDeleteContact = GroupwareError{ Status: http.StatusInternalServerError, Code: ErrorCodeFailedToDeleteContact, Title: "Failed to delete contacts", Detail: "One or more contacts could not be deleted.", } + ErrorFailedToDeleteCalendar = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeFailedToDeleteCalendar, + Title: "Failed to delete calendar", + Detail: "One or more calendars could not be deleted.", + } ErrorNoMailboxWithDraftRole = GroupwareError{ Status: http.StatusExpectationFailed, Code: ErrorCodeNoMailboxWithDraftRole, diff --git a/services/groupware/pkg/groupware/examples_test.go b/services/groupware/pkg/groupware/examples_test.go index 0bfcef5229..69570affd5 100644 --- a/services/groupware/pkg/groupware/examples_test.go +++ b/services/groupware/pkg/groupware/examples_test.go @@ -187,3 +187,17 @@ func (e Exemplar) DeletedMailboxes() ([]string, string, string, string) { j := jmap.ExemplarInstance return []string{j.MailboxProjectId, j.MailboxJunkId}, "Identifiers of the Mailboxes that have successfully been deleted", "", "deletedmailboxes" } + +func (e Exemplar) ObjectsRequest() ObjectsRequest { + return ObjectsRequest{ + Mailboxes: []string{"ahh9ye", "ahbei8"}, + Emails: []string{"koo6ka", "fa1ees", "zaish0", "iek2fo"}, + Addressbooks: []string{"ungu0a"}, + Contacts: []string{"oo8ahv", "lexue6", "mohth3"}, + Calendars: []string{"aa8aqu", "detho5"}, + Events: []string{"oo8thu", "mu9sha", "aim1sh", "sair6a"}, + Quotas: []string{"vei4ai"}, + Identities: []string{"iuj4ae", "mahv9y"}, + EmailSubmissions: []string{"eidoo6", "aakie7", "uh7ous"}, + } +} diff --git a/services/groupware/pkg/groupware/route.go b/services/groupware/pkg/groupware/route.go index 8c65315f60..96667614e5 100644 --- a/services/groupware/pkg/groupware/route.go +++ b/services/groupware/pkg/groupware/route.go @@ -148,22 +148,28 @@ func (g *Groupware) Route(r chi.Router) { }) r.Route("/addressbooks", func(r chi.Router) { r.Get("/", g.GetAddressbooks) + r.Post("/", g.CreateAddressBook) r.Route("/{addressbookid}", func(r chi.Router) { r.Get("/", g.GetAddressbook) r.Get("/contacts", g.GetContactsInAddressbook) //NOSONAR + r.Delete("/", g.DeleteAddressBook) }) }) r.Route("/contacts", func(r chi.Router) { r.Get("/", g.GetAllContacts) r.Post("/", g.CreateContact) - r.Delete("/{contactid}", g.DeleteContact) - r.Get("/{contactid}", g.GetContactById) + r.Route("/{contactid}", func(r chi.Router) { + r.Get("/", g.GetContactById) + r.Delete("/", g.DeleteContact) + }) }) r.Route("/calendars", func(r chi.Router) { r.Get("/", g.GetCalendars) + r.Post("/", g.CreateCalendar) r.Route("/{calendarid}", func(r chi.Router) { r.Get("/", g.GetCalendarById) r.Get("/events", g.GetEventsInCalendar) //NOSONAR + r.Delete("/", g.DeleteCalendar) }) }) r.Route("/events", func(r chi.Router) { diff --git a/services/groupware/pnpm-lock.yaml b/services/groupware/pnpm-lock.yaml index af92e0b1b0..2cd76c7674 100644 --- a/services/groupware/pnpm-lock.yaml +++ b/services/groupware/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@redocly/cli': - specifier: ^2.25.2 - version: 2.25.2(@opentelemetry/api@1.9.1)(core-js@3.45.1) + specifier: ^2.25.3 + version: 2.25.3(@opentelemetry/api@1.9.1)(core-js@3.45.1) '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -194,8 +194,8 @@ packages: '@redocly/cli-otel@0.1.2': resolution: {integrity: sha512-Bg7BoO5t1x3lVK+KhA5aGPmeXpQmdf6WtTYHhelKJCsQ+tRMiJoFAQoKHoBHAoNxXrhlS3K9lKFLHGmtxsFQfA==} - '@redocly/cli@2.25.2': - resolution: {integrity: sha512-kn1SiHDss3t+Ami37T6ZH5ov1fiEXF1y488bUOUgrh0pEK8VOq8+HlPbdte/cH0K+dWPhuLyKNACd+KhMQPjCw==} + '@redocly/cli@2.25.3': + resolution: {integrity: sha512-02wjApwJwGD+kGWRoiFVY0Hq960ydMAMHrK3AJH2LMiYNYcrzAr1FSbA3OSylvg2gx3w/r1r710B+iMz3KJKbw==} engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} hasBin: true @@ -209,12 +209,12 @@ packages: resolution: {integrity: sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} - '@redocly/openapi-core@2.25.2': - resolution: {integrity: sha512-HIvxgwxQct/IdRJjjqu4g8BLpCik6I3zxp8JFJpRtmY1TSIZAOZjJwlkoh4uQcy/nCP+psSMgQvzjVGml3k6+w==} + '@redocly/openapi-core@2.25.3': + resolution: {integrity: sha512-GIu3Mdym5IDIPCvXTzMZ6TQw/+7sKd52PdysxNVe7zBk22ExSGnVE9UAk9BaLOzXT77PJWDUwaimBdJoPpxHMA==} engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} - '@redocly/respect-core@2.25.2': - resolution: {integrity: sha512-GpvmjY2x8u4pAGNts7slexuKDzDWHNUB4gey9/rSqvC8IaqY49vkvMuRodIBwCsqXhn2rpkJbar1UK3rAOuy7g==} + '@redocly/respect-core@2.25.3': + resolution: {integrity: sha512-07m80JYdp7J7kH4D1Vqdpa2ZBFCv3QAwCoh2w9H3OjuT/rXQkBSkJQm1n70fzO/HuUf4azzULdp2XnsIpxP2qw==} engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} '@tsconfig/node10@1.0.12': @@ -556,8 +556,8 @@ packages: engines: {node: '>= 12'} hasBin: true - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} minimatch@5.1.9: @@ -1135,15 +1135,15 @@ snapshots: dependencies: ulid: 2.4.0 - '@redocly/cli@2.25.2(@opentelemetry/api@1.9.1)(core-js@3.45.1)': + '@redocly/cli@2.25.3(@opentelemetry/api@1.9.1)(core-js@3.45.1)': dependencies: '@opentelemetry/exporter-trace-otlp-http': 0.202.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.34.0 '@redocly/cli-otel': 0.1.2 - '@redocly/openapi-core': 2.25.2 - '@redocly/respect-core': 2.25.2 + '@redocly/openapi-core': 2.25.3 + '@redocly/respect-core': 2.25.3 abort-controller: 3.0.0 ajv: '@redocly/ajv@8.18.0' ajv-formats: 3.0.1(@redocly/ajv@8.18.0) @@ -1195,7 +1195,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@redocly/openapi-core@2.25.2': + '@redocly/openapi-core@2.25.3': dependencies: '@redocly/ajv': 8.18.0 '@redocly/config': 0.45.0 @@ -1208,12 +1208,12 @@ snapshots: pluralize: 8.0.0 yaml-ast-parser: 0.0.43 - '@redocly/respect-core@2.25.2': + '@redocly/respect-core@2.25.3': dependencies: '@faker-js/faker': 7.6.0 '@noble/hashes': 1.8.0 '@redocly/ajv': 8.18.0 - '@redocly/openapi-core': 2.25.2 + '@redocly/openapi-core': 2.25.3 ajv: '@redocly/ajv@8.18.0' better-ajv-errors: 1.2.0(@redocly/ajv@8.18.0) colorette: 2.0.20 @@ -1446,7 +1446,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -1527,7 +1527,7 @@ snapshots: marked@4.3.0: {} - minimatch@10.2.4: + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5