/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ use crate::utils::{ jmap::{ChangeType, IntoJmapSet, JmapUtils}, server::TestServer, }; use ahash::AHashSet; use calcard::jscontact::JSContactProperty; use dav_proto::Depth; use groupware::{DavResourceName, cache::GroupwareCache}; use hyper::StatusCode; use jmap_proto::request::method::MethodObject; use serde_json::{Value, json}; use std::str::FromStr; use types::{collection::SyncCollection, id::Id}; pub async fn test(test: &TestServer) { println!("Running Contact Card tests..."); let account = test.account("jdoe@example.com"); // Create test address books let response = account .jmap_create( MethodObject::AddressBook, [ json!({ "name": "Test #1", }), json!({ "name": "Test #2", }), ], Vec::<(&str, &str)>::new(), ) .await; let book1_id = response.created(0).id().to_string(); let book2_id = response.created(1).id().to_string(); // Obtain state let change_id = account .jmap_get( MethodObject::ContactCard, Vec::<&str>::new(), Vec::<&str>::new(), ) .await .state() .to_string(); // Create test contacts let sarah_contact = test_jscontact_1().with_property( JSContactProperty::::AddressBookIds, [book1_id.as_str()].into_jmap_set(), ); let carlos_contact = test_jscontact_2().with_property( JSContactProperty::::AddressBookIds, [book2_id.as_str()].into_jmap_set(), ); let acme_contact = test_jscontact_3().with_property( JSContactProperty::::AddressBookIds, [book1_id.as_str(), book2_id.as_str()].into_jmap_set(), ); let tmp_contact = test_jscontact_4().with_property( JSContactProperty::::AddressBookIds, [book2_id.as_str()].into_jmap_set(), ); let response = account .jmap_create( MethodObject::ContactCard, [ sarah_contact.clone(), carlos_contact.clone(), acme_contact.clone(), tmp_contact, ], Vec::<(&str, &str)>::new(), ) .await; let sarah_contact_id = response.created(0).id().to_string(); let carlos_contact_id = response.created(1).id().to_string(); let acme_contact_id = response.created(2).id().to_string(); let tmp_contact_id = response.created(3).id().to_string(); // Destroy tmp contact test.wait_for_tasks().await; assert_eq!( account .jmap_destroy( MethodObject::ContactCard, [tmp_contact_id.as_str()], Vec::<(&str, &str)>::new(), ) .await .destroyed() .next(), Some(tmp_contact_id.as_str()) ); // Validate changes assert_eq!( account .jmap_changes(MethodObject::ContactCard, &change_id) .await .changes() .collect::>(), [ ChangeType::Created(&sarah_contact_id), ChangeType::Created(&carlos_contact_id), ChangeType::Created(&acme_contact_id) ] .into_iter() .collect::>(), ); // Fetch contacts and verify let response = account .jmap_get( MethodObject::ContactCard, Vec::<&str>::new(), [&sarah_contact_id, &carlos_contact_id, &acme_contact_id], ) .await; response.list()[0].assert_is_equal( sarah_contact.with_property(JSContactProperty::::Id, sarah_contact_id.as_str()), ); response.list()[1].assert_is_equal( carlos_contact.with_property(JSContactProperty::::Id, carlos_contact_id.as_str()), ); response.list()[2].assert_is_equal( acme_contact.with_property(JSContactProperty::::Id, acme_contact_id.as_str()), ); let response = account .jmap_method_calls(json!([[ "ContactCard/get", { "accountId": account.id_string(), "properties": [], "ids": [&sarah_contact_id, &carlos_contact_id], }, "0" ]])) .await; response.list()[0].assert_is_equal(json!({ "id": &sarah_contact_id })); response.list()[1].assert_is_equal(json!({ "id": &carlos_contact_id })); // Creating a contact without address book should fail assert_eq!( account .jmap_create( MethodObject::ContactCard, [json!({ "name": { "full": "Simple Contact", }, "addressBookIds": {}, }),], Vec::<(&str, &str)>::new() ) .await .not_created(0) .description(), "Contact has to belong to at least one address book." ); // Creating a contact with a duplicate UID should fail assert!( account .jmap_create( MethodObject::ContactCard, [json!({ "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "name": { "full": "Simple Contact", }, "addressBookIds": { &book1_id: true }, }),], Vec::<(&str, &str)>::new() ) .await .not_created(0) .description() .contains( "Contact with UID urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 already exists" ), ); // Patching tests let response = account .jmap_update( MethodObject::ContactCard, [ ( &sarah_contact_id, json!({ "name/full": "Sarah O'Connor", "name/components/0/value": "O'Connor", format!("addressBookIds/{book2_id}"): true }), ), ( &carlos_contact_id, json!({ "addressBookIds": { &book1_id: true, &book2_id: true }, "nicknames/k1": (), "nicknames/k2": { "name": "Carlitos" }, }), ), ( &acme_contact_id, json!({ format!("addressBookIds/{book2_id}"): false, "keywords/B2B": false, "keywords/B2C": true, }), ), ], Vec::<(&str, &str)>::new(), ) .await; response.updated(&sarah_contact_id); response.updated(&carlos_contact_id); response.updated(&acme_contact_id); // Verify patches let response = account .jmap_get( MethodObject::ContactCard, [ JSContactProperty::::Id, JSContactProperty::AddressBookIds, JSContactProperty::Name, JSContactProperty::Keywords, JSContactProperty::Nicknames, ], [&sarah_contact_id, &carlos_contact_id, &acme_contact_id], ) .await; response.list()[0].assert_is_equal(json!({ "id": &sarah_contact_id, "name": { "full": "Sarah O'Connor", "components": [ { "kind": "surname", "value": "O'Connor" }, { "kind": "given", "value": "Sarah" }, { "kind": "given2", "value": "Marie" }, { "kind": "title", "value": "Dr." }, { "kind": "credential", "value": "Ph.D." } ], "isOrdered": true }, "nicknames": { "k1": { "name": "Sadie" } }, "keywords": { "Work": true, "Research": true, "VIP": true }, "addressBookIds": { &book1_id: true, &book2_id: true }, })); response.list()[1].assert_is_equal(json!({ "id": &carlos_contact_id, "name": { "components": [ { "kind": "surname", "value": "Rodriguez-Martinez" }, { "kind": "given", "value": "Carlos" }, { "kind": "given2", "value": "Alberto" }, { "kind": "title", "value": "Mr." }, { "kind": "credential", "value": "Jr." } ], "isOrdered": true, "full": "Carlos Rodriguez-Martinez" }, "keywords": { "Marketing": true, "Management": true, "International": true }, "nicknames": { "k2": { "name": "Carlitos" } }, "addressBookIds": { &book1_id: true, &book2_id: true }, })); response.list()[2].assert_is_equal(json!({ "id": acme_contact_id, "addressBookIds": { &book1_id: true, }, "name": { "full": "Acme Business Solutions Ltd." }, "keywords": { "Technology": true, "B2C": true, "Solutions": true, "Services": true } })); // Query tests test.wait_for_tasks().await; let email = if !test.server.search_store().is_mysql() { "sarah.johnson@example.com" } else { "sarah.johnson@example" }; assert_eq!( account .jmap_query( MethodObject::ContactCard, [ ("text", "Sarah"), ("inAddressBook", book1_id.as_str()), ("uid", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), ("email", email), ], ["created"], Vec::<(&str, &str)>::new(), ) .await .ids() .collect::>(), [sarah_contact_id.as_str()] .into_iter() .collect::>() ); // Parse tests account .jmap_method_calls(json!([ [ "Blob/upload", { "accountId": account.id_string(), "create": { "vcard": { "data": [ { "data:asText": r#"BEGIN:VCARD VERSION:4.0 KIND:individual FN:Jane Doe ORG:ABC\, Inc.;North American Division;Marketing END:VCARD"# } ] } } }, "S4" ], [ "ContactCard/parse", { "accountId": account.id_string(), "blobIds": [ "#vcard" ] }, "G4" ] ])) .await .pointer("/methodResponses/1/1/parsed") .unwrap() .as_object() .unwrap() .iter() .next() .unwrap() .1 .assert_is_equal(json!({ "name": { "full": "Jane Doe" }, "version": "1.0", "vCard": { "properties": [ [ "version", {}, "unknown", "4.0" ] ] }, "organizations": { "k1": { "name": "ABC, Inc.", "units": [ { "name": "North American Division" }, { "name": "Marketing" } ] } }, "@type": "Card", "kind": "individual" })); // Deletion tests test.wait_for_tasks().await; assert_eq!( account .jmap_destroy( MethodObject::ContactCard, [carlos_contact_id.as_str(), acme_contact_id.as_str()], Vec::<(&str, &str)>::new() ) .await .destroyed() .collect::>(), [carlos_contact_id.as_str(), acme_contact_id.as_str()] .into_iter() .collect::>() ); // CardDAV compatibility tests let account_id = account.id().document_id(); let dav_client = account.webdav_client(); let resources = test .server .fetch_dav_resources(account_id, account_id, SyncCollection::AddressBook) .await .unwrap(); let path = format!( "{}{}", resources.base_path, resources .paths .iter() .find(|v| v.parent_id.is_some()) .unwrap() .path ); let vcard = dav_client .request("GET", &path, "") .await .with_status(StatusCode::OK) .expect_body() .lines() .map(String::from) .collect::>(); let expected_vcard = TEST_VCARD_1 .lines() .map(String::from) .collect::>(); assert_eq!(vcard, expected_vcard); // Moving a card between address books tombstones the previous CardDAV href test.wait_for_tasks().await; let card_base_path = format!("{}/jdoe%40example.com/", DavResourceName::Card.base_path()); let sync_token = dav_client .sync_collection(&card_base_path, "", Depth::Infinity, None, ["D:getetag"]) .await .sync_token() .to_string(); let moved_card_id = account .jmap_create( MethodObject::ContactCard, [json!({ "@type": "Card", "uid": "9b1f6c22-2f0e-4d6b-8f3a-1c7f2b5d9e10", "name": { "full": "Moving Contact", }, "addressBookIds": { &book1_id: true }, })], Vec::<(&str, &str)>::new(), ) .await .created(0) .id() .to_string(); let response = dav_client .sync_collection( &card_base_path, &sync_token, Depth::Infinity, None, ["D:getetag"], ) .await .with_href_count(1); let sync_token = response.sync_token().to_string(); let href_in_book1 = response.hrefs()[0].to_string(); account .jmap_update( MethodObject::ContactCard, [( &moved_card_id, json!({ "addressBookIds": { &book2_id: true } }), )], Vec::<(&str, &str)>::new(), ) .await .updated(&moved_card_id); let response = dav_client .sync_collection( &card_base_path, &sync_token, Depth::Infinity, None, ["D:getetag"], ) .await .with_href_count(2); let sync_token = response.sync_token().to_string(); let href_in_book2 = response .hrefs() .into_iter() .find(|href| *href != href_in_book1) .unwrap() .to_string(); let response = response.into_propfind_response(None); response .properties(&href_in_book1) .with_status(StatusCode::NOT_FOUND); response .properties(&href_in_book2) .with_status(StatusCode::OK); account .jmap_destroy( MethodObject::ContactCard, [moved_card_id.as_str()], Vec::<(&str, &str)>::new(), ) .await .assert_destroyed(&[Id::from_str(&moved_card_id).unwrap()]); dav_client .sync_collection( &card_base_path, &sync_token, Depth::Infinity, None, ["D:getetag"], ) .await .with_href_count(1) .into_propfind_response(None) .properties(&href_in_book2) .with_status(StatusCode::NOT_FOUND); // Clean up test.wait_for_tasks().await; account.destroy_all_addressbooks().await; test.assert_is_empty().await; } fn test_jscontact_1() -> Value { json!({ "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "@type": "Card", "preferredLanguages": { "k1": { "language": "en", "contexts": { "work": true }, "pref": 1 }, "k2": { "language": "fr", "contexts": { "work": true }, "pref": 2 } }, "name": { "full": "Sarah Johnson", "components": [ { "kind": "surname", "value": "Johnson" }, { "kind": "given", "value": "Sarah" }, { "kind": "given2", "value": "Marie" }, { "kind": "title", "value": "Dr." }, { "kind": "credential", "value": "Ph.D." } ], "isOrdered": true }, "cryptoKeys": { "k1": { "uri": "https://pgp.example.com/pks/lookup?op=get&search=sarah.johnson@example.com", "contexts": { "pgp": true } } }, "keywords": { "Work": true, "Research": true, "VIP": true }, "anniversaries": { "k1": { "date": { "@type": "PartialDate", "year": 1985, "month": 4, "day": 15 }, "kind": "birth" }, "k2": { "date": { "@type": "PartialDate", "year": 2010, "month": 6, "day": 10 }, "kind": "wedding" } }, "links": { "k1": { "uri": "https://www.example.com/staff/sjohnson", "contexts": { "work": true } }, "k2": { "uri": "https://www.sarahjohnson.example.com", "contexts": { "private": true } } }, "organizations": { "k1": { "name": "Acme Technologies Inc.", "units": [ { "name": "Research Department" } ] } }, "emails": { "k1": { "address": "sarah.johnson@example.com", "contexts": { "work": true } }, "k2": { "address": "sarahjpersonal@example.com", "contexts": { "private": true, "pref": true } } }, "phones": { "k1": { "number": "+1-555-123-4567", "contexts": { "pref": true }, "features": { "mobile": true, "voice": true } }, "k2": { "number": "+1-555-987-6543", "contexts": { "work": true }, "features": { "voice": true } }, "k3": { "number": "+1-555-456-7890", "contexts": { "private": true }, "features": { "voice": true } } }, "version": "1.0", "addresses": { "k1": { "contexts": { "work": true }, "full": "123 Business Ave\nSuite 400\nNew York, NY 10001\nUSA", "components": [ { "kind": "name", "value": "123 Business Ave" }, { "kind": "locality", "value": "New York" }, { "kind": "region", "value": "NY" }, { "kind": "postcode", "value": "10001" }, { "kind": "country", "value": "USA" } ], "timeZone": "Etc/GMT+5", "coordinates": "40.7128;-74.0060", "isOrdered": true }, "k2": { "contexts": { "private": true, "pref": true }, "full": "456 Residential St\nApt 7B\nBrooklyn, NY 11201\nUSA", "components": [ { "kind": "name", "value": "456 Residential St" }, { "kind": "locality", "value": "Brooklyn" }, { "kind": "region", "value": "NY" }, { "kind": "postcode", "value": "11201" }, { "kind": "country", "value": "USA" } ], "isOrdered": true } }, "titles": { "k1": { "name": "Senior Research Scientist", "kind": "title" }, "k2": { "name": "Team Lead", "kind": "role", "organizationId": "k1" } }, "nicknames": { "k1": { "name": "Sadie" } }, "notes": { "k1": { "note": "Sarah prefers video calls over phone calls. Available Mon-Thu 9-5 EST." } }, "updated": "2022-03-15T13:30:00Z" }) } fn test_jscontact_2() -> Value { json!({ "phones": { "k1": { "number": "+34-611-234-567", "contexts": { "pref": true }, "features": { "mobile": true, "voice": true } }, "k2": { "number": "+34-911-876-543", "contexts": { "work": true }, "features": { "voice": true } }, "k3": { "number": "+34-644-321-987", "contexts": { "private": true }, "features": { "voice": true } }, "k4": { "number": "+34-911-876-544", "features": { "fax": true } } }, "keywords": { "Marketing": true, "Management": true, "International": true }, "kind": "individual", "anniversaries": { "k1": { "date": { "@type": "PartialDate", "month": 6, "day": 23 }, "kind": "birth" }, "k2": { "date": { "@type": "PartialDate", "year": 2015, "month": 8, "day": 9 }, "kind": "wedding" } }, "members": { "urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af": true }, "uid": "urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686a", "name": { "components": [ { "kind": "surname", "value": "Rodriguez-Martinez" }, { "kind": "given", "value": "Carlos" }, { "kind": "given2", "value": "Alberto" }, { "kind": "title", "value": "Mr." }, { "kind": "credential", "value": "Jr." } ], "full": "Carlos Rodriguez-Martinez", "isOrdered": true }, "nicknames": { "k1": { "name": "Charlie" } }, "relatedTo": { "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": { "relation": { "friend": true } } }, "emails": { "k1": { "address": "carlos.rodriguez@example-corp.com", "contexts": { "work": true, "pref": true } }, "k2": { "address": "carlosrm@personalmail.example", "contexts": { "private": true } } }, "directories": { "k1": { "uri": "https://contacts.example.com/carlosrodriguez.vcf", "kind": "entry" } }, "cryptoKeys": { "k1": { "uri": "https://pgp.example.com/pks/lookup?op=get&search=carlos.rodriguez@example-corp.com", "contexts": { "pgp": true } } }, "version": "1.0", "notes": { "k1": { "note": "Carlos speaks English, Spanish, and Portuguese fluently. Prefers communication via email. Do not contact after 7PM CET." } }, "updated": "2023-07-12T09:21:35Z", "links": { "k1": { "uri": "https://www.example-corp.com/team/carlos", "contexts": { "work": true } }, "k2": { "uri": "https://www.carlosrodriguez.example", "contexts": { "private": true } }, "k3": { "uri": "https://linkedin.com/in/carlosrodriguezm", "contexts": { "social": true } } }, "@type": "Card", "titles": { "k1": { "name": "Digital Marketing Director", "kind": "title" }, "k2": { "name": "Department Head", "kind": "role", "organizationId": "k1" } }, "preferredLanguages": { "k1": { "language": "es", "contexts": { "work": true }, "pref": 1 }, "k2": { "language": "en", "contexts": { "work": true }, "pref": 2 }, "k3": { "language": "pt", "contexts": { "work": true }, "pref": 3 } }, "addresses": { "k1": { "contexts": { "work": true }, "full": "Calle Empresarial 42\nPlanta 3\nMadrid, 28001\nSpain", "components": [ { "kind": "name", "value": "Calle Empresarial 42" }, { "kind": "locality", "value": "Madrid" }, { "kind": "postcode", "value": "28001" }, { "kind": "country", "value": "Spain" } ], "timeZone": "Etc/GMT-1", "coordinates": "40.4168;-3.7038", "isOrdered": true }, "k2": { "contexts": { "private": true, "pref": true }, "full": "Avenida Residencial 15\nPiso 7, Puerta C\nMadrid, 28045\nSpain", "components": [ { "kind": "name", "value": "Avenida Residencial 15" }, { "kind": "locality", "value": "Madrid" }, { "kind": "postcode", "value": "28045" }, { "kind": "country", "value": "Spain" } ], "isOrdered": true } }, "organizations": { "k1": { "name": "Global Solutions S.L.", "units": [ { "name": "Marketing Division" } ] } } }) } fn test_jscontact_3() -> Value { json!({ "kind": "org", "organizations": { "k1": { "name": "Acme Business Solutions Ltd.", "units": [ { "name": "Technology Division" } ] } }, "preferredLanguages": { "k1": { "language": "en", "contexts": { "work": true }, "pref": 1 }, "k2": { "language": "de", "contexts": { "work": true }, "pref": 2 }, "k3": { "language": "fr", "contexts": { "work": true }, "pref": 3 } }, "directories": { "k1": { "uri": "https://directory.example.com/acme.vcf", "kind": "entry" } }, "cryptoKeys": { "k1": { "uri": "https://pgp.example.com/pks/lookup?op=get&search=info@acme-solutions.example", "contexts": { "pgp": true } } }, "links": { "k1": { "uri": "https://www.acme-solutions.example", "contexts": { "work": true } }, "k2": { "uri": "https://support.acme-solutions.example", "contexts": { "support": true } } }, "name": { "full": "Acme Business Solutions Ltd." }, "notes": { "k1": { "note": "Business hours: Mon-Fri 9:00-17:30 GMT. Closed on UK bank holidays. VAT Reg: GB123456789" } }, "uid": "urn:uuid:a9e95948-7b1c-46e8-bd85-c729a9e910f2", "@type": "Card", "prodId": "-//Example Corp.//Contact Manager 3.0//EN", "version": "1.0", "emails": { "k1": { "address": "info@acme-solutions.example", "contexts": { "work": true, "pref": true } }, "k2": { "address": "support@acme-solutions.example", "contexts": { "support": true } }, "k3": { "address": "sales@acme-solutions.example", "contexts": { "sales": true } } }, "phones": { "k1": { "number": "+44-20-1234-5678", "contexts": { "work": true, "pref": true }, "features": { "voice": true } }, "k2": { "number": "+44-20-1234-5679", "features": { "fax": true } }, "k3": { "number": "+44-800-987-6543", "contexts": { "support": true } } }, "addresses": { "k1": { "contexts": { "work": true }, "full": "10 Enterprise Way\nTech Park\nLondon, EC1A 1BB\nUnited Kingdom", "components": [ { "kind": "name", "value": "10 Enterprise Way, Tech Park" }, { "kind": "locality", "value": "London" }, { "kind": "postcode", "value": "EC1A 1BB" }, { "kind": "country", "value": "United Kingdom" } ], "timeZone": "Etc/UTC", "coordinates": "51.5074;-0.1278", "isOrdered": true }, "k2": { "contexts": { "branch": true }, "full": "25 Innovation Street\nManchester, M1 5QF\nUnited Kingdom", "components": [ { "kind": "name", "value": "25 Innovation Street" }, { "kind": "locality", "value": "Manchester" }, { "kind": "postcode", "value": "M1 5QF" }, { "kind": "country", "value": "United Kingdom" } ], "isOrdered": true } }, "updated": "2023-04-15T15:30:00Z", "keywords": { "Technology": true, "B2B": true, "Solutions": true, "Services": true }, "relatedTo": { "urn:uuid:b9e93fdb-4d34-45fa-a1e2-47da0428c4a1": { "relation": { "contact": true } }, "urn:uuid:c8e74dfe-6b34-45fa-b1e2-47ea0428c4b2": { "relation": { "contact": true } } } }) } fn test_jscontact_4() -> Value { json!({ "@type": "Card", "version": "1.0", "kind": "individual", "name": { "@type": "Name", "full": "Temporary Contact" }}) } const TEST_VCARD_1: &str = r#"BEGIN:VCARD VERSION:4.0 UID:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 LANG;TYPE=WORK;PREF=1;PROP-ID=k1:en LANG;TYPE=WORK;PREF=2;PROP-ID=k2:fr FN:Sarah O'Connor N;JSCOMPS=";0;1;2;3;4":O'Connor;Sarah;Marie;Dr.;Ph.D.;; KEY;TYPE=PGP;PROP-ID=k1:https://pgp.example.com/pks/lookup?op=get&search=sa rah.johnson@example.com CATEGORIES:Work,Research,VIP BDAY;PROP-ID=k1:19850415 ANNIVERSARY;PROP-ID=k2:20100610 URL;TYPE=WORK;PROP-ID=k1:https://www.example.com/staff/sjohnson URL;TYPE=HOME;PROP-ID=k2:https://www.sarahjohnson.example.com ORG;PROP-ID=k1:Acme Technologies Inc.;Research Department EMAIL;TYPE=WORK;PROP-ID=k1:sarah.johnson@example.com EMAIL;TYPE=HOME,PREF;PROP-ID=k2:sarahjpersonal@example.com TEL;TYPE=PREF,CELL,VOICE;PROP-ID=k1:+1-555-123-4567 TEL;TYPE=WORK,VOICE;PROP-ID=k2:+1-555-987-6543 TEL;TYPE=HOME,VOICE;PROP-ID=k3:+1-555-456-7890 ADR;TYPE=WORK;LABEL="123 Business Ave\nSuite 400\nNew York, NY 10001\nUSA"; TZ=Etc/GMT+5;GEO="40.7128;-74.0060";PROP-ID=k1;JSCOMPS=";11;3;4;5;6":;;123 Business Ave;New York;NY;10001;USA;;;;;123 Business Ave;;;;;; ADR;TYPE=HOME,PREF;LABEL="456 Residential St\nApt 7B\nBrooklyn, NY 11201\nU SA";PROP-ID=k2;JSCOMPS=";11;3;4;5;6":;;456 Residential St;Brooklyn;NY;1120 1;USA;;;;;456 Residential St;;;;;; TITLE;PROP-ID=k1:Senior Research Scientist JSPROP;JSPTR=titles/k2/organizationId:"k1" ROLE;PROP-ID=k2:Team Lead NICKNAME;PROP-ID=k1:Sadie NOTE;PROP-ID=k1:Sarah prefers video calls over phone calls. Available Mon-T hu 9-5 EST. REV:20220315T133000Z END:VCARD "#;