diff --git a/cassandane/tiny-tests/JMAPContacts/contact_copy_preserve_xprops b/cassandane/tiny-tests/JMAPContacts/contact_copy_preserve_xprops new file mode 100644 index 0000000000..e3a086ca2a --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_copy_preserve_xprops @@ -0,0 +1,101 @@ +#!perl +use Cassandane::Tiny; + +sub test_contact_copy_preserve_xprops + : needs_component_jmap { + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + my $admintalk = $self->{adminstore}->get_client(); + my $service = $self->{instance}->get_service("http"); + + xlog $self, "create shared account"; + $admintalk->create("user.other"); + + my $otherCarddav = Net::CardDAVTalk->new( + user => "other", + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/', + expandurl => 1, + ); + + my $otherJmap = Mail::JMAPTalk->new( + user => 'other', + password => 'pass', + host => $service->host(), + port => $service->port(), + scheme => 'http', + url => '/jmap/', + ); + $otherJmap->DefaultUsing([ + 'urn:ietf:params:jmap:core', + 'https://cyrusimap.org/ns/jmap/contacts', + 'https://cyrusimap.org/ns/jmap/debug' + ]); + + xlog $self, "share addressbook"; + $admintalk->setacl( + "user.other.#addressbooks.Default", + "cassandane" => 'lrswipkxtecdn' + ) or die; + + my $card = decode( + 'utf-8', <Request('PUT', 'Default/test.vcf', $card, 'Content-Type' => 'text/vcard'); + + my $res = $jmap->CallMethods([ + [ 'Contact/query', {}, 'R1' ], + ]); + $self->assert_num_equals(1, scalar @{ $res->[0][1]{ids} }); + my $contactId = $res->[0][1]{ids}[0]; + $self->assert_not_null($contactId); + + $res = $jmap->CallMethods([ + [ + 'Contact/copy', + { + fromAccountId => 'cassandane', + accountId => 'other', + create => { + contact1 => { + addressbookId => 'Default', + id => $contactId + } + }, + onSuccessDestroyOriginal => JSON::false, + }, + 'R1' + ], + ]); + my $copiedContactId = $res->[0][1]{created}{contact1}{id}; + $self->assert_not_null($copiedContactId); + + $res = $otherJmap->CallMethods([ + [ + 'Contact/get', + { + accountId => 'other', + ids => [$copiedContactId], + properties => [ 'x-href' ], + }, + 'R1' + ], + ]); + + $card = $otherCarddav->Request('GET', $res->[0][1]{list}[0]{'x-href'}); + $self->assert_matches(qr/^X-FOO;X-BAZ=Bam:Bar\r$/m, $card->{content}); +} diff --git a/cassandane/tiny-tests/JMAPContacts/contact_set_preserve_xprops b/cassandane/tiny-tests/JMAPContacts/contact_set_preserve_xprops new file mode 100644 index 0000000000..bade9479e9 --- /dev/null +++ b/cassandane/tiny-tests/JMAPContacts/contact_set_preserve_xprops @@ -0,0 +1,74 @@ +#!perl +use Cassandane::Tiny; +use Encode qw(decode); + +sub test_contact_set_preserve_xprops + : needs_component_jmap { + my ($self) = @_; + my $jmap = $self->{jmap}; + my $carddav = $self->{carddav}; + + xlog $self, "Create vCard with x-property"; + my $card = decode( + 'utf-8', <Request('PUT', 'Default/test.vcf', $card, 'Content-Type' => 'text/vcard'); + + xlog $self, "Update some contact property"; + my $res = $jmap->CallMethods([ + [ 'Contact/query', {}, 'R1' ], + [ + 'Contact/get', + { + '#ids' => { + resultOf => 'R1', + path => '/ids', + name => 'Contact/query', + }, + properties => ['lastName'], + }, + 'R2' + ], + ]); + + my $contactId = $res->[0][1]{ids}[0]; + $self->assert_not_null($contactId); + $self->assert_str_equals('Smith', $res->[1][1]{list}[0]{lastName}); + + $res = $jmap->CallMethods([ + [ + 'Contact/set', + { + update => { + $contactId => { + lastName => 'Kraut', + } + }, + }, + 'R1' + ], + [ + 'Contact/get', + { + ids => [$contactId], + properties => ['lastName'], + }, + 'R2' + ], + ]); + $self->assert_str_equals('Kraut', $res->[1][1]{list}[0]{lastName}); + + xlog $self, "Update x-property is preserved in vCard"; + $res = $carddav->Request('GET', 'Default/test.vcf'); + $self->assert_matches(qr/^X-FOO;X-BAZ=Bam:Bar\r?$/m, $res->{content}); +} diff --git a/imap/jmap_api.c b/imap/jmap_api.c index 5dc19f5869..9dbeb1a35e 100644 --- a/imap/jmap_api.c +++ b/imap/jmap_api.c @@ -1594,7 +1594,7 @@ HIDDEN void jmap_get_parse(jmap_req_t *req, propdef = NULL; } } - if (!propdef) { + if (!propdef || (propdef->flags & JMAP_PROP_REJECT_GET)) { jmap_parser_push_index(parser, "properties", i, name); jmap_parser_invalid(parser, NULL); jmap_parser_pop(parser); @@ -1706,6 +1706,9 @@ static void jmap_set_validate_props(jmap_req_t *req, const char *id, json_t *job else if (prop->capability && !jmap_is_using(req, prop->capability)) { json_array_append_new(invalid, json_string(path)); } + else if (prop->flags & JMAP_PROP_REJECT_SET) { + json_array_append_new(invalid, json_string(path)); + } else if (id) { /* update */ if (!strcmp("id", prop->name) && diff --git a/imap/jmap_api.h b/imap/jmap_api.h index af4efa0f04..d498f11869 100644 --- a/imap/jmap_api.h +++ b/imap/jmap_api.h @@ -332,7 +332,9 @@ enum { JMAP_PROP_SERVER_SET = (1<<0), JMAP_PROP_IMMUTABLE = (1<<1), JMAP_PROP_SKIP_GET = (1<<2), // skip in Foo/get if not requested by client - JMAP_PROP_ALWAYS_GET = (1<<3) // always include in Foo/get + JMAP_PROP_ALWAYS_GET = (1<<3), // always include in Foo/get + JMAP_PROP_REJECT_GET = (1<<4), // reject as unknown in Foo/get + JMAP_PROP_REJECT_SET = (1<<5) // reject as unknown in Foo/set }; extern const jmap_property_t *jmap_property_find(const char *name, diff --git a/imap/jmap_contact.c b/imap/jmap_contact.c index 4c33b1c887..12f5d95d5f 100644 --- a/imap/jmap_contact.c +++ b/imap/jmap_contact.c @@ -650,6 +650,11 @@ static const jmap_property_t contact_props[] = { NULL, 0 }, + { + "vCardProps", + NULL, + JMAP_PROP_REJECT_GET | JMAP_PROP_REJECT_SET | JMAP_PROP_SKIP_GET + }, /* FM extensions */ { @@ -1474,6 +1479,75 @@ static const char *_servicetype(const char *type) return type; } +static json_t *vcardprop_from_vcard_entry(struct vparse_entry *entry) +{ + struct buf buf = BUF_INITIALIZER; + + // Make jCard property. + json_t *vprop = json_array(); + + // Element 1: Property name. + buf_setcstr(&buf, entry->name); + json_array_append_new(vprop, json_string(buf_lcase(&buf))); + + // Element 2: Parameters. + json_t *vparams = json_object(); + for (struct vparse_param *param = entry->params; param; + param = param->next) { + if (strcasecmp(param->name, "value") && param->value) { + buf_setcstr(&buf, param->name); + json_object_set_new(vparams, buf_lcase(&buf), + json_string(param->value)); + } + } + + if (entry->group) { + // Set this after arbitrary parameters so that we overwrite + // any bogus GROUP parameter set in the vCard property. + buf_setcstr(&buf, entry->group); + json_object_set_new(vparams, "group", json_string(buf_lcase(&buf))); + } + json_array_append_new(vprop, vparams); + + // Element 3: Value type. + const char *value = NULL; + for (struct vparse_param *param = entry->params; param; + param = param->next) { + if (!strcasecmp(param->name, "value")) { + value = param->value; + break; + } + } + json_array_append_new(vprop, json_string(value ? value : "unknown")); + + // Element 4 and more: Property value. + if (entry->multivaluesep) { + // It's highly unlikely we'll encounter an unknown x-property + // for which we do know the multi-value separator. But even if + // we do, we can't rely on knowing it when we convert that + // jCard property value back to vCard. + // Instead, we'll join the multiple values by their separator + // and set a single string value in jCard, as these vCardProps + // are for internal use anyway. + buf_reset(&buf); + for (int i = 0; i < strarray_size(entry->v.values); i++) { + if (i) + buf_putc(&buf, entry->multivaluesep); + buf_appendcstr(&buf, strarray_nth(entry->v.values, i)); + } + json_array_append_new(vprop, json_string(buf_cstring(&buf))); + } + else if (entry->v.value) { + json_array_append_new(vprop, json_string(entry->v.value)); + } + if (json_array_size(vprop) == 3) { + json_array_append_new(vprop, json_string("")); + } + + buf_free(&buf); + return vprop; +} + /* Convert the VCARD card to jmap properties */ static json_t *jmap_contact_from_vcard(const char *userid, struct vparse_card *card, @@ -1792,6 +1866,22 @@ static json_t *jmap_contact_from_vcard(const char *userid, jmap_utf8string(entry->v.value)); } } + else if (!strncasecmp(entry->name, "x-", 2)) { + // Preserve unknown x-properties as RFC 7095 jCard. + json_t *vcardprops = json_object_get(obj, "vCardProps"); + if (!vcardprops) { + vcardprops = json_array(); + json_object_set_new(obj, "vCardProps", vcardprops); + } + + json_t *vprop = vcardprop_from_vcard_entry(entry); + if (vprop) { + json_array_append_new(vcardprops, vprop); + } + else if (!json_array_size(vcardprops)) { + json_object_del(obj, "vCardProps"); + } + } } if (defaultEmailIndex < 0) @@ -3706,6 +3796,57 @@ static void _make_fn(struct vparse_card *card) free(fn); } +static int vcardprop_to_vcard(json_t *vprop, struct vparse_card *card, int reject_iana) +{ + // Validate that it's a sane jCard value. + int is_valid = json_array_size(vprop) == 4 && + json_is_string(json_array_get(vprop, 0)) && + json_is_object(json_array_get(vprop, 1)) && + json_is_string(json_array_get(vprop, 2)) && + json_is_string(json_array_get(vprop, 3)); + + if (!is_valid) + return -1; + + + // Check if this is a X-property or IANA name. + if (reject_iana && + strncasecmp("X-", json_string_value(json_array_get(vprop, 0)), 2)) + return -1; + + // Validate parameter object. + const char *group = NULL; + const char *pname; + json_t *pval; + json_object_foreach(json_array_get(vprop, 1), pname, pval) { + if (!json_is_string(pval)) { + return -1; + } + if (!strcasecmp(pname, "group")) + group = json_string_value(pval); + } + + // Set vCard property. + struct buf buf = BUF_INITIALIZER; + + buf_setcstr(&buf, json_string_value(json_array_get(vprop, 0))); + buf_ucase(&buf); + struct vparse_entry *entry = + vparse_add_entry(card, group, buf_cstring(&buf), + json_string_value(json_array_get(vprop, 3))); + + json_object_foreach(json_array_get(vprop, 1), pname, pval) { + if (strcasecmp(pname, "group")) { + buf_setcstr(&buf, pname); + buf_ucase(&buf); + vparse_add_param(entry, buf_cstring(&buf), json_string_value(pval)); + } + } + + buf_free(&buf); + return 0; +} + static int _json_to_card(struct jmap_req *req, struct carddav_data *cdata, const char *mboxname, @@ -3955,6 +4096,7 @@ static int _json_to_card(struct jmap_req *req, pname = "X-PHONETIC-ORG"; if (json_is_string(jval) && pname) { + vparse_delete_entries(card, NULL, pname); buf_setcstr(&buf, json_string_value(jval)); buf_trim(&buf); if (buf_len(&buf)) { @@ -3965,6 +4107,24 @@ static int _json_to_card(struct jmap_req *req, json_array_append_new(invalid, json_string(key)); } } + else if (!strcmp(key, "vCardProps")) { + if (json_is_array(jval)) { + size_t i; + json_t *vprop; + json_array_foreach(jval, i, vprop) { + if (vcardprop_to_vcard(vprop, card, /*reject_iana*/1) < 0) { + // Report erroneous jCard property. + buf_reset(&buf); + buf_printf(&buf, "%s/%zu", key, i); + json_array_append_new(invalid, + json_string(buf_cstring(&buf))); + } + } + } + else { + json_array_append_new(invalid, json_string(key)); + } + } else { json_array_append_new(invalid, json_string(key)); }