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)