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