Skip to main content

kernoid_client/
contacts.rs

1// SPDX-License-Identifier: MIT
2//! Contacts over JMAP (RFC 9610): the address books of every account the user reaches, and their cards as JSContact –
3//! read, made, changed and removed.
4
5use std::collections::BTreeMap;
6
7use serde::Deserialize;
8use serde_json::{Map, Value, json};
9
10use crate::{CONTACTS, Client, Error, Result, ids_of};
11
12#[derive(Debug, Clone, Default, Deserialize)]
13pub struct NameComponent {
14    #[serde(default)]
15    pub kind: String,
16    #[serde(default)]
17    pub value: String,
18}
19
20#[derive(Debug, Clone, Default, Deserialize)]
21pub struct Name {
22    #[serde(default)]
23    pub full: Option<String>,
24    #[serde(default)]
25    pub components: Vec<NameComponent>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct EmailEntry {
30    pub address: String,
31    /// `work`, `private` (RFC 9553, 1.5.1).
32    #[serde(default)]
33    pub contexts: Option<BTreeMap<String, bool>>,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37pub struct Phone {
38    pub number: String,
39    #[serde(default)]
40    pub contexts: Option<BTreeMap<String, bool>>,
41    /// `mobile`, `voice`, `fax`, `text` … (RFC 9553, 2.3.3).
42    #[serde(default)]
43    pub features: Option<BTreeMap<String, bool>>,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47pub struct Organization {
48    #[serde(default)]
49    pub name: Option<String>,
50}
51
52#[derive(Debug, Clone, Deserialize)]
53pub struct Note {
54    pub note: String,
55}
56
57#[derive(Debug, Clone, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct Card {
60    pub id: String,
61    #[serde(default)]
62    pub address_book_ids: BTreeMap<String, bool>,
63    #[serde(default)]
64    pub name: Option<Name>,
65    #[serde(default)]
66    pub emails: Option<BTreeMap<String, EmailEntry>>,
67    #[serde(default)]
68    pub phones: Option<BTreeMap<String, Phone>>,
69    #[serde(default)]
70    pub organizations: Option<BTreeMap<String, Organization>>,
71    #[serde(default)]
72    pub notes: Option<BTreeMap<String, Note>>,
73    #[serde(skip)]
74    pub account: String,
75}
76
77impl Card {
78    /// The name to show: the full name, the parts of the name, or the first address.
79    pub fn full_name(&self) -> String {
80        if let Some(full) = self.name.as_ref().and_then(|name| name.full.clone()).filter(|full| !full.is_empty()) {
81            return full;
82        }
83        let parts: Vec<&str> = self
84            .name
85            .iter()
86            .flat_map(|name| name.components.iter())
87            .filter(|part| !part.value.is_empty() && part.kind != "separator")
88            .map(|part| part.value.as_str())
89            .collect();
90        if !parts.is_empty() {
91            return parts.join(" ");
92        }
93        self.emails().into_iter().next().unwrap_or_default()
94    }
95
96    pub fn emails(&self) -> Vec<String> {
97        self.emails.iter().flat_map(|map| map.values()).map(|entry| entry.address.clone()).collect()
98    }
99
100    pub fn phones(&self) -> Vec<String> {
101        self.phones.iter().flat_map(|map| map.values()).map(|phone| phone.number.clone()).collect()
102    }
103
104    pub fn organization(&self) -> Option<String> {
105        let names: Vec<_> =
106            self.organizations.iter().flat_map(|map| map.values()).filter_map(|o| o.name.clone()).collect();
107        (!names.is_empty()).then(|| names.join(", "))
108    }
109
110    pub fn notes(&self) -> Vec<String> {
111        self.notes.iter().flat_map(|map| map.values()).map(|note| note.note.clone()).collect()
112    }
113}
114
115#[derive(Deserialize)]
116struct List<T> {
117    list: Vec<T>,
118}
119
120/// The cards of every account with contacts, `text` searching them; sorted by name.
121pub async fn cards(client: &Client, text: &str) -> Result<Vec<Card>> {
122    let mut out = Vec::new();
123    for (account, _) in client.session.accounts_with(CONTACTS) {
124        let mut filter = Map::new();
125        if !text.trim().is_empty() {
126            filter.insert("text".into(), text.trim().into());
127        }
128        let responses = client
129            .request(
130                &[CONTACTS],
131                vec![
132                    ("ContactCard/query", json!({ "accountId": account, "filter": filter }), "q"),
133                    ("ContactCard/get", json!({ "accountId": account, "#ids": ids_of("q", "ContactCard/query") }), "g"),
134                ],
135            )
136            .await?;
137        let value = responses.get("g").cloned().unwrap_or(json!({ "list": [] }));
138        let cards = serde_json::from_value::<List<Card>>(value).map_err(|error| Error::Protocol(error.to_string()))?;
139        out.extend(cards.list.into_iter().map(|mut card| {
140            card.account = account.to_owned();
141            card
142        }));
143    }
144    out.sort_by_key(|card| card.full_name().to_lowercase());
145    Ok(out)
146}
147
148#[derive(Debug, Clone, Copy, Default, Deserialize)]
149#[serde(rename_all = "camelCase", default)]
150pub struct BookRights {
151    pub may_write: bool,
152}
153
154#[derive(Debug, Clone, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct AddressBook {
157    pub id: String,
158    pub name: String,
159    #[serde(default)]
160    pub is_default: bool,
161    #[serde(default)]
162    pub my_rights: Option<BookRights>,
163    #[serde(skip)]
164    pub account: String,
165    /// The name of the account, for address books shared with the user.
166    #[serde(skip)]
167    pub shared: Option<String>,
168}
169
170impl AddressBook {
171    pub fn writable(&self) -> bool {
172        self.my_rights.is_none_or(|rights| rights.may_write)
173    }
174}
175
176/// The address books of every account with contacts; the user's default first.
177pub async fn address_books(client: &Client) -> Result<Vec<AddressBook>> {
178    let mut out = Vec::new();
179    for (id, account) in client.session.accounts_with(CONTACTS) {
180        let result = client.call(&[CONTACTS], "AddressBook/get", json!({ "accountId": id })).await?;
181        let books =
182            serde_json::from_value::<List<AddressBook>>(result).map_err(|error| Error::Protocol(error.to_string()))?;
183        out.extend(books.list.into_iter().map(|mut book| {
184            book.account = id.to_owned();
185            book.shared = (!account.is_personal).then(|| account.name.clone());
186            book
187        }));
188    }
189    out.sort_by_key(|book| (book.shared.is_some(), !book.is_default, book.name.to_lowercase()));
190    Ok(out)
191}
192
193/// The contexts a value may have (RFC 9553, 1.5.1); for phones, other labels are its features.
194pub const CONTEXTS: [&str; 2] = ["work", "private"];
195/// The features of a phone (RFC 9553, 2.3.3).
196pub const FEATURES: [&str; 7] = ["mobile", "voice", "text", "video", "fax", "pager", "textphone"];
197
198/// A value with its labels – contexts, and for a phone its features: `alice@x.example (work)`.
199#[derive(Debug, Clone, Default, PartialEq, Eq)]
200pub struct Labelled {
201    pub value: String,
202    pub labels: Vec<String>,
203}
204
205impl Labelled {
206    fn of(value: &str, maps: &[&Option<BTreeMap<String, bool>>]) -> Self {
207        let labels = maps
208            .iter()
209            .flat_map(|map| map.iter().flat_map(|map| map.iter()))
210            .filter(|(_, on)| **on)
211            .map(|(label, _)| label.clone())
212            .collect();
213        Self { value: value.to_owned(), labels }
214    }
215
216    /// As the editor shows it.
217    pub fn render(&self) -> String {
218        if self.labels.is_empty() { self.value.clone() } else { format!("{} ({})", self.value, self.labels.join(", ")) }
219    }
220
221    /// From the editor; `allowed` are the labels it may have.
222    pub fn parse(text: &str, allowed: &[&str]) -> std::result::Result<Self, String> {
223        let text = text.trim();
224        let (value, labels) = match text.strip_suffix(')').and_then(|rest| rest.rsplit_once('(')) {
225            Some((value, labels)) => {
226                (value.trim(), labels.split(',').map(|label| label.trim().to_lowercase()).collect())
227            }
228            None => (text, Vec::new()),
229        };
230        let labels: Vec<String> = labels.into_iter().filter(|label: &String| !label.is_empty()).collect();
231        if let Some(unknown) = labels.iter().find(|label| !allowed.contains(&label.as_str())) {
232            return Err(format!("unknown label {unknown} – one of {}", allowed.join(", ")));
233        }
234        Ok(Self { value: value.to_owned(), labels })
235    }
236
237    fn set(&self, among: &[&str]) -> Value {
238        let on: Map<String, Value> = self
239            .labels
240            .iter()
241            .filter(|label| among.contains(&label.as_str()))
242            .map(|label| (label.clone(), json!(true)))
243            .collect();
244        if on.is_empty() { Value::Null } else { Value::Object(on) }
245    }
246}
247
248/// A card as the user writes it: every address, phone number and organisation, with their labels.
249#[derive(Debug, Clone, Default, PartialEq, Eq)]
250pub struct CardForm {
251    pub name: String,
252    pub emails: Vec<Labelled>,
253    pub phones: Vec<Labelled>,
254    pub organizations: Vec<String>,
255    pub note: String,
256}
257
258impl CardForm {
259    pub fn of(card: &Card) -> Self {
260        Self {
261            name: card.full_name(),
262            emails: card
263                .emails
264                .iter()
265                .flat_map(|map| map.values())
266                .map(|entry| Labelled::of(&entry.address, &[&entry.contexts]))
267                .collect(),
268            phones: card
269                .phones
270                .iter()
271                .flat_map(|map| map.values())
272                .map(|phone| Labelled::of(&phone.number, &[&phone.contexts, &phone.features]))
273                .collect(),
274            organizations: card
275                .organizations
276                .iter()
277                .flat_map(|map| map.values())
278                .filter_map(|organization| organization.name.clone())
279                .collect(),
280            note: card.notes().join("\n"),
281        }
282    }
283
284    /// The JSContact properties (without `addressBookIds`); empty fields remove what was.
285    pub fn object(&self) -> Value {
286        let map = |prefix: &str, values: Vec<Value>| -> Value {
287            if values.is_empty() {
288                return Value::Null;
289            }
290            Value::Object(
291                values.into_iter().enumerate().map(|(n, value)| (format!("{prefix}{}", n + 1), value)).collect(),
292            )
293        };
294        let emails = self
295            .emails
296            .iter()
297            .map(|email| json!({ "@type": "EmailAddress", "address": email.value, "contexts": email.set(&CONTEXTS) }))
298            .collect();
299        let phones = self
300            .phones
301            .iter()
302            .map(|phone| {
303                json!({ "@type": "Phone", "number": phone.value, "contexts": phone.set(&CONTEXTS),
304                        "features": phone.set(&FEATURES) })
305            })
306            .collect();
307        let organizations =
308            self.organizations.iter().map(|name| json!({ "@type": "Organization", "name": name })).collect();
309        json!({
310            "name": { "full": self.name },
311            "emails": map("e", emails),
312            "phones": map("p", phones),
313            "organizations": map("o", organizations),
314            "notes": if self.note.is_empty() { Value::Null } else { json!({ "n1": { "@type": "Note", "note": self.note } }) },
315        })
316    }
317}
318
319pub async fn create(client: &Client, book: &AddressBook, form: &CardForm) -> Result<()> {
320    let mut object = form.object();
321    object["@type"] = "Card".into();
322    object["version"] = "1.0".into();
323    object["addressBookIds"] = json!({ book.id.as_str(): true });
324    client.set(&[CONTACTS], "ContactCard", &book.account, json!({ "create": { "c": object } }), json!({})).await?;
325    Ok(())
326}
327
328/// Changes a card, and moves it to `book` (of the same account).
329pub async fn update(client: &Client, book: &AddressBook, id: &str, form: &CardForm) -> Result<()> {
330    let mut object = form.object();
331    object["addressBookIds"] = json!({ book.id.as_str(): true });
332    client.set(&[CONTACTS], "ContactCard", &book.account, json!({ "update": { id: object } }), json!({})).await?;
333    Ok(())
334}
335
336/// Cards as stored – every property –, for writing them to a file.
337pub async fn raw_cards(client: &Client, account: &str, ids: &[String]) -> Result<Vec<Map<String, Value>>> {
338    let result = client.call(&[CONTACTS], "ContactCard/get", json!({ "accountId": account, "ids": ids })).await?;
339    Ok(result
340        .get("list")
341        .and_then(Value::as_array)
342        .map(|list| list.iter().filter_map(|card| card.as_object().cloned()).collect())
343        .unwrap_or_default())
344}
345
346/// Makes the cards read from a file in `book`; how many were made, and why the others were not.
347pub async fn import(
348    client: &Client,
349    book: &AddressBook,
350    cards: Vec<Map<String, Value>>,
351) -> Result<(usize, Vec<String>)> {
352    let create: Map<String, Value> = cards
353        .into_iter()
354        .enumerate()
355        .map(|(n, mut card)| {
356            card.insert("addressBookIds".into(), json!({ book.id.as_str(): true }));
357            (format!("i{n}"), Value::Object(card))
358        })
359        .collect();
360    let result =
361        client.call(&[CONTACTS], "ContactCard/set", json!({ "accountId": book.account, "create": create })).await?;
362    Ok(crate::calendars::made_and_refused(&result))
363}
364
365pub async fn destroy(client: &Client, account: &str, id: &str) -> Result<()> {
366    client.set(&[CONTACTS], "ContactCard", account, json!({ "destroy": [id] }), json!({})).await?;
367    Ok(())
368}