From 0cebbbd83f2794424a11d363fcb66fb632cc94ba Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Thu, 2 Oct 2025 17:02:52 +0200 Subject: [PATCH] groupware: add JMAP capability checking (in part: for contacts, calendars, tasks) --- pkg/jmap/jmap_model.go | 234 ++++++++++++++---- pkg/jmap/jmap_test.go | 46 +++- .../pkg/groupware/groupware_api_calendars.go | 18 ++ .../pkg/groupware/groupware_api_contacts.go | 18 ++ .../pkg/groupware/groupware_api_tasklists.go | 18 ++ .../pkg/groupware/groupware_error.go | 50 ++++ .../pkg/groupware/groupware_request.go | 111 +++++++++ .../pkg/groupware/groupware_response.go | 9 + 8 files changed, 459 insertions(+), 45 deletions(-) diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 6335671544..e068f124a0 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -131,20 +131,28 @@ type ActionMode string type SendingMode string type DispositionTypeOption string +type Duration string + const ( - JmapCore = "urn:ietf:params:jmap:core" - JmapMail = "urn:ietf:params:jmap:mail" - JmapMDN = "urn:ietf:params:jmap:mdn" // https://datatracker.ietf.org/doc/rfc9007/ - JmapSubmission = "urn:ietf:params:jmap:submission" - JmapVacationResponse = "urn:ietf:params:jmap:vacationresponse" - JmapCalendars = "urn:ietf:params:jmap:calendars" - JmapContacts = "urn:ietf:params:jmap:contacts" - JmapSieve = "urn:ietf:params:jmap:sieve" - JmapBlob = "urn:ietf:params:jmap:blob" - JmapQuota = "urn:ietf:params:jmap:quota" - JmapWebsocket = "urn:ietf:params:jmap:websocket" - JmapPrincipals = "urn:ietf:params:jmap:principals" - JmapPrincipalsOwner = "urn:ietf:params:jmap:principals:owner" + JmapCore = "urn:ietf:params:jmap:core" + JmapMail = "urn:ietf:params:jmap:mail" + JmapMDN = "urn:ietf:params:jmap:mdn" // https://datatracker.ietf.org/doc/rfc9007/ + JmapSubmission = "urn:ietf:params:jmap:submission" + JmapVacationResponse = "urn:ietf:params:jmap:vacationresponse" + JmapCalendars = "urn:ietf:params:jmap:calendars" + JmapContacts = "urn:ietf:params:jmap:contacts" + JmapSieve = "urn:ietf:params:jmap:sieve" + JmapBlob = "urn:ietf:params:jmap:blob" + JmapQuota = "urn:ietf:params:jmap:quota" + JmapWebsocket = "urn:ietf:params:jmap:websocket" + JmapPrincipals = "urn:ietf:params:jmap:principals" + JmapPrincipalsOwner = "urn:ietf:params:jmap:principals:owner" + JmapTasks = "urn:ietf:params:jmap:tasks" + JmapTasksRecurrences = "urn:ietf:params:jmap:tasks:recurrences" + JmapTasksAssignees = "urn:ietf:params:jmap:tasks:assignees" + JmapTasksAlerts = "urn:ietf:params:jmap:tasks:alerts" + JmapTasksMultilingual = "urn:ietf:params:jmap:tasks:multilingual" + JmapTasksCustomTimezones = "urn:ietf:params:jmap:tasks:customtimezones" CoreType = ObjectType("Core") PushSubscriptionType = ObjectType("PushSubscription") @@ -584,6 +592,42 @@ type SessionContactsAccountCapabilities struct { MayCreateAddressBook bool `json:"mayCreateAddressBook"` } +type SessionCalendarsAccountCapabilities struct { +} + +type SessionCalendarsParseAccountCapabilities struct { +} + +type SessionTasksAccountCapabilities struct { + // The earliest date-time the server is willing to accept for any date stored in a Task. + MinDateTime LocalDate `json:"minDateTime,omitzero"` + + // The latest date-time the server is willing to accept for any date stored in a Task. + MaxDateTime LocalDate `json:"maxDateTime,omitzero"` + + // If true, the user may create a task list in this account. + MayCreateTaskList bool `json:"mayCreateTaskList"` +} + +type SessionTasksRecurrencesAccountCapabilities struct { + // The maximum duration the user may query over when asking the server to expand recurrences. + MaxExpandedQueryDuration Duration `json:"maxExpandedQueryDuration"` +} + +type SessionTasksAssigneesAccountCapabilities struct { + // The maximum number of participants a single task may have, or null for no limit. + MaxParticipantsPerTask *uint `json:"maxParticipantsPerTask,omitzero"` +} + +type SessionTasksAlertsAccountCapabilities struct { +} + +type SessionTasksMultilingualAccountCapabilities struct { +} + +type SessionTasksCustomTimezonesAccountCapabilities struct { +} + type SessionPrincipalsAccountCapabilities struct { // The id of the principal in this account that corresponds to the user fetching this object, if any. CurrentUserPrincipalId string `json:"currentUserPrincipalId,omitempty"` @@ -598,20 +642,34 @@ type SessionPrincipalsOwnerAccountCapabilities struct { PrincipalId string `json:"principalId,omitempty"` } +type SessionPrincipalAvailabilityAccountCapabilities struct { + // The maximum duration over which the server is prepared to calculate availability in a single call. + MaxAvailabilityDuration Duration `json:"maxAvailabilityDuration"` +} + type SessionMDNAccountCapabilities struct { } type SessionAccountCapabilities struct { - Mail *SessionMailAccountCapabilities `json:"urn:ietf:params:jmap:mail,omitempty"` - Submission *SessionSubmissionAccountCapabilities `json:"urn:ietf:params:jmap:submission,omitempty"` - VacationResponse *SessionVacationResponseAccountCapabilities `json:"urn:ietf:params:jmap:vacationresponse,omitempty"` - Sieve *SessionSieveAccountCapabilities `json:"urn:ietf:params:jmap:sieve,omitempty"` - Blob *SessionBlobAccountCapabilities `json:"urn:ietf:params:jmap:blob,omitempty"` - Quota *SessionQuotaAccountCapabilities `json:"urn:ietf:params:jmap:quota,omitempty"` - Contacts *SessionContactsAccountCapabilities `json:"urn:ietf:params:jmap:contacts,omitempty"` - Principals *SessionPrincipalsAccountCapabilities `json:"urn:ietf:params:jmap:principals,omitempty"` - PrincipalsOwner *SessionPrincipalsOwnerAccountCapabilities `json:"urn:ietf:params:jmap:principals:owner,omitempty"` - MDN *SessionMDNAccountCapabilities `json:"urn:ietf:params:jmap:mdn,omitempty"` + Mail *SessionMailAccountCapabilities `json:"urn:ietf:params:jmap:mail,omitempty"` + Submission *SessionSubmissionAccountCapabilities `json:"urn:ietf:params:jmap:submission,omitempty"` + VacationResponse *SessionVacationResponseAccountCapabilities `json:"urn:ietf:params:jmap:vacationresponse,omitempty"` + Sieve *SessionSieveAccountCapabilities `json:"urn:ietf:params:jmap:sieve,omitempty"` + Blob *SessionBlobAccountCapabilities `json:"urn:ietf:params:jmap:blob,omitempty"` + Quota *SessionQuotaAccountCapabilities `json:"urn:ietf:params:jmap:quota,omitempty"` + Contacts *SessionContactsAccountCapabilities `json:"urn:ietf:params:jmap:contacts,omitempty"` + Calendars *SessionCalendarsAccountCapabilities `json:"urn:ietf:params:jmap:calendars,omitempty"` + CalendarsParse *SessionCalendarsParseAccountCapabilities `json:"urn:ietf:params:jmap:calendars:parse,omitempty"` + Tasks *SessionTasksAccountCapabilities `json:"urn:ietf:params:jmap:tasks,omitempty"` + TasksRecurrences *SessionTasksRecurrencesAccountCapabilities `json:"urn:ietf:params:jmap:tasks:recurrences,omitempty"` + TasksAssignees *SessionTasksAssigneesAccountCapabilities `json:"urn:ietf:params:jmap:tasks:assignees,omitempty"` + TasksAlerts *SessionTasksAlertsAccountCapabilities `json:"urn:ietf:params:jmap:tasks:alerts,omitempty"` + TasksMultilingual *SessionTasksMultilingualAccountCapabilities `json:"urn:ietf:params:jmap:tasks:multilingual,omitempty"` + TasksCustomTimezones *SessionTasksCustomTimezonesAccountCapabilities `json:"urn:ietf:params:jmap:tasks:customtimezones,omitempty"` + Principals *SessionPrincipalsAccountCapabilities `json:"urn:ietf:params:jmap:principals,omitempty"` + PrincipalsOwner *SessionPrincipalsOwnerAccountCapabilities `json:"urn:ietf:params:jmap:principals:owner,omitempty"` + PrincipalsAvailability *SessionPrincipalAvailabilityAccountCapabilities `json:"urn:ietf:params:jmap:principals:availability,omitempty"` + MDN *SessionMDNAccountCapabilities `json:"urn:ietf:params:jmap:mdn,omitempty"` } type Account struct { @@ -644,7 +702,7 @@ type SessionCoreCapabilities struct { MaxObjectsInSet int `json:"maxObjectsInSet"` // A list of identifiers for algorithms registered in the collation registry, as defined in [@!RFC4790], that the server // supports for sorting when querying records. - CollationAlgorithms []string `json:"collationAlgorithms"` + CollationAlgorithms []string `json:"collationAlgorithms,omitempty"` } type SessionMailCapabilities struct { @@ -682,35 +740,125 @@ type SessionWebsocketCapabilities struct { type SessionContactsCapabilities struct { } +type SessionCalendarsCapabilities struct { +} + +type SessionCalendarsParseCapabilities struct { +} + +type SessionTasksCapabilities struct { +} + +type SessionTasksRecurrencesCapabilities struct { +} + +type SessionTasksAssigneesCapabilities struct { +} + +type SessionTasksAlertsCapabilities struct { +} + +type SessionTasksMultilingualCapabilities struct { +} + +type SessionTasksCustomTimezonesCapabilities struct { +} + type SessionPrincipalCapabilities struct { } +type SessionPrincipalAvailabilityCapabilities struct { +} + type SessionMDNCapabilities struct { } type SessionCapabilities struct { - Core SessionCoreCapabilities `json:"urn:ietf:params:jmap:core"` - Mail SessionMailCapabilities `json:"urn:ietf:params:jmap:mail"` - Submission SessionSubmissionCapabilities `json:"urn:ietf:params:jmap:submission"` - VacationResponse SessionVacationResponseCapabilities `json:"urn:ietf:params:jmap:vacationresponse"` - Sieve SessionSieveCapabilities `json:"urn:ietf:params:jmap:sieve"` - Blob SessionBlobCapabilities `json:"urn:ietf:params:jmap:blob"` - Quota SessionQuotaCapabilities `json:"urn:ietf:params:jmap:quota"` - Websocket SessionWebsocketCapabilities `json:"urn:ietf:params:jmap:websocket"` - Contacts *SessionContactsCapabilities `json:"urn:ietf:params:jmap:contacts"` - Principals *SessionPrincipalCapabilities `json:"urn:ietf:params:jmap:principals"` - MDN *SessionMDNCapabilities `json:"urn:ietf:params:jmap:mdn,omitempty"` + Core *SessionCoreCapabilities `json:"urn:ietf:params:jmap:core,omitempty"` + Mail *SessionMailCapabilities `json:"urn:ietf:params:jmap:mail,omitempty"` + Submission *SessionSubmissionCapabilities `json:"urn:ietf:params:jmap:submission,omitempty"` + VacationResponse *SessionVacationResponseCapabilities `json:"urn:ietf:params:jmap:vacationresponse,omitempty"` + Sieve *SessionSieveCapabilities `json:"urn:ietf:params:jmap:sieve,omitempty"` + Blob *SessionBlobCapabilities `json:"urn:ietf:params:jmap:blob,omitempty"` + Quota *SessionQuotaCapabilities `json:"urn:ietf:params:jmap:quota,omitempty"` + Websocket *SessionWebsocketCapabilities `json:"urn:ietf:params:jmap:websocket,omitempty"` + + // This represents support for the `AddressBook` and `ContactCard` data types and associated API methods. + // + // The value of this property in the JMAP Session `capabilities` property is an empty object. + Contacts *SessionContactsCapabilities `json:"urn:ietf:params:jmap:contacts,omitempty"` + + // This represents support for the `Calendar`, `CalendarEvent`, `CalendarEventNotification`, + // and `ParticipantIdentity` data types and associated API methods, except for `CalendarEvent/parse`. + // + // The value of this property in the JMAP Session `capabilities` property is an empty object. + Calendars *SessionCalendarsCapabilities `json:"urn:ietf:params:jmap:calendars,omitempty"` + + // This represents support for the `CalendarEvent/parse` method. + // + // The value of this property is an empty object in the JMAP session `capabilities` property. + CalendarsParse *SessionCalendarsParseCapabilities `json:"urn:ietf:params:jmap:calendars:parse,omitempty"` + + // This represents support for the core properties and objects of the `TaskList`, + // `Task` and `TaskNotification` data types and associated API methods. + // + // The value of this property in the JMAP Session `capabilities` property is an empty object. + Tasks *SessionTasksCapabilities `json:"urn:ietf:params:jmap:tasks,omitempty"` + + // This represents support for the `recurrence` properties and objects of the `TaskList`, + // `Task` and `TaskNotification` data types and associated API methods. + // + // The value of this property in the JMAP Session `capabilities` property is an empty object. + TasksRecurrences *SessionTasksRecurrencesCapabilities `json:"urn:ietf:params:jmap:tasks:recurrences,omitempty"` + + // This represents support for the `assignee` properties and objects of the `TaskList`, + // `Task` and `TaskNotification` data types and associated API methods. + // + // The value of this property in the JMAP Session `capabilities` property is an empty object. + TasksAssignees *SessionTasksAssigneesCapabilities `json:"urn:ietf:params:jmap:tasks:assignees,omitempty"` + + // This represents support for the `alerts` properties and objects of the `TaskList`, + // `Task` and `TaskNotification` data types and associated API methods. + // + // The value of this property in the JMAP Session `capabilities` property is an empty object. + TasksAlerts *SessionTasksAlertsCapabilities `json:"urn:ietf:params:jmap:tasks:alerts,omitempty"` + + // This represents support for the multilingual properties and objects of the `TaskList`, + // `Task` and `TaskNotification` data types and associated API methods. + // + // The value of this property in the JMAP Session `capabilities` property is an empty object. + TasksMultilingual *SessionTasksMultilingualCapabilities `json:"urn:ietf:params:jmap:tasks:multilingual,omitempty"` + + // This represents support for the custom time zone properties and objects of the `TaskList`, + // `Task` and `TaskNotification` data types and associated API methods. + // + // The value of this property in the JMAP Session `capabilities` property is an empty object. + TasksCustomTimezones *SessionTasksCustomTimezonesCapabilities `json:"urn:ietf:params:jmap:tasks:customtimezones,omitempty"` + + Principals *SessionPrincipalCapabilities `json:"urn:ietf:params:jmap:principals,omitempty"` + + // Represents support for the `Principal/getAvailability` method. + // + // Any account with this capability MUST also have the `urn:ietf:params:jmap:principals` capability. + // + // The value of this property in the JMAP Session `capabilities` property is an empty object. + PrincipalsAvailability *SessionPrincipalAvailabilityCapabilities `json:"urn:ietf:params:jmap:principals:availability,omitempty"` + + MDN *SessionMDNCapabilities `json:"urn:ietf:params:jmap:mdn,omitempty"` } type SessionPrimaryAccounts struct { - Core string `json:"urn:ietf:params:jmap:core"` - Mail string `json:"urn:ietf:params:jmap:mail"` - Submission string `json:"urn:ietf:params:jmap:submission"` - VacationResponse string `json:"urn:ietf:params:jmap:vacationresponse"` - Sieve string `json:"urn:ietf:params:jmap:sieve"` - Blob string `json:"urn:ietf:params:jmap:blob"` - Quota string `json:"urn:ietf:params:jmap:quota"` - Websocket string `json:"urn:ietf:params:jmap:websocket"` + Core string `json:"urn:ietf:params:jmap:core,omitempty"` + Mail string `json:"urn:ietf:params:jmap:mail,omitempty"` + Submission string `json:"urn:ietf:params:jmap:submission,omitempty"` + VacationResponse string `json:"urn:ietf:params:jmap:vacationresponse,omitempty"` + Sieve string `json:"urn:ietf:params:jmap:sieve,omitempty"` + Blob string `json:"urn:ietf:params:jmap:blob,omitempty"` + Quota string `json:"urn:ietf:params:jmap:quota,omitempty"` + Websocket string `json:"urn:ietf:params:jmap:websocket,omitempty"` + Task string `json:"urn:ietf:params:jmap:task,omitempty"` + Calendar string `json:"urn:ietf:params:jmap:calendar,omitempty"` + Contact string `json:"urn:ietf:params:jmap:contact,omitempty"` } type SessionState string diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index 88ac0f7857..cd3fcdd2ea 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -20,6 +20,48 @@ import ( "github.com/stretchr/testify/require" ) +func jsoneq[X any](t *testing.T, expected string, object X) { + data, err := json.MarshalIndent(object, "", "") + require.NoError(t, err) + require.JSONEq(t, expected, string(data)) + + var rec X + err = json.Unmarshal(data, &rec) + require.NoError(t, err) + require.Equal(t, object, rec) +} + +func TestEmptySessionCapabilitiesMarshalling(t *testing.T) { + jsoneq(t, `{}`, SessionCapabilities{}) +} + +func TestSessionCapabilitiesMarshalling(t *testing.T) { + jsoneq(t, `{ + "urn:ietf:params:jmap:core": { + "maxSizeUpload": 123, + "maxConcurrentUpload": 4, + "maxSizeRequest": 1000, + "maxConcurrentRequests": 8, + "maxCallsInRequest": 16, + "maxObjectsInGet": 32, + "maxObjectsInSet": 8 + }, + "urn:ietf:params:jmap:tasks": { + } + }`, SessionCapabilities{ + Core: &SessionCoreCapabilities{ + MaxSizeUpload: 123, + MaxConcurrentUpload: 4, + MaxSizeRequest: 1000, + MaxConcurrentRequests: 8, + MaxCallsInRequest: 16, + MaxObjectsInGet: 32, + MaxObjectsInSet: 8, + }, + Tasks: &SessionTasksCapabilities{}, + }) +} + type TestJmapWellKnownClient struct { t *testing.T } @@ -48,7 +90,7 @@ func (t *TestJmapWellKnownClient) GetSession(sessionUrl *url.URL, username strin Websocket: pa, }, Capabilities: SessionCapabilities{ - Core: SessionCoreCapabilities{ + Core: &SessionCoreCapabilities{ MaxCallsInRequest: 64, }, }, @@ -182,7 +224,7 @@ func TestRequests(t *testing.T) { JmapUrl: *jmapUrl, SessionResponse: SessionResponse{ Capabilities: SessionCapabilities{ - Core: SessionCoreCapabilities{ + Core: &SessionCoreCapabilities{ MaxCallsInRequest: 10, }, }, diff --git a/services/groupware/pkg/groupware/groupware_api_calendars.go b/services/groupware/pkg/groupware/groupware_api_calendars.go index c9bfa351f3..732b78cd5a 100644 --- a/services/groupware/pkg/groupware/groupware_api_calendars.go +++ b/services/groupware/pkg/groupware/groupware_api_calendars.go @@ -25,6 +25,12 @@ type SwaggerGetCalendars200 struct { // 500: ErrorResponse500 func (g *Groupware) GetCalendars(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { + ok, accountId, resp := req.needCalendarWithAccount() + if !ok { + return resp + } + var _ string = accountId + return response(AllCalendars, req.session.State) }) } @@ -49,6 +55,12 @@ type SwaggerGetCalendarById200 struct { // 500: ErrorResponse500 func (g *Groupware) GetCalendarById(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { + ok, accountId, resp := req.needCalendarWithAccount() + if !ok { + return resp + } + var _ string = accountId + calendarId := chi.URLParam(r, UriParamCalendarId) // TODO replace with proper implementation for _, calendar := range AllCalendars { @@ -78,6 +90,12 @@ type SwaggerGetEventsInCalendar200 struct { // 500: ErrorResponse500 func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { + ok, accountId, resp := req.needCalendarWithAccount() + if !ok { + return resp + } + var _ string = accountId + calendarId := chi.URLParam(r, UriParamCalendarId) // TODO replace with proper implementation events, ok := EventsMapByCalendarId[calendarId] diff --git a/services/groupware/pkg/groupware/groupware_api_contacts.go b/services/groupware/pkg/groupware/groupware_api_contacts.go index 7eca524aad..75579d3e74 100644 --- a/services/groupware/pkg/groupware/groupware_api_contacts.go +++ b/services/groupware/pkg/groupware/groupware_api_contacts.go @@ -26,6 +26,12 @@ type SwaggerGetAddressbooks200 struct { // 500: ErrorResponse500 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 + } + var _ string = accountId + // TODO replace with proper implementation return response(AllAddressBooks, req.session.State) }) @@ -51,6 +57,12 @@ type SwaggerGetAddressbookById200 struct { // 500: ErrorResponse500 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 + } + var _ string = accountId + addressBookId := chi.URLParam(r, UriParamAddressBookId) // TODO replace with proper implementation for _, ab := range AllAddressBooks { @@ -80,6 +92,12 @@ type SwaggerGetContactsInAddressbook200 struct { // 500: ErrorResponse500 func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { + ok, accountId, resp := req.needContactWithAccount() + if !ok { + return resp + } + var _ string = accountId + addressBookId := chi.URLParam(r, UriParamAddressBookId) // TODO replace with proper implementation contactCards, ok := ContactsMapByAddressBookId[addressBookId] diff --git a/services/groupware/pkg/groupware/groupware_api_tasklists.go b/services/groupware/pkg/groupware/groupware_api_tasklists.go index 878935cc1f..e1b9511f77 100644 --- a/services/groupware/pkg/groupware/groupware_api_tasklists.go +++ b/services/groupware/pkg/groupware/groupware_api_tasklists.go @@ -25,6 +25,12 @@ type SwaggerGetTaskLists200 struct { // 500: ErrorResponse500 func (g *Groupware) GetTaskLists(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { + ok, accountId, resp := req.needTaskWithAccount() + if !ok { + return resp + } + var _ string = accountId + return response(AllTaskLists, req.session.State) }) } @@ -49,6 +55,12 @@ type SwaggerGetTaskListById200 struct { // 500: ErrorResponse500 func (g *Groupware) GetTaskListById(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { + ok, accountId, resp := req.needTaskWithAccount() + if !ok { + return resp + } + var _ string = accountId + tasklistId := chi.URLParam(r, UriParamTaskListId) // TODO replace with proper implementation for _, tasklist := range AllTaskLists { @@ -78,6 +90,12 @@ type SwaggerGetTasksInTaskList200 struct { // 500: ErrorResponse500 func (g *Groupware) GetTasksInTaskList(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { + ok, accountId, resp := req.needTaskWithAccount() + if !ok { + return resp + } + var _ string = accountId + tasklistId := chi.URLParam(r, UriParamTaskListId) // TODO replace with proper implementation tasks, ok := TaskMapByTaskListId[tasklistId] diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index 0039cb7fd7..a80c7b4be5 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -188,6 +188,12 @@ const ( ErrorCodeAccountNotFound = "ACCNFD" ErrorCodeAccountNotSupportedByMethod = "ACCNSM" ErrorCodeAccountReadOnly = "ACCRDO" + ErrorCodeMissingCalendarsSessionCapability = "MSCCAL" + ErrorCodeMissingCalendarsAccountCapability = "MACCAL" + ErrorCodeMissingContactsSessionCapability = "MSCCON" + ErrorCodeMissingContactsAccountCapability = "MACCON" + ErrorCodeMissingTasksSessionCapability = "MSCTSK" + ErrorCodeMissingTaskAccountCapability = "MACTSK" ) var ( @@ -371,6 +377,42 @@ var ( Title: "The referenced Account is read-only", Detail: "The Account that was referenced in the request only supports read-only operations.", } + ErrorMissingCalendarsSessionCapability = GroupwareError{ + Status: http.StatusExpectationFailed, + Code: ErrorCodeMissingCalendarsSessionCapability, + Title: "Session is missing the task capability '" + jmap.JmapCalendars + "'", + Detail: "The JMAP Session of the user does not have the required capability '" + jmap.JmapTasks + "'.", + } + ErrorMissingCalendarsAccountCapability = GroupwareError{ + Status: http.StatusExpectationFailed, + Code: ErrorCodeMissingCalendarsSessionCapability, + Title: "Account is missing the task capability '" + jmap.JmapCalendars + "'", + Detail: "The JMAP Account of the user does not have the required capability '" + jmap.JmapTasks + "'.", + } + ErrorMissingContactsSessionCapability = GroupwareError{ + Status: http.StatusExpectationFailed, + Code: ErrorCodeMissingContactsSessionCapability, + Title: "Session is missing the task capability '" + jmap.JmapContacts + "'", + Detail: "The JMAP Session of the user does not have the required capability '" + jmap.JmapContacts + "'.", + } + ErrorMissingContactsAccountCapability = GroupwareError{ + Status: http.StatusExpectationFailed, + Code: ErrorCodeMissingContactsSessionCapability, + Title: "Account is missing the task capability '" + jmap.JmapContacts + "'", + Detail: "The JMAP Account of the user does not have the required capability '" + jmap.JmapContacts + "'.", + } + ErrorMissingTasksSessionCapability = GroupwareError{ + Status: http.StatusExpectationFailed, + Code: ErrorCodeMissingTasksSessionCapability, + Title: "Session is missing the task capability '" + jmap.JmapTasks + "'", + Detail: "The JMAP Session of the user does not have the required capability '" + jmap.JmapTasks + "'.", + } + ErrorMissingTasksAccountCapability = GroupwareError{ + Status: http.StatusExpectationFailed, + Code: ErrorCodeMissingTasksSessionCapability, + Title: "Account is missing the task capability '" + jmap.JmapTasks + "'", + Detail: "The JMAP Account of the user does not have the required capability '" + jmap.JmapTasks + "'.", + } ) type ErrorOpt interface { @@ -527,6 +569,14 @@ func (r Request) observedParameterError(gwerr GroupwareError, options ...ErrorOp return r.observeParameterError(apiError(r.errorId(), gwerr, options...)) } +func (r Request) apiError(err *GroupwareError) *Error { + if err == nil { + return nil + } + errorId := r.errorId() + return apiError(errorId, *err) +} + func (r Request) apiErrorFromJmap(err jmap.Error) *Error { if err == nil { return nil diff --git a/services/groupware/pkg/groupware/groupware_request.go b/services/groupware/pkg/groupware/groupware_request.go index ca83c987d5..8ba2ef9c56 100644 --- a/services/groupware/pkg/groupware/groupware_request.go +++ b/services/groupware/pkg/groupware/groupware_request.go @@ -57,6 +57,9 @@ var ( errNoPrimaryAccountForBlob = errors.New("no primary account for blob") errNoPrimaryAccountForVacationResponse = errors.New("no primary account for vacation response") errNoPrimaryAccountForSubmission = errors.New("no primary account for submission") + errNoPrimaryAccountForTask = errors.New("no primary account for task") + errNoPrimaryAccountForCalendar = errors.New("no primary account for calendar") + errNoPrimaryAccountForContact = errors.New("no primary account for contact") // errNoPrimaryAccountForSieve = errors.New("no primary account for sieve") // errNoPrimaryAccountForQuota = errors.New("no primary account for quota") // errNoPrimaryAccountForWebsocket = errors.New("no primary account for websocket") @@ -105,6 +108,18 @@ func (r Request) GetAccountIdForSubmission() (string, *Error) { return r.getAccountId(r.session.PrimaryAccounts.Blob, errNoPrimaryAccountForSubmission) } +func (r Request) GetAccountIdForTask() (string, *Error) { + return r.getAccountId(r.session.PrimaryAccounts.Task, errNoPrimaryAccountForTask) +} + +func (r Request) GetAccountIdForCalendar() (string, *Error) { + return r.getAccountId(r.session.PrimaryAccounts.Calendar, errNoPrimaryAccountForCalendar) +} + +func (r Request) GetAccountIdForContact() (string, *Error) { + return r.getAccountId(r.session.PrimaryAccounts.Contact, errNoPrimaryAccountForContact) +} + func (r Request) GetAccountForMail() (jmap.Account, *Error) { accountId, err := r.GetAccountIdForMail() if err != nil { @@ -277,3 +292,99 @@ func (r Request) observeJmapError(jerr jmap.Error) jmap.Error { } return jerr } + +func (r Request) needTask() (bool, Response) { + if r.session.Capabilities.Tasks == nil { + return false, errorResponseWithSessionState(r.apiError(&ErrorMissingTasksSessionCapability), r.session.State) + } + return true, Response{} +} + +func (r Request) needTaskForAccount(accountId string) (bool, Response) { + if ok, resp := r.needTask(); !ok { + return ok, resp + } + account, ok := r.session.Accounts[accountId] + if !ok { + return false, errorResponseWithSessionState(r.apiError(&ErrorAccountNotFound), r.session.State) + } + if account.AccountCapabilities.Tasks == nil { + return false, errorResponseWithSessionState(r.apiError(&ErrorMissingTasksAccountCapability), r.session.State) + } + return true, Response{} +} + +func (r Request) needTaskWithAccount() (bool, string, Response) { + accountId, err := r.GetAccountIdForTask() + if err != nil { + return false, "", errorResponse(err) + } + if ok, resp := r.needTaskForAccount(accountId); !ok { + return false, accountId, resp + } + return true, accountId, Response{} +} + +func (r Request) needCalendar() (bool, Response) { + if r.session.Capabilities.Calendars == nil { + return false, errorResponseWithSessionState(r.apiError(&ErrorMissingCalendarsSessionCapability), r.session.State) + } + return true, Response{} +} + +func (r Request) needCalendarForAccount(accountId string) (bool, Response) { + if ok, resp := r.needCalendar(); !ok { + return ok, resp + } + account, ok := r.session.Accounts[accountId] + if !ok { + return false, errorResponseWithSessionState(r.apiError(&ErrorAccountNotFound), r.session.State) + } + if account.AccountCapabilities.Calendars == nil { + return false, errorResponseWithSessionState(r.apiError(&ErrorMissingCalendarsAccountCapability), r.session.State) + } + return true, Response{} +} + +func (r Request) needCalendarWithAccount() (bool, string, Response) { + accountId, err := r.GetAccountIdForCalendar() + if err != nil { + return false, "", errorResponse(err) + } + if ok, resp := r.needCalendarForAccount(accountId); !ok { + return false, accountId, resp + } + return true, accountId, Response{} +} + +func (r Request) needContact() (bool, Response) { + if r.session.Capabilities.Contacts == nil { + return false, errorResponseWithSessionState(r.apiError(&ErrorMissingContactsSessionCapability), r.session.State) + } + return true, Response{} +} + +func (r Request) needContactForAccount(accountId string) (bool, Response) { + if ok, resp := r.needContact(); !ok { + return ok, resp + } + account, ok := r.session.Accounts[accountId] + if !ok { + return false, errorResponseWithSessionState(r.apiError(&ErrorAccountNotFound), r.session.State) + } + if account.AccountCapabilities.Contacts == nil { + return false, errorResponseWithSessionState(r.apiError(&ErrorMissingContactsAccountCapability), r.session.State) + } + return true, Response{} +} + +func (r Request) needContactWithAccount() (bool, string, Response) { + accountId, err := r.GetAccountIdForContact() + if err != nil { + return false, "", errorResponse(err) + } + if ok, resp := r.needContactForAccount(accountId); !ok { + return false, accountId, resp + } + return true, accountId, Response{} +} diff --git a/services/groupware/pkg/groupware/groupware_response.go b/services/groupware/pkg/groupware/groupware_response.go index c8abb3222f..d380fa9e4b 100644 --- a/services/groupware/pkg/groupware/groupware_response.go +++ b/services/groupware/pkg/groupware/groupware_response.go @@ -23,6 +23,15 @@ func errorResponse(err *Error) Response { } } +func errorResponseWithSessionState(err *Error, sessionState jmap.SessionState) Response { + return Response{ + body: nil, + err: err, + etag: "", + sessionState: sessionState, + } +} + func response(body any, sessionState jmap.SessionState) Response { return Response{ body: body,