From 4c1aadde85fe64d797ab3fb0cd8af50fb6baaec0 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Thu, 23 Oct 2025 13:34:30 +0200
Subject: [PATCH] groupware: add ContactCard operations
---
pkg/jmap/jmap_api_contact.go | 193 +++++++
pkg/jmap/jmap_api_email.go | 23 +-
pkg/jmap/jmap_client.go | 2 +-
pkg/jmap/jmap_http.go | 10 +-
pkg/jmap/jmap_model.go | 486 ++++++++++++++++++
pkg/jscontact/jscontact_model.go | 76 ++-
services/groupware/pkg/config/config.go | 1 +
.../pkg/config/defaults/defaultconfig.go | 1 +
.../pkg/groupware/groupware_api_contacts.go | 136 ++++-
.../pkg/groupware/groupware_error.go | 7 +
.../pkg/groupware/groupware_framework.go | 41 +-
.../pkg/groupware/groupware_request.go | 18 +-
.../pkg/groupware/groupware_route.go | 5 +
13 files changed, 930 insertions(+), 69 deletions(-)
create mode 100644 pkg/jmap/jmap_api_contact.go
diff --git a/pkg/jmap/jmap_api_contact.go b/pkg/jmap/jmap_api_contact.go
new file mode 100644
index 0000000000..0c958415f7
--- /dev/null
+++ b/pkg/jmap/jmap_api_contact.go
@@ -0,0 +1,193 @@
+package jmap
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/opencloud-eu/opencloud/pkg/jscontact"
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
+)
+
+type AddressBooksResponse struct {
+ AddressBooks []AddressBook `json:"addressbooks"`
+ NotFound []string `json:"notFound,omitempty"`
+ State State `json:"state"`
+}
+
+func (j *Client) GetAddressbooks(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (AddressBooksResponse, SessionState, Language, Error) {
+ logger = j.logger("GetAddressbooks", session, logger)
+
+ cmd, err := j.request(session, logger,
+ 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, Error) {
+ var response AddressBookGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandAddressBookGet, "0", &response)
+ if err != nil {
+ return AddressBooksResponse{}, err
+ }
+ return AddressBooksResponse{
+ AddressBooks: response.List,
+ NotFound: response.NotFound,
+ State: response.State,
+ }, nil
+ })
+}
+
+func (j *Client) QueryContactCards(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string,
+ filter ContactCardFilterElement, sortBy []ContactCardComparator,
+ position uint, limit uint) (map[string][]jscontact.ContactCard, SessionState, Language, Error) {
+ logger = j.logger("QueryContactCards", session, logger)
+
+ uniqueAccountIds := structs.Uniq(accountIds)
+
+ if sortBy == nil {
+ sortBy = []ContactCardComparator{{Property: jscontact.ContactCardPropertyUpdated, IsAscending: false}}
+ }
+
+ invocations := make([]Invocation, len(uniqueAccountIds)*2)
+ for i, accountId := range uniqueAccountIds {
+ query := ContactCardQueryCommand{
+ AccountId: accountId,
+ Filter: filter,
+ Sort: sortBy,
+ }
+ if limit > 0 {
+ query.Limit = limit
+ }
+ if position > 0 {
+ query.Position = position
+ }
+ invocations[i*2+0] = invocation(CommandContactCardQuery, query, mcid(accountId, "0"))
+ invocations[i*2+1] = invocation(CommandContactCardGet, ContactCardGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ Name: CommandContactCardQuery,
+ Path: "/ids/*",
+ ResultOf: mcid(accountId, "0"),
+ },
+ }, mcid(accountId, "1"))
+ }
+ cmd, err := j.request(session, logger, invocations...)
+ if err != nil {
+ return nil, "", "", err
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string][]jscontact.ContactCard, Error) {
+ resp := map[string][]jscontact.ContactCard{}
+ for _, accountId := range uniqueAccountIds {
+ var response ContactCardGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandContactCardGet, mcid(accountId, "1"), &response)
+ if err != nil {
+ return nil, err
+ }
+ if len(response.NotFound) > 0 {
+ // TODO what to do when there are not-found emails here? potentially nothing, they could have been deleted between query and get?
+ }
+ resp[accountId] = response.List
+ }
+ return resp, nil
+ })
+}
+
+type CreatedContactCard struct {
+ ContactCard *jscontact.ContactCard `json:"contactCard"`
+ State State `json:"state"`
+}
+
+func (j *Client) CreateContactCard(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create jscontact.ContactCard) (CreatedContactCard, SessionState, Language, Error) {
+ logger = j.logger("CreateContactCard", session, logger)
+
+ cmd, err := j.request(session, logger,
+ invocation(CommandContactCardSet, ContactCardSetCommand{
+ AccountId: accountId,
+ Create: map[string]jscontact.ContactCard{
+ "c": create,
+ },
+ }, "0"),
+ invocation(CommandContactCardGet, ContactCardGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ ResultOf: "0",
+ Name: CommandContactCardSet,
+ Path: "/created/c/id",
+ },
+ }, "1"),
+ )
+ if err != nil {
+ return CreatedContactCard{}, "", "", err
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (CreatedContactCard, Error) {
+ var setResponse ContactCardSetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandContactCardSet, "0", &setResponse)
+ if err != nil {
+ return CreatedContactCard{}, err
+ }
+
+ setErr, notok := setResponse.NotCreated["c"]
+ if notok {
+ logger.Error().Msgf("%T.NotCreated returned an error %v", setResponse, setErr)
+ return CreatedContactCard{}, setErrorError(setErr, EmailType)
+ }
+
+ if created, ok := setResponse.Created["c"]; !ok || created != nil {
+ berr := fmt.Errorf("failed to find %s in %s response", string(ContactCardType), string(CommandContactCardSet))
+ logger.Error().Err(berr)
+ return CreatedContactCard{}, simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ }
+
+ var getResponse ContactCardGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandContactCardGet, "1", &getResponse)
+ if err != nil {
+ return CreatedContactCard{}, err
+ }
+
+ if len(getResponse.List) < 1 {
+ berr := fmt.Errorf("failed to find %s in %s response", string(ContactCardType), string(CommandContactCardSet))
+ logger.Error().Err(berr)
+ return CreatedContactCard{}, simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ }
+
+ return CreatedContactCard{
+ ContactCard: &getResponse.List[0],
+ State: setResponse.NewState,
+ }, nil
+ })
+}
+
+type DeletedContactCards struct {
+ State State `json:"state"`
+ NotDestroyed map[string]SetError `json:"notDestroyed"`
+}
+
+func (j *Client) DeleteContactCard(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (DeletedContactCards, SessionState, Language, Error) {
+ logger = j.logger("DeleteContactCard", session, logger)
+
+ cmd, err := j.request(session, logger,
+ invocation(CommandContactCardSet, ContactCardSetCommand{
+ AccountId: accountId,
+ Destroy: destroy,
+ }, "0"),
+ )
+ if err != nil {
+ return DeletedContactCards{}, "", "", err
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (DeletedContactCards, Error) {
+ var setResponse ContactCardSetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandContactCardSet, "0", &setResponse)
+ if err != nil {
+ return DeletedContactCards{}, err
+ }
+ return DeletedContactCards{
+ State: setResponse.NewState,
+ NotDestroyed: setResponse.NotDestroyed,
+ }, nil
+ })
+}
diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go
index 2799c31878..48a731dee4 100644
--- a/pkg/jmap/jmap_api_email.go
+++ b/pkg/jmap/jmap_api_email.go
@@ -11,18 +11,6 @@ import (
"github.com/rs/zerolog"
)
-const (
- emailSortByReceivedAt = "receivedAt"
- emailSortBySize = "size"
- emailSortByFrom = "from"
- emailSortByTo = "to"
- emailSortBySubject = "subject"
- emailSortBySentAt = "sentAt"
- emailSortByHasKeyword = "hasKeyword"
- emailSortByAllInThreadHaveKeyword = "allInThreadHaveKeyword"
- emailSortBySomeInThreadHaveKeyword = "someInThreadHaveKeyword"
-)
-
type Emails struct {
Emails []Email `json:"emails,omitempty"`
Total uint `json:"total,omitzero"`
@@ -128,7 +116,7 @@ func (j *Client) GetAllEmailsInMailbox(accountId string, session *Session, ctx c
query := EmailQueryCommand{
AccountId: accountId,
Filter: &EmailFilterCondition{InMailbox: mailboxId},
- Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
+ Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: collapseThreads,
CalculateTotal: true,
}
@@ -276,7 +264,7 @@ func (j *Client) QueryEmailSnippets(accountIds []string, filter EmailFilterEleme
query := EmailQueryCommand{
AccountId: accountId,
Filter: filter,
- Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
+ Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: true,
CalculateTotal: true,
}
@@ -392,7 +380,7 @@ func (j *Client) QueryEmails(accountIds []string, filter EmailFilterElement, ses
query := EmailQueryCommand{
AccountId: accountId,
Filter: filter,
- Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
+ Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: true,
CalculateTotal: true,
}
@@ -474,7 +462,7 @@ func (j *Client) QueryEmailsWithSnippets(accountIds []string, filter EmailFilter
query := EmailQueryCommand{
AccountId: accountId,
Filter: filter,
- Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
+ Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: false,
CalculateTotal: true,
}
@@ -915,7 +903,6 @@ func (j *Client) EmailsInThread(accountId string, threadId string, session *Sess
}
return emailsResponse.List, nil
})
-
}
type EmailsSummary struct {
@@ -957,7 +944,7 @@ func (j *Client) QueryEmailSummaries(accountIds []string, session *Session, ctx
invocations[i*factor+0] = invocation(CommandEmailQuery, EmailQueryCommand{
AccountId: accountId,
Filter: filter,
- Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
+ Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
Limit: limit,
//CalculateTotal: false,
}, mcid(accountId, "0"))
diff --git a/pkg/jmap/jmap_client.go b/pkg/jmap/jmap_client.go
index 8268a0d0e3..118b33481f 100644
--- a/pkg/jmap/jmap_client.go
+++ b/pkg/jmap/jmap_client.go
@@ -100,7 +100,7 @@ func (j *Client) request(session *Session, logger *log.Logger, methodCalls ...In
return Request{}, err
}
return Request{
- Using: []string{JmapCore, JmapMail},
+ Using: []string{JmapCore, JmapMail, JmapContacts},
MethodCalls: methodCalls,
CreatedIds: nil,
}, nil
diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go
index 5e9ad47bfe..9c5bb08ae9 100644
--- a/pkg/jmap/jmap_http.go
+++ b/pkg/jmap/jmap_http.go
@@ -206,7 +206,7 @@ func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, sessio
if logger.Trace().Enabled() {
requestBytes, err := httputil.DumpRequestOut(req, true)
if err == nil {
- logger.Trace().Str(logEndpoint, endpoint).Msg(string(requestBytes))
+ logger.Trace().Str(logEndpoint, endpoint).Str("proto", "jmap").Str("type", "request").Msg(string(requestBytes))
}
}
h.auth(session.Username, logger, req)
@@ -217,6 +217,14 @@ func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, sessio
logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl)
return nil, "", SimpleError{code: JmapErrorSendingRequest, err: err}
}
+
+ if logger.Trace().Enabled() {
+ requestBytes, err := httputil.DumpResponse(res, true)
+ if err == nil {
+ logger.Trace().Str(logEndpoint, endpoint).Str("proto", "jmap").Str("type", "response").Msg(string(requestBytes))
+ }
+ }
+
language := Language(res.Header.Get("Content-Language"))
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index 389617fd61..4f1122fa34 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -5,6 +5,7 @@ import (
"time"
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
+ "github.com/opencloud-eu/opencloud/pkg/jscontact"
)
// https://www.iana.org/assignments/jmap/jmap.xml#jmap-data-types
@@ -4722,6 +4723,483 @@ type QuotaGetResponse struct {
NotFound []string `json:"notFound,omitempty"`
}
+type AddressBookGetCommand struct {
+ AccountId string `json:"accountId"`
+ Ids []string `json:"ids,omitempty"`
+}
+
+type AddressBookGetResponse struct {
+ AccountId string `json:"accountId"`
+ State State `json:"state,omitempty"`
+ List []AddressBook `json:"list,omitempty"`
+ NotFound []string `json:"notFound,omitempty"`
+}
+
+type ContacCardGetResponse struct {
+ AccountId string `json:"accountId"`
+ State State `json:"state,omitempty"`
+ List []AddressBook `json:"list,omitempty"`
+ NotFound []string `json:"notFound,omitempty"`
+}
+
+type ContactCardComparator struct {
+ // The name of the property on the objects to compare.
+ Property string `json:"property,omitempty"`
+
+ // If true, sort in ascending order.
+ //
+ // Optional; default value: true.
+ //
+ // If false, reverse the comparator’s results to sort in descending order.
+ IsAscending bool `json:"isAscending,omitempty"`
+
+ // The identifier, as registered in the collation registry defined in [RFC4790],
+ // for the algorithm to use when comparing the order of strings.
+ //
+ // Optional; default is server dependent.
+ //
+ // The algorithms the server supports are advertised in the capabilities object returned
+ // with the Session object.
+ //
+ // [RFC4790]: https://www.rfc-editor.org/rfc/rfc4790.html
+ Collation string `json:"collation,omitempty"`
+
+ // ContactCard-specific: The “created” date on the ContactCard.
+ Created time.Time `json:"created,omitzero"`
+
+ // ContactCard-specific: The "updated” date on the ContactCard.
+ Updated time.Time `json:"updated,omitzero"`
+}
+
+type ContactCardFilterElement interface {
+ _isAContactCardFilterElement() // marker method
+ IsNotEmpty() bool
+}
+
+type ContactCardFilterCondition struct {
+ // An AddressBook id.
+ //
+ // A card must be in this address book to match the condition.
+ InAddressBook string `json:"inAddressBook,omitempty"`
+
+ // A card must have this string exactly as its uid to match.
+ Uid string `json:"uid,omitempty"`
+
+ // A card must have a “members” property that contains this string as one of the uids in the set to match.
+ HasMember string `json:"hasMember,omitempty"`
+
+ // A card must have a type property that equals this string exactly to match.
+ Kind string `json:"kind,omitempty"`
+
+ // The “created” date-time of the ContactCard must be before this date-time to match the condition.
+ CreatedBefore UTCDate `json:"createdBefore,omitzero"`
+
+ // The “created” date-time of the ContactCard must be the same or after this date-time to match the condition.
+ CreatedAfter UTCDate `json:"createdAfter,omitzero"`
+
+ // The “updated” date-time of the ContactCard must be before this date-time to match the condition.
+ UpdatedBefore UTCDate `json:"updatedBefore,omitzero"`
+
+ // The “updated” date-time of the ContactCard must be the same or after this date-time to match the condition.
+ UpdatedAfter UTCDate `json:"updatedAfter,omitzero"`
+
+ // A card matches this condition if the text matches with text in the card.
+ Text string `json:"text,omitempty"`
+
+ // A card matches this condition if the value of any NameComponent in the “name” property, or the
+ // “full” property in the “name” property of the card matches the value.
+ Name string `json:"name,omitempty"`
+
+ // A card matches this condition if the value of a NameComponent with kind “given” inside the “name” property of
+ // the card matches the value.
+ NameGiven string `json:"name/given,omitempty"`
+
+ // A card matches this condition if the value of a NameComponent with kind “surname” inside the “name” property
+ // of the card matches the value.
+ NameSurname string `json:"name/surname,omitempty"`
+
+ // A card matches this condition if the value of a NameComponent with kind “surname2” inside the “name” property
+ // of the card matches the value.
+ NameSurname2 string `json:"name/surname2,omitempty"`
+
+ // A card matches this condition if the “name” of any NickName in the “nickNames” property of the card matches the value.
+ NickName string `json:"nickName,omitempty"`
+
+ // A card matches this condition if the “name” of any Organization in the “organizations” property of the card
+ // matches the value.
+ Organization string `json:"organization,omitempty"`
+
+ // A card matches this condition if the “address” or “label” of any EmailAddress in the “emails” property of the
+ // card matches the value.
+ Email string `json:"email,omitempty"`
+
+ // A card matches this condition if the “number” or “label” of any Phone in the “phones” property of the card
+ // matches the value.
+ Phone string `json:"phone,omitempty"`
+
+ // A card matches this condition if the “service”, “uri”, “user”, or “label” of any OnlineService in the
+ // “onlineServices” property of the card matches the value.
+ OnlineService string `json:"onlineService,omitempty"`
+
+ // A card matches this condition if the value of any StreetComponent in the “street” property, or the “locality”,
+ // “region”, “country”, or “postcode” property in any Address in the “addresses” property of the card matches the value.
+ Address string `json:"address,omitempty"`
+
+ // A card matches this condition if the “note” of any Note in the “notes” property of the card matches the value.
+ Note string `json:"note,omitempty"`
+}
+
+func (f ContactCardFilterCondition) _isAContactCardFilterElement() {
+}
+
+func (f ContactCardFilterCondition) IsNotEmpty() bool {
+ if len(f.InAddressBook) != 0 {
+ return true
+ }
+ if f.Uid != "" {
+ return true
+ }
+ if f.HasMember != "" {
+ return true
+ }
+ if f.Kind != "" {
+ return true
+ }
+ if !f.CreatedBefore.IsZero() {
+ return true
+ }
+ if !f.CreatedAfter.IsZero() {
+ return true
+ }
+ if !f.UpdatedBefore.IsZero() {
+ return true
+ }
+ if !f.UpdatedAfter.IsZero() {
+ return true
+ }
+ if f.Text != "" {
+ return true
+ }
+ if f.Name != "" {
+ return true
+ }
+ if f.NameGiven != "" {
+ return true
+ }
+ if f.NameSurname != "" {
+ return true
+ }
+ if f.NameSurname2 != "" {
+ return true
+ }
+ if f.NickName != "" {
+ return true
+ }
+ if f.Organization != "" {
+ return true
+ }
+ if f.Email != "" {
+ return true
+ }
+ if f.Phone != "" {
+ return true
+ }
+ if f.OnlineService != "" {
+ return true
+ }
+ if f.Address != "" {
+ return true
+ }
+ if f.Note != "" {
+ return true
+ }
+ return false
+}
+
+var _ ContactCardFilterElement = &ContactCardFilterCondition{}
+
+type ContactCardFilterOperator struct {
+ Operator FilterOperatorTerm `json:"operator"`
+ Conditions []ContactCardFilterElement `json:"conditions,omitempty"`
+}
+
+func (o ContactCardFilterOperator) _isAContactCardFilterElement() {
+}
+
+func (o ContactCardFilterOperator) IsNotEmpty() bool {
+ return len(o.Conditions) > 0
+}
+
+var _ ContactCardFilterElement = &ContactCardFilterOperator{}
+
+type ContactCardQueryCommand struct {
+ AccountId string `json:"accountId"`
+
+ Filter ContactCardFilterElement `json:"filter,omitempty"`
+
+ Sort []ContactCardComparator `json:"sort,omitempty"`
+
+ // The zero-based index of the first id in the full list of results to return.
+ //
+ // If a negative value is given, it is an offset from the end of the list.
+ // Specifically, the negative value MUST be added to the total number of results given
+ // the filter, and if still negative, it’s clamped to 0. This is now the zero-based
+ // index of the first id to return.
+ //
+ // If the index is greater than or equal to the total number of objects in the results
+ // list, then the ids array in the response will be empty, but this is not an error.
+ Position uint `json:"position,omitempty"`
+
+ // An Email id.
+ //
+ // If supplied, the position argument is ignored.
+ // The index of this id in the results will be used in combination with the anchorOffset
+ // argument to determine the index of the first result to return.
+ Anchor string `json:"anchor,omitempty"`
+
+ // The index of the first result to return relative to the index of the anchor,
+ // if an anchor is given.
+ //
+ // Default: 0.
+ //
+ // This MAY be negative.
+ //
+ // For example, -1 means the Email immediately preceding the anchor is the first result in
+ // the list returned.
+ AnchorOffset int `json:"anchorOffset,omitzero"`
+
+ // The maximum number of results to return.
+ //
+ // If null, no limit presumed.
+ // The server MAY choose to enforce a maximum limit argument.
+ // In this case, if a greater value is given (or if it is null), the limit is clamped
+ // to the maximum; the new limit is returned with the response so the client is aware.
+ //
+ // If a negative value is given, the call MUST be rejected with an invalidArguments error.
+ Limit uint `json:"limit,omitempty"`
+
+ // Does the client wish to know the total number of results in the query?
+ //
+ // This may be slow and expensive for servers to calculate, particularly with complex filters,
+ // so clients should take care to only request the total when needed.
+ CalculateTotal bool `json:"calculateTotal,omitempty"`
+}
+
+type ContactCardQueryResponse 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
+ // Email/queryChanges to get the difference.
+ QueryState State `json:"queryState"`
+
+ // This is true if the server supports calling ContactCard/queryChanges with these filter/sort parameters.
+ //
+ // Note, this does not guarantee that the ContactCard/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 uint `json:"position"`
+
+ // The list of ids for each ContactCard 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 ContactCards in the results (given the filter).
+ //
+ // Only if requested.
+ //
+ // This argument MUST be omitted if the calculateTotal request argument is not true.
+ Total uint `json:"total,omitempty,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 uint `json:"limit,omitempty,omitzero"`
+}
+
+type ContactCardGetCommand struct {
+ // The ids of the ContactCard objects to return.
+ //
+ // If null, then all records of the data type are returned, if this is supported for that
+ // data type and the number of records does not exceed the maxObjectsInGet limit.
+ Ids []string `json:"ids,omitempty"`
+
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // If supplied, only the properties listed in the array are returned for each ContactCard object.
+ //
+ // The id property of the object is always returned, even if not explicitly requested.
+ //
+ // If an invalid property is requested, the call MUST be rejected with an invalidArguments error.
+ Properties []string `json:"properties,omitempty"`
+}
+
+type ContactCardGetRefCommand struct {
+ // The ids of the ContactCard objects to return.
+ //
+ // If null, then all records of the data type are returned, if this is supported for that
+ // data type and the number of records does not exceed the maxObjectsInGet limit.
+ IdsRef *ResultReference `json:"#ids,omitempty"`
+
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // If supplied, only the properties listed in the array are returned for each ContactCard object.
+ //
+ // The id property of the object is always returned, even if not explicitly requested.
+ //
+ // If an invalid property is requested, the call MUST be rejected with an invalidArguments error.
+ Properties []string `json:"properties,omitempty"`
+}
+
+type ContactCardGetResponse 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 Email data is unchanged, servers SHOULD return the same state string on subsequent requests for this data type.
+ State State `json:"state"`
+
+ // An array of the ContactCard 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 []jscontact.ContactCard `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 []any `json:"notFound"`
+}
+
+type ContactCardUpdate map[string]any
+
+type ContactCardSetCommand struct {
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // This is a state string as returned by the `ContactCard/get` method.
+ //
+ // If supplied, the string must match the current state; otherwise, the method will be aborted and a
+ // `stateMismatch` error returned.
+ //
+ // If null, any changes will be applied to the current state.
+ IfInState string `json:"ifInState,omitempty"`
+
+ // A map of a creation id (a temporary id set by the client) to ContactCard objects,
+ // or null if no objects are to be created.
+ //
+ // The ContactCard object type definition may define default values for properties.
+ //
+ // Any such property may be omitted by the client.
+ //
+ // The client MUST omit any properties that may only be set by the server.
+ Create map[string]jscontact.ContactCard `json:"create,omitempty"`
+
+ // A map of an id to a `Patch` object to apply to the current Email object with that id,
+ // or null if no objects are to be updated.
+ //
+ // A `PatchObject` is of type `String[*]` and represents an unordered set of patches.
+ //
+ // The keys are a path in JSON Pointer Format [@!RFC6901], with an implicit leading `/` (i.e., prefix each key
+ // with `/` before applying the JSON Pointer evaluation algorithm).
+ //
+ // All paths MUST also conform to the following restrictions; if there is any violation, the update
+ // MUST be rejected with an `invalidPatch` error:
+ // !- The pointer MUST NOT reference inside an array (i.e., you MUST NOT insert/delete from an array; the array MUST be replaced in its entirety instead).
+ // !- All parts prior to the last (i.e., the value after the final slash) MUST already exist on the object being patched.
+ // !- There MUST NOT be two patches in the `PatchObject` where the pointer of one is the prefix of the pointer of the other, e.g., `"alerts/1/offset"` and `"alerts"`.
+ //
+ // The value associated with each pointer determines how to apply that patch:
+ // !- If null, set to the default value if specified for this property; otherwise, remove the property from the patched object. If the key is not present in the parent, this a no-op.
+ // !- Anything else: The value to set for this property (this may be a replacement or addition to the object being patched).
+ //
+ // Any server-set properties MAY be included in the patch if their value is identical to the current server value
+ // (before applying the patches to the object). Otherwise, the update MUST be rejected with an `invalidProperties` `SetError`.
+ //
+ // This patch definition is designed such that an entire Email object is also a valid `PatchObject`.
+ //
+ // The client may choose to optimise network usage by just sending the diff or may send the whole object; the server
+ // processes it the same either way.
+ Update map[string]ContactCardUpdate `json:"update,omitempty"`
+
+ // A list of ids for ContactCard objects to permanently delete, or null if no objects are to be destroyed.
+ Destroy []string `json:"destroy,omitempty"`
+}
+
+type ContactCardSetResponse struct {
+ // The id of the account used for the call.
+ AccountId string `json:"accountId"`
+
+ // The state string that would have been returned by ContactCard/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]*jscontact.ContactCard `json:"created,omitempty"`
+
+ // The keys in this map are the ids of all Emails that were successfully updated.
+ //
+ // The value for each id is an ContactCard 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]*jscontact.ContactCard `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 ErrorResponse struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
@@ -4748,6 +5226,10 @@ const (
CommandVacationResponseSet Command = "VacationResponse/set"
CommandSearchSnippetGet Command = "SearchSnippet/get"
CommandQuotaGet Command = "Quota/get"
+ CommandAddressBookGet Command = "AddressBook/get"
+ CommandContactCardQuery Command = "ContactCard/query"
+ CommandContactCardGet Command = "ContactCard/get"
+ CommandContactCardSet Command = "ContactCard/set"
)
var CommandResponseTypeMap = map[Command]func() any{
@@ -4770,4 +5252,8 @@ var CommandResponseTypeMap = map[Command]func() any{
CommandVacationResponseSet: func() any { return VacationResponseSetResponse{} },
CommandSearchSnippetGet: func() any { return SearchSnippetGetResponse{} },
CommandQuotaGet: func() any { return QuotaGetResponse{} },
+ CommandAddressBookGet: func() any { return AddressBookGetResponse{} },
+ CommandContactCardQuery: func() any { return ContactCardQueryResponse{} },
+ CommandContactCardGet: func() any { return ContactCardGetResponse{} },
+ CommandContactCardSet: func() any { return ContactCardSetResponse{} },
}
diff --git a/pkg/jscontact/jscontact_model.go b/pkg/jscontact/jscontact_model.go
index e9d3c4794f..36cfbd8739 100644
--- a/pkg/jscontact/jscontact_model.go
+++ b/pkg/jscontact/jscontact_model.go
@@ -2129,7 +2129,7 @@ type ContactCard struct {
// This is a JMAP extension and not part of [RFC9553].
//
// [RFC9553]: https://www.rfc-editor.org/rfc/rfc9553.html
- Id string `json:"id"`
+ Id string `json:"id,omitempty"`
// The set of AddressBook ids this Card belongs to.
//
@@ -2142,7 +2142,7 @@ type ContactCard struct {
// This is a JMAP extension and not part of [RFC9553].
//
// [RFC9553]: https://www.rfc-editor.org/rfc/rfc9553.html
- AddressBookIds map[string]bool `json:"addressBookIds"`
+ AddressBookIds map[string]bool `json:"addressBookIds,omitempty"`
// The JSContact type of the Card object: the value MUST be "Card".
Type TypeOfContactCard `json:"@type,omitempty"`
@@ -2376,3 +2376,75 @@ type ContactCard struct {
// The personal information of the entity represented by the Card.
PersonalInfo map[string]PersonalInfo `json:"personalInfo,omitempty"`
}
+
+const (
+ ContactCardPropertyId = "id"
+ ContactCardPropertyAddressBookIds = "addressBookIds"
+ ContactCardPropertyType = "@type"
+ ContactCardPropertyVersion = "version"
+ ContactCardPropertyCreated = "created"
+ ContactCardPropertyKind = "kind"
+ ContactCardPropertyLanguage = "language"
+ ContactCardPropertyMembers = "members"
+ ContactCardPropertyProdId = "prodId"
+ ContactCardPropertyRelatedTo = "relatedTo"
+ ContactCardPropertyUid = "uid"
+ ContactCardPropertyUpdated = "updated"
+ ContactCardPropertyName = "name"
+ ContactCardPropertyNicknames = "nicknames"
+ ContactCardPropertyOrganizations = "organizations"
+ ContactCardPropertySpeakToAs = "speakToAs"
+ ContactCardPropertyTitles = "titles"
+ ContactCardPropertyEmails = "emails"
+ ContactCardPropertyOnlineServices = "onlineServices"
+ ContactCardPropertyPhones = "phones"
+ ContactCardPropertyPreferredLanguages = "preferredLanguages"
+ ContactCardPropertyCalendars = "calendars"
+ ContactCardPropertySchedulingAddresses = "schedulingAddresses"
+ ContactCardPropertyAddresses = "addresses"
+ ContactCardPropertyCryptoKeys = "cryptoKeys"
+ ContactCardPropertyDirectories = "directories"
+ ContactCardPropertyLinks = "links"
+ ContactCardPropertyMedia = "media"
+ ContactCardPropertyLocalizations = "localizations"
+ ContactCardPropertyAnniversaries = "anniversaries"
+ ContactCardPropertyKeywords = "keywords"
+ ContactCardPropertyNotes = "notes"
+ ContactCardPropertyPersonalInfo = "personalInfo"
+)
+
+var ContactCardProperties = []string{
+ ContactCardPropertyId,
+ ContactCardPropertyAddressBookIds,
+ ContactCardPropertyType,
+ ContactCardPropertyVersion,
+ ContactCardPropertyCreated,
+ ContactCardPropertyKind,
+ ContactCardPropertyLanguage,
+ ContactCardPropertyMembers,
+ ContactCardPropertyProdId,
+ ContactCardPropertyRelatedTo,
+ ContactCardPropertyUid,
+ ContactCardPropertyUpdated,
+ ContactCardPropertyName,
+ ContactCardPropertyNicknames,
+ ContactCardPropertyOrganizations,
+ ContactCardPropertySpeakToAs,
+ ContactCardPropertyTitles,
+ ContactCardPropertyEmails,
+ ContactCardPropertyOnlineServices,
+ ContactCardPropertyPhones,
+ ContactCardPropertyPreferredLanguages,
+ ContactCardPropertyCalendars,
+ ContactCardPropertySchedulingAddresses,
+ ContactCardPropertyAddresses,
+ ContactCardPropertyCryptoKeys,
+ ContactCardPropertyDirectories,
+ ContactCardPropertyLinks,
+ ContactCardPropertyMedia,
+ ContactCardPropertyLocalizations,
+ ContactCardPropertyAnniversaries,
+ ContactCardPropertyKeywords,
+ ContactCardPropertyNotes,
+ ContactCardPropertyPersonalInfo,
+}
diff --git a/services/groupware/pkg/config/config.go b/services/groupware/pkg/config/config.go
index 60dcdc2e2b..38d5e2cde0 100644
--- a/services/groupware/pkg/config/config.go
+++ b/services/groupware/pkg/config/config.go
@@ -43,6 +43,7 @@ type Mail struct {
Timeout time.Duration `yaml:"timeout" env:"GROUPWARE_JMAP_TIMEOUT"`
DefaultEmailLimit uint `yaml:"default_email_limit" env:"GROUPWARE_DEFAULT_EMAIL_LIMIT"`
MaxBodyValueBytes uint `yaml:"max_body_value_bytes" env:"GROUPWARE_MAX_BODY_VALUE_BYTES"`
+ DefaultContactLimit uint `yaml:"default_contact_limit" env:"GROUPWARE_DEFAULT_CONTACT_LIMIT"`
ResponseHeaderTimeout time.Duration `yaml:"response_header_timeout" env:"GROUPWARE_RESPONSE_HEADER_TIMEOUT"`
PushHandshakeTimeout time.Duration `yaml:"push_handshake_timeout" env:"GROUPWARE_PUSH_HANDSHAKE_TIMEOUT"`
SessionCache MailSessionCache `yaml:"session_cache"`
diff --git a/services/groupware/pkg/config/defaults/defaultconfig.go b/services/groupware/pkg/config/defaults/defaultconfig.go
index a5b8f444ba..a5c0a55b6d 100644
--- a/services/groupware/pkg/config/defaults/defaultconfig.go
+++ b/services/groupware/pkg/config/defaults/defaultconfig.go
@@ -33,6 +33,7 @@ func DefaultConfig() *config.Config {
Timeout: 30 * time.Second,
DefaultEmailLimit: uint(0),
MaxBodyValueBytes: uint(0),
+ DefaultContactLimit: uint(0),
ResponseHeaderTimeout: 10 * time.Second,
PushHandshakeTimeout: 10 * time.Second,
SessionCache: config.MailSessionCache{
diff --git a/services/groupware/pkg/groupware/groupware_api_contacts.go b/services/groupware/pkg/groupware/groupware_api_contacts.go
index 4336002694..acca85eacc 100644
--- a/services/groupware/pkg/groupware/groupware_api_contacts.go
+++ b/services/groupware/pkg/groupware/groupware_api_contacts.go
@@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
+ "github.com/opencloud-eu/opencloud/pkg/log"
)
// When the request succeeds.
@@ -30,10 +31,13 @@ func (g *Groupware) GetAddressbooks(w http.ResponseWriter, r *http.Request) {
if !ok {
return resp
}
- var _ string = accountId
- // TODO replace with proper implementation
- return response(AllAddressBooks, req.session.State, "")
+ addressbooks, sessionState, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, req.logger, req.language(), nil)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ return response(addressbooks, sessionState, lang)
})
}
@@ -61,16 +65,23 @@ func (g *Groupware) GetAddressbook(w http.ResponseWriter, r *http.Request) {
if !ok {
return resp
}
- var _ string = accountId
+
+ l := req.logger.With()
addressBookId := chi.URLParam(r, UriParamAddressBookId)
- // TODO replace with proper implementation
- for _, ab := range AllAddressBooks {
- if ab.Id == addressBookId {
- return response(ab, req.session.State, "")
- }
+ l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
+
+ logger := log.From(l)
+ addressbooks, sessionState, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, logger, req.language(), []string{addressBookId})
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ if len(addressbooks.NotFound) > 0 {
+ return notFoundResponse(sessionState)
+ } else {
+ return response(addressbooks, sessionState, lang)
}
- return notFoundResponse(req.session.State)
})
}
@@ -96,14 +107,107 @@ func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Requ
if !ok {
return resp
}
- var _ string = accountId
+
+ l := req.logger.With()
addressBookId := chi.URLParam(r, UriParamAddressBookId)
- // TODO replace with proper implementation
- contactCards, ok := ContactsMapByAddressBookId[addressBookId]
- if !ok {
- return notFoundResponse(req.session.State)
+ l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
+
+ offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
+ if err != nil {
+ return errorResponse(err)
+ }
+ if ok {
+ l = l.Uint(QueryParamOffset, offset)
+ }
+
+ limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaultContactLimit)
+ if err != nil {
+ return errorResponse(err)
+ }
+ if ok {
+ l = l.Uint(QueryParamLimit, limit)
+ }
+
+ filter := jmap.ContactCardFilterCondition{
+ InAddressBook: addressBookId,
+ }
+ sortBy := []jmap.ContactCardComparator{{Property: jscontact.ContactCardPropertyUpdated, IsAscending: false}}
+
+ logger := log.From(l)
+ contactsByAccountId, sessionState, lang, jerr := g.jmap.QueryContactCards([]string{accountId}, req.session, req.ctx, logger, req.language(), filter, sortBy, offset, limit)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ if contacts, ok := contactsByAccountId[accountId]; ok {
+ return response(contacts, req.session.State, lang)
+ } else {
+ return notFoundResponse(sessionState)
}
- return response(contactCards, req.session.State, "")
+ })
+}
+
+func (g *Groupware) CreateContact(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 := chi.URLParam(r, UriParamAddressBookId)
+ l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
+
+ var create jscontact.ContactCard
+ err := req.body(&create)
+ if err != nil {
+ return errorResponse(err)
+ }
+
+ logger := log.From(l)
+ created, sessionState, lang, jerr := g.jmap.CreateContactCard(accountId, req.session, req.ctx, logger, req.language(), create)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+ return etagResponse(created.ContactCard, sessionState, created.State, lang)
+ })
+}
+
+func (g *Groupware) DeleteContact(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))
+
+ contactId := chi.URLParam(r, UriParamContactId)
+ l.Str(UriParamContactId, log.SafeString(contactId))
+
+ logger := log.From(l)
+
+ deleted, sessionState, _, jerr := g.jmap.DeleteContactCard(accountId, []string{contactId}, req.session, req.ctx, logger, req.language())
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ for _, e := range deleted.NotDestroyed {
+ desc := e.Description
+ if desc != "" {
+ return errorResponseWithSessionState(apiError(
+ req.errorId(),
+ ErrorFailedToDeleteContact,
+ withDetail(e.Description),
+ ), sessionState)
+ } else {
+ return errorResponseWithSessionState(apiError(
+ req.errorId(),
+ ErrorFailedToDeleteContact,
+ ), sessionState)
+ }
+ }
+ return noContentResponseWithEtag(sessionState, deleted.State)
})
}
diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go
index 9a284a521c..bb586d3928 100644
--- a/services/groupware/pkg/groupware/groupware_error.go
+++ b/services/groupware/pkg/groupware/groupware_error.go
@@ -197,6 +197,7 @@ const (
ErrorCodeFailedToDeleteEmail = "DELEML"
ErrorCodeFailedToDeleteSomeIdentities = "DELSID"
ErrorCodeFailedToSanitizeEmail = "FSANEM"
+ ErrorCodeFailedToDeleteContact = "DELCNT"
)
var (
@@ -434,6 +435,12 @@ var (
Title: "Failed to sanitize an email",
Detail: "Email content sanitization failed.",
}
+ ErrorFailedToDeleteContact = GroupwareError{
+ Status: http.StatusInternalServerError,
+ Code: ErrorCodeFailedToDeleteContact,
+ Title: "Failed to delete contacts",
+ Detail: "One or more contacts could not be deleted.",
+ }
)
type ErrorOpt interface {
diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go
index 1bd4bc9695..f8bb5c5dbb 100644
--- a/services/groupware/pkg/groupware/groupware_framework.go
+++ b/services/groupware/pkg/groupware/groupware_framework.go
@@ -86,11 +86,12 @@ type Groupware struct {
// unfortunately, the sse implementation does not provide such a function.
// Key: the stream ID, which is the username
// Value: the timestamp of the creation of the stream
- streams cmap.ConcurrentMap
- logger *log.Logger
- defaultEmailLimit uint
- maxBodyValueBytes uint
- sanitize bool
+ streams cmap.ConcurrentMap
+ logger *log.Logger
+ defaultEmailLimit uint
+ defaultContactLimit uint
+ maxBodyValueBytes uint
+ sanitize bool
// Caches successful and failed Sessions by the username.
sessionCache sessionCache
jmap *jmap.Client
@@ -176,6 +177,7 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
defaultEmailLimit := max(config.Mail.DefaultEmailLimit, 0)
maxBodyValueBytes := max(config.Mail.MaxBodyValueBytes, 0)
+ defaultContactLimit := max(config.Mail.DefaultContactLimit, 0)
responseHeaderTimeout := max(config.Mail.ResponseHeaderTimeout, 0)
sessionCacheMaxCapacity := uint64(max(config.Mail.SessionCache.MaxCapacity, 0))
sessionCacheTtl := max(config.Mail.SessionCache.Ttl, 0)
@@ -332,20 +334,21 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
}
g := &Groupware{
- mux: mux,
- metrics: m,
- sseServer: sseServer,
- streams: cmap.New(),
- logger: logger,
- sessionCache: sessionCache,
- userProvider: userProvider,
- jmap: &jmapClient,
- defaultEmailLimit: defaultEmailLimit,
- maxBodyValueBytes: maxBodyValueBytes,
- sanitize: sanitize,
- eventChannel: eventChannel,
- jobsChannel: jobsChannel,
- jobCounter: atomic.Uint64{},
+ mux: mux,
+ metrics: m,
+ sseServer: sseServer,
+ streams: cmap.New(),
+ logger: logger,
+ sessionCache: sessionCache,
+ userProvider: userProvider,
+ jmap: &jmapClient,
+ defaultEmailLimit: defaultEmailLimit,
+ defaultContactLimit: defaultContactLimit,
+ maxBodyValueBytes: maxBodyValueBytes,
+ sanitize: sanitize,
+ eventChannel: eventChannel,
+ jobsChannel: jobsChannel,
+ jobCounter: atomic.Uint64{},
}
for w := 1; w <= workerPoolSize; w++ {
diff --git a/services/groupware/pkg/groupware/groupware_request.go b/services/groupware/pkg/groupware/groupware_request.go
index 680fd49323..2a9b4e3a0b 100644
--- a/services/groupware/pkg/groupware/groupware_request.go
+++ b/services/groupware/pkg/groupware/groupware_request.go
@@ -395,10 +395,8 @@ func (r Request) needCalendarWithAccount() (bool, string, Response) {
}
func (r Request) needContact() (bool, Response) {
- if !IgnoreSessionCapabilityChecks {
- if r.session.Capabilities.Contacts == nil {
- return false, errorResponseWithSessionState(r.apiError(&ErrorMissingContactsSessionCapability), r.session.State)
- }
+ if r.session.Capabilities.Contacts == nil {
+ return false, errorResponseWithSessionState(r.apiError(&ErrorMissingContactsSessionCapability), r.session.State)
}
return true, Response{}
}
@@ -411,10 +409,8 @@ func (r Request) needContactForAccount(accountId string) (bool, Response) {
if !ok {
return false, errorResponseWithSessionState(r.apiError(&ErrorAccountNotFound), r.session.State)
}
- if !IgnoreSessionCapabilityChecks {
- if account.AccountCapabilities.Contacts == nil {
- return false, errorResponseWithSessionState(r.apiError(&ErrorMissingContactsAccountCapability), r.session.State)
- }
+ if account.AccountCapabilities.Contacts == nil {
+ return false, errorResponseWithSessionState(r.apiError(&ErrorMissingContactsAccountCapability), r.session.State)
}
return true, Response{}
}
@@ -424,10 +420,8 @@ func (r Request) needContactWithAccount() (bool, string, Response) {
if err != nil {
return false, "", errorResponse(err)
}
- if !IgnoreSessionCapabilityChecks {
- if ok, resp := r.needContactForAccount(accountId); !ok {
- return false, accountId, resp
- }
+ if ok, resp := r.needContactForAccount(accountId); !ok {
+ return false, accountId, resp
}
return true, accountId, Response{}
}
diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go
index e9a0bb8029..6b8ad6cea4 100644
--- a/services/groupware/pkg/groupware/groupware_route.go
+++ b/services/groupware/pkg/groupware/groupware_route.go
@@ -22,6 +22,7 @@ const (
UriParamAddressBookId = "addressbookid"
UriParamCalendarId = "calendarid"
UriParamTaskListId = "tasklistid"
+ UriParamContactId = "contactid"
QueryParamMailboxSearchName = "name"
QueryParamMailboxSearchRole = "role"
QueryParamMailboxSearchSubscribed = "subscribed"
@@ -115,6 +116,10 @@ func (g *Groupware) Route(r chi.Router) {
r.Get("/", g.GetAddressbooks)
r.Get("/{addressbookid}", g.GetAddressbook)
r.Get("/{addressbookid}/contacts", g.GetContactsInAddressbook)
+ r.Route("/contacts", func(r chi.Router) {
+ r.Post("/", g.CreateContact)
+ r.Delete("/{contactid}", g.DeleteContact)
+ })
})
r.Route("/calendars", func(r chi.Router) {
r.Get("/", g.GetCalendars)