diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index 7b9590d92..86e947d67 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -32,230 +32,11 @@ use std::ops::Deref; use std::sync::LazyLock; use anyhow::bail; -use anyhow::Context as _; use anyhow::Result; -use chrono::{DateTime, NaiveDateTime}; use regex::Regex; -#[derive(Debug)] -/// A Contact, as represented in a VCard. -pub struct VcardContact { - /// The email address, vcard property `email` - pub addr: String, - /// This must be the name authorized by the contact itself, not a locally given name. Vcard - /// property `fn`. Can be empty, one should use `display_name()` to obtain the display name. - pub authname: String, - /// The contact's public PGP key in Base64, vcard property `key` - pub key: Option, - /// The contact's profile image (=avatar) in Base64, vcard property `photo` - pub profile_image: Option, - /// The timestamp when the vcard was created / last updated, vcard property `rev` - pub timestamp: Result, -} - -impl VcardContact { - /// Returns the contact's display name. - pub fn display_name(&self) -> &str { - match self.authname.is_empty() { - false => &self.authname, - true => &self.addr, - } - } -} - -/// Returns a vCard containing given contacts. -/// -/// Calling [`parse_vcard()`] on the returned result is a reverse operation. -pub fn make_vcard(contacts: &[VcardContact]) -> String { - fn format_timestamp(c: &VcardContact) -> Option { - let timestamp = *c.timestamp.as_ref().ok()?; - let datetime = DateTime::from_timestamp(timestamp, 0)?; - Some(datetime.format("%Y%m%dT%H%M%SZ").to_string()) - } - - let mut res = "".to_string(); - for c in contacts { - let addr = &c.addr; - let display_name = c.display_name(); - res += &format!( - "BEGIN:VCARD\r\n\ - VERSION:4.0\r\n\ - EMAIL:{addr}\r\n\ - FN:{display_name}\r\n" - ); - if let Some(key) = &c.key { - res += &format!("KEY:data:application/pgp-keys;base64,{key}\r\n"); - } - if let Some(profile_image) = &c.profile_image { - res += &format!("PHOTO:data:image/jpeg;base64,{profile_image}\r\n"); - } - if let Some(timestamp) = format_timestamp(c) { - res += &format!("REV:{timestamp}\r\n"); - } - res += "END:VCARD\r\n"; - } - res -} - -/// Parses `VcardContact`s from a given `&str`. -pub fn parse_vcard(vcard: &str) -> Vec { - fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { - let start_of_s = s.get(..prefix.len())?; - - if start_of_s.eq_ignore_ascii_case(prefix) { - s.get(prefix.len()..) - } else { - None - } - } - /// Returns (parameters, value) tuple. - fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, &'a str)> { - let remainder = remove_prefix(line, property)?; - // If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`, - // then `remainder` is now `;TYPE=work:alice@example.com` - - // Note: This doesn't handle the case where there are quotes around a colon, - // like `NAME;Foo="Some quoted text: that contains a colon":value`. - // This could be improved in the future, but for now, the parsing is good enough. - let (mut params, value) = remainder.split_once(':')?; - // In the example from above, `params` is now `;TYPE=work` - // and `value` is now `alice@example.com` - - if params - .chars() - .next() - .filter(|c| !c.is_ascii_punctuation() || *c == '_') - .is_some() - { - // `s` started with `property`, but the next character after it was not punctuation, - // so this line's property is actually something else - return None; - } - if let Some(p) = remove_prefix(params, ";") { - params = p; - } - if let Some(p) = remove_prefix(params, "PREF=1") { - params = p; - } - Some((params, value)) - } - fn base64_key(line: &str) -> Option<&str> { - let (params, value) = vcard_property(line, "key")?; - if params.eq_ignore_ascii_case("PGP;ENCODING=BASE64") - || params.eq_ignore_ascii_case("TYPE=PGP;ENCODING=b") - { - return Some(value); - } - if let Some(value) = remove_prefix(value, "data:application/pgp-keys;base64,") - .or_else(|| remove_prefix(value, r"data:application/pgp-keys;base64\,")) - { - return Some(value); - } - - None - } - fn base64_photo(line: &str) -> Option<&str> { - let (params, value) = vcard_property(line, "photo")?; - if params.eq_ignore_ascii_case("JPEG;ENCODING=BASE64") - || params.eq_ignore_ascii_case("ENCODING=BASE64;JPEG") - || params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=b") - || params.eq_ignore_ascii_case("ENCODING=b;TYPE=JPEG") - || params.eq_ignore_ascii_case("ENCODING=BASE64;TYPE=JPEG") - || params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=BASE64") - { - return Some(value); - } - if let Some(value) = remove_prefix(value, "data:image/jpeg;base64,") - .or_else(|| remove_prefix(value, r"data:image/jpeg;base64\,")) - { - return Some(value); - } - - None - } - fn parse_datetime(datetime: &str) -> Result { - // According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp - // is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses - // ISO.8601, but fails to parse any of the examples given. - // So, instead just parse using a format string. - - // Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500. - let timestamp = match DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") { - Ok(datetime) => datetime.timestamp(), - // Parses 19961022T140000. - Err(e) => match NaiveDateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S") { - Ok(datetime) => datetime - .and_local_timezone(chrono::offset::Local) - .single() - .context("Could not apply local timezone to parsed date and time")? - .timestamp(), - Err(_) => return Err(e.into()), - }, - }; - Ok(timestamp) - } - - // Remove line folding, see https://datatracker.ietf.org/doc/html/rfc6350#section-3.2 - static NEWLINE_AND_SPACE_OR_TAB: LazyLock = - LazyLock::new(|| Regex::new("\r?\n[\t ]").unwrap()); - let unfolded_lines = NEWLINE_AND_SPACE_OR_TAB.replace_all(vcard, ""); - - let mut lines = unfolded_lines.lines().peekable(); - let mut contacts = Vec::new(); - - while lines.peek().is_some() { - // Skip to the start of the vcard: - for line in lines.by_ref() { - if line.eq_ignore_ascii_case("BEGIN:VCARD") { - break; - } - } - - let mut display_name = None; - let mut addr = None; - let mut key = None; - let mut photo = None; - let mut datetime = None; - - for mut line in lines.by_ref() { - if let Some(remainder) = remove_prefix(line, "item1.") { - // Remove the group name, if the group is called "item1". - // If necessary, we can improve this to also remove groups that are called something different that "item1". - // - // Search "group name" at https://datatracker.ietf.org/doc/html/rfc6350 for more infos. - line = remainder; - } - - if let Some((_params, email)) = vcard_property(line, "email") { - addr.get_or_insert(email); - } else if let Some((_params, name)) = vcard_property(line, "fn") { - display_name.get_or_insert(name); - } else if let Some(k) = base64_key(line) { - key.get_or_insert(k); - } else if let Some(p) = base64_photo(line) { - photo.get_or_insert(p); - } else if let Some((_params, rev)) = vcard_property(line, "rev") { - datetime.get_or_insert(rev); - } else if line.eq_ignore_ascii_case("END:VCARD") { - let (authname, addr) = - sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or("")); - - contacts.push(VcardContact { - authname, - addr, - key: key.map(|s| s.to_string()), - profile_image: photo.map(|s| s.to_string()), - timestamp: datetime - .context("No timestamp in vcard") - .and_then(parse_datetime), - }); - break; - } - } - } - - contacts -} +mod vcard; +pub use vcard::{make_vcard, parse_vcard, VcardContact}; /// Valid contact address. #[derive(Debug, Clone)] @@ -510,148 +291,8 @@ impl rusqlite::types::ToSql for EmailAddress { #[cfg(test)] mod tests { - use chrono::TimeZone; - use super::*; - #[test] - fn test_vcard_thunderbird() { - let contacts = parse_vcard( - "BEGIN:VCARD -VERSION:4.0 -FN:'Alice Mueller' -EMAIL;PREF=1:alice.mueller@posteo.de -UID:a8083264-ca47-4be7-98a8-8ec3db1447ca -END:VCARD -BEGIN:VCARD -VERSION:4.0 -FN:'bobzzz@freenet.de' -EMAIL;PREF=1:bobzzz@freenet.de -UID:cac4fef4-6351-4854-bbe4-9b6df857eaed -END:VCARD -", - ); - - assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string()); - assert_eq!(contacts[0].authname, "Alice Mueller".to_string()); - assert_eq!(contacts[0].key, None); - assert_eq!(contacts[0].profile_image, None); - assert!(contacts[0].timestamp.is_err()); - - assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string()); - assert_eq!(contacts[1].authname, "".to_string()); - assert_eq!(contacts[1].key, None); - assert_eq!(contacts[1].profile_image, None); - assert!(contacts[1].timestamp.is_err()); - - assert_eq!(contacts.len(), 2); - } - - #[test] - fn test_vcard_simple_example() { - let contacts = parse_vcard( - "BEGIN:VCARD -VERSION:4.0 -FN:Alice Wonderland -N:Wonderland;Alice;;;Ms. -GENDER:W -EMAIL;TYPE=work:alice@example.com -KEY;TYPE=PGP;ENCODING=b:[base64-data] -REV:20240418T184242Z - -END:VCARD", - ); - - assert_eq!(contacts[0].addr, "alice@example.com".to_string()); - assert_eq!(contacts[0].authname, "Alice Wonderland".to_string()); - assert_eq!(contacts[0].key, Some("[base64-data]".to_string())); - assert_eq!(contacts[0].profile_image, None); - assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762); - - assert_eq!(contacts.len(), 1); - } - - #[test] - fn test_vcard_with_trailing_newline() { - let contacts = parse_vcard( - "BEGIN:VCARD\r -VERSION:4.0\r -FN:Alice Wonderland\r -N:Wonderland;Alice;;;Ms.\r -GENDER:W\r -EMAIL;TYPE=work:alice@example.com\r -KEY;TYPE=PGP;ENCODING=b:[base64-data]\r -REV:20240418T184242Z\r -END:VCARD\r -\r", - ); - - assert_eq!(contacts[0].addr, "alice@example.com".to_string()); - assert_eq!(contacts[0].authname, "Alice Wonderland".to_string()); - assert_eq!(contacts[0].key, Some("[base64-data]".to_string())); - assert_eq!(contacts[0].profile_image, None); - assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762); - - assert_eq!(contacts.len(), 1); - } - - #[test] - fn test_make_and_parse_vcard() { - let contacts = [ - VcardContact { - addr: "alice@example.org".to_string(), - authname: "Alice Wonderland".to_string(), - key: Some("[base64-data]".to_string()), - profile_image: Some("image in Base64".to_string()), - timestamp: Ok(1713465762), - }, - VcardContact { - addr: "bob@example.com".to_string(), - authname: "".to_string(), - key: None, - profile_image: None, - timestamp: Ok(0), - }, - ]; - let items = [ - "BEGIN:VCARD\r\n\ - VERSION:4.0\r\n\ - EMAIL:alice@example.org\r\n\ - FN:Alice Wonderland\r\n\ - KEY:data:application/pgp-keys;base64,[base64-data]\r\n\ - PHOTO:data:image/jpeg;base64,image in Base64\r\n\ - REV:20240418T184242Z\r\n\ - END:VCARD\r\n", - "BEGIN:VCARD\r\n\ - VERSION:4.0\r\n\ - EMAIL:bob@example.com\r\n\ - FN:bob@example.com\r\n\ - REV:19700101T000000Z\r\n\ - END:VCARD\r\n", - ]; - let mut expected = "".to_string(); - for len in 0..=contacts.len() { - let contacts = &contacts[0..len]; - let vcard = make_vcard(contacts); - if len > 0 { - expected += items[len - 1]; - } - assert_eq!(vcard, expected); - let parsed = parse_vcard(&vcard); - assert_eq!(parsed.len(), contacts.len()); - for i in 0..parsed.len() { - assert_eq!(parsed[i].addr, contacts[i].addr); - assert_eq!(parsed[i].authname, contacts[i].authname); - assert_eq!(parsed[i].key, contacts[i].key); - assert_eq!(parsed[i].profile_image, contacts[i].profile_image); - assert_eq!( - parsed[i].timestamp.as_ref().unwrap(), - contacts[i].timestamp.as_ref().unwrap() - ); - } - } - } - #[test] fn test_contact_address() -> Result<()> { let alice_addr = "alice@example.org"; @@ -698,139 +339,6 @@ END:VCARD\r assert_eq!(EmailAddress::new("@d.tt").is_ok(), false); } - #[test] - fn test_vcard_android() { - let contacts = parse_vcard( - "BEGIN:VCARD -VERSION:2.1 -N:;Bob;;; -FN:Bob -TEL;CELL:+1-234-567-890 -EMAIL;HOME:bob@example.org -END:VCARD -BEGIN:VCARD -VERSION:2.1 -N:;Alice;;; -FN:Alice -EMAIL;HOME:alice@example.org -END:VCARD -", - ); - - assert_eq!(contacts[0].addr, "bob@example.org".to_string()); - assert_eq!(contacts[0].authname, "Bob".to_string()); - assert_eq!(contacts[0].key, None); - assert_eq!(contacts[0].profile_image, None); - - assert_eq!(contacts[1].addr, "alice@example.org".to_string()); - assert_eq!(contacts[1].authname, "Alice".to_string()); - assert_eq!(contacts[1].key, None); - assert_eq!(contacts[1].profile_image, None); - - assert_eq!(contacts.len(), 2); - } - - #[test] - fn test_vcard_local_datetime() { - let contacts = parse_vcard( - "BEGIN:VCARD\n\ - VERSION:4.0\n\ - FN:Alice Wonderland\n\ - EMAIL;TYPE=work:alice@example.org\n\ - REV:20240418T184242\n\ - END:VCARD", - ); - assert_eq!(contacts.len(), 1); - assert_eq!(contacts[0].addr, "alice@example.org".to_string()); - assert_eq!(contacts[0].authname, "Alice Wonderland".to_string()); - assert_eq!( - *contacts[0].timestamp.as_ref().unwrap(), - chrono::offset::Local - .with_ymd_and_hms(2024, 4, 18, 18, 42, 42) - .unwrap() - .timestamp() - ); - } - - #[test] - fn test_vcard_with_base64_avatar() { - // This is not an actual base64-encoded avatar, it's just to test the parsing. - // This one is Android-like. - let vcard0 = "BEGIN:VCARD -VERSION:2.1 -N:;Bob;;; -FN:Bob -EMAIL;HOME:bob@example.org -PHOTO;ENCODING=BASE64;JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEU - AAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAA - L8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q== - -END:VCARD -"; - // This one is DOS-like. - let vcard1 = vcard0.replace('\n', "\r\n"); - for vcard in [vcard0, vcard1.as_str()] { - let contacts = parse_vcard(vcard); - assert_eq!(contacts.len(), 1); - assert_eq!(contacts[0].addr, "bob@example.org".to_string()); - assert_eq!(contacts[0].authname, "Bob".to_string()); - assert_eq!(contacts[0].key, None); - assert_eq!(contacts[0].profile_image.as_deref().unwrap(), "/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEUAAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAAL8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q=="); - } - } - - #[test] - fn test_protonmail_vcard() { - let contacts = parse_vcard( - "BEGIN:VCARD -VERSION:4.0 -FN;PREF=1:Alice Wonderland -UID:proton-web-03747582-328d-38dc-5ddd-000000000000 -ITEM1.EMAIL;PREF=1:alice@example.org -ITEM1.KEY;PREF=1:data:application/pgp-keys;base64,aaaaaaaaaaaaaaaaaaaaaaaaa - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -ITEM1.KEY;PREF=2:data:application/pgp-keys;base64,bbbbbbbbbbbbbbbbbbbbbbbbb - bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb -ITEM1.X-PM-ENCRYPT:true -ITEM1.X-PM-SIGN:true -END:VCARD", - ); - - assert_eq!(contacts.len(), 1); - assert_eq!(&contacts[0].addr, "alice@example.org"); - assert_eq!(&contacts[0].authname, "Alice Wonderland"); - assert_eq!(contacts[0].key.as_ref().unwrap(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - assert!(contacts[0].timestamp.is_err()); - assert_eq!(contacts[0].profile_image, None); - } - - /// Proton at some point slightly changed the format of their vcards - #[test] - fn test_protonmail_vcard2() { - let contacts = parse_vcard( - r"BEGIN:VCARD -VERSION:4.0 -FN;PREF=1:Alice -PHOTO;PREF=1:data:image/jpeg;base64\,/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z -REV:Invalid Date -ITEM1.EMAIL;PREF=1:alice@example.org -KEY;PREF=1:data:application/pgp-keys;base64,xsaaaaaaaaaaaaaaaaaaaaaaaaaaaa - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa== -UID:proton-web-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa -END:VCARD", - ); - - assert_eq!(contacts.len(), 1); - assert_eq!(&contacts[0].addr, "alice@example.org"); - assert_eq!(&contacts[0].authname, "Alice"); - assert_eq!(contacts[0].key.as_ref().unwrap(), "xsaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=="); - assert!(contacts[0].timestamp.is_err()); - assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z"); - } - #[test] fn test_sanitize_name() { assert_eq!(&sanitize_name(" hello world "), "hello world"); diff --git a/deltachat-contact-tools/src/vcard.rs b/deltachat-contact-tools/src/vcard.rs new file mode 100644 index 000000000..0ccd81104 --- /dev/null +++ b/deltachat-contact-tools/src/vcard.rs @@ -0,0 +1,232 @@ +use std::sync::LazyLock; + +use anyhow::Context as _; +use anyhow::Result; +use chrono::DateTime; +use chrono::NaiveDateTime; +use regex::Regex; + +use crate::sanitize_name_and_addr; + +#[derive(Debug)] +/// A Contact, as represented in a VCard. +pub struct VcardContact { + /// The email address, vcard property `email` + pub addr: String, + /// This must be the name authorized by the contact itself, not a locally given name. Vcard + /// property `fn`. Can be empty, one should use `display_name()` to obtain the display name. + pub authname: String, + /// The contact's public PGP key in Base64, vcard property `key` + pub key: Option, + /// The contact's profile image (=avatar) in Base64, vcard property `photo` + pub profile_image: Option, + /// The timestamp when the vcard was created / last updated, vcard property `rev` + pub timestamp: Result, +} + +impl VcardContact { + /// Returns the contact's display name. + pub fn display_name(&self) -> &str { + match self.authname.is_empty() { + false => &self.authname, + true => &self.addr, + } + } +} + +/// Returns a vCard containing given contacts. +/// +/// Calling [`parse_vcard()`] on the returned result is a reverse operation. +pub fn make_vcard(contacts: &[VcardContact]) -> String { + fn format_timestamp(c: &VcardContact) -> Option { + let timestamp = *c.timestamp.as_ref().ok()?; + let datetime = DateTime::from_timestamp(timestamp, 0)?; + Some(datetime.format("%Y%m%dT%H%M%SZ").to_string()) + } + + let mut res = "".to_string(); + for c in contacts { + let addr = &c.addr; + let display_name = c.display_name(); + res += &format!( + "BEGIN:VCARD\r\n\ + VERSION:4.0\r\n\ + EMAIL:{addr}\r\n\ + FN:{display_name}\r\n" + ); + if let Some(key) = &c.key { + res += &format!("KEY:data:application/pgp-keys;base64,{key}\r\n"); + } + if let Some(profile_image) = &c.profile_image { + res += &format!("PHOTO:data:image/jpeg;base64,{profile_image}\r\n"); + } + if let Some(timestamp) = format_timestamp(c) { + res += &format!("REV:{timestamp}\r\n"); + } + res += "END:VCARD\r\n"; + } + res +} + +/// Parses `VcardContact`s from a given `&str`. +pub fn parse_vcard(vcard: &str) -> Vec { + fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let start_of_s = s.get(..prefix.len())?; + + if start_of_s.eq_ignore_ascii_case(prefix) { + s.get(prefix.len()..) + } else { + None + } + } + /// Returns (parameters, value) tuple. + fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, &'a str)> { + let remainder = remove_prefix(line, property)?; + // If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`, + // then `remainder` is now `;TYPE=work:alice@example.com` + + // Note: This doesn't handle the case where there are quotes around a colon, + // like `NAME;Foo="Some quoted text: that contains a colon":value`. + // This could be improved in the future, but for now, the parsing is good enough. + let (mut params, value) = remainder.split_once(':')?; + // In the example from above, `params` is now `;TYPE=work` + // and `value` is now `alice@example.com` + + if params + .chars() + .next() + .filter(|c| !c.is_ascii_punctuation() || *c == '_') + .is_some() + { + // `s` started with `property`, but the next character after it was not punctuation, + // so this line's property is actually something else + return None; + } + if let Some(p) = remove_prefix(params, ";") { + params = p; + } + if let Some(p) = remove_prefix(params, "PREF=1") { + params = p; + } + Some((params, value)) + } + fn base64_key(line: &str) -> Option<&str> { + let (params, value) = vcard_property(line, "key")?; + if params.eq_ignore_ascii_case("PGP;ENCODING=BASE64") + || params.eq_ignore_ascii_case("TYPE=PGP;ENCODING=b") + { + return Some(value); + } + if let Some(value) = remove_prefix(value, "data:application/pgp-keys;base64,") + .or_else(|| remove_prefix(value, r"data:application/pgp-keys;base64\,")) + { + return Some(value); + } + + None + } + fn base64_photo(line: &str) -> Option<&str> { + let (params, value) = vcard_property(line, "photo")?; + if params.eq_ignore_ascii_case("JPEG;ENCODING=BASE64") + || params.eq_ignore_ascii_case("ENCODING=BASE64;JPEG") + || params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=b") + || params.eq_ignore_ascii_case("ENCODING=b;TYPE=JPEG") + || params.eq_ignore_ascii_case("ENCODING=BASE64;TYPE=JPEG") + || params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=BASE64") + { + return Some(value); + } + if let Some(value) = remove_prefix(value, "data:image/jpeg;base64,") + .or_else(|| remove_prefix(value, r"data:image/jpeg;base64\,")) + { + return Some(value); + } + + None + } + fn parse_datetime(datetime: &str) -> Result { + // According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp + // is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses + // ISO.8601, but fails to parse any of the examples given. + // So, instead just parse using a format string. + + // Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500. + let timestamp = match DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") { + Ok(datetime) => datetime.timestamp(), + // Parses 19961022T140000. + Err(e) => match NaiveDateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S") { + Ok(datetime) => datetime + .and_local_timezone(chrono::offset::Local) + .single() + .context("Could not apply local timezone to parsed date and time")? + .timestamp(), + Err(_) => return Err(e.into()), + }, + }; + Ok(timestamp) + } + + // Remove line folding, see https://datatracker.ietf.org/doc/html/rfc6350#section-3.2 + static NEWLINE_AND_SPACE_OR_TAB: LazyLock = + LazyLock::new(|| Regex::new("\r?\n[\t ]").unwrap()); + let unfolded_lines = NEWLINE_AND_SPACE_OR_TAB.replace_all(vcard, ""); + + let mut lines = unfolded_lines.lines().peekable(); + let mut contacts = Vec::new(); + + while lines.peek().is_some() { + // Skip to the start of the vcard: + for line in lines.by_ref() { + if line.eq_ignore_ascii_case("BEGIN:VCARD") { + break; + } + } + + let mut display_name = None; + let mut addr = None; + let mut key = None; + let mut photo = None; + let mut datetime = None; + + for mut line in lines.by_ref() { + if let Some(remainder) = remove_prefix(line, "item1.") { + // Remove the group name, if the group is called "item1". + // If necessary, we can improve this to also remove groups that are called something different that "item1". + // + // Search "group name" at https://datatracker.ietf.org/doc/html/rfc6350 for more infos. + line = remainder; + } + + if let Some((_params, email)) = vcard_property(line, "email") { + addr.get_or_insert(email); + } else if let Some((_params, name)) = vcard_property(line, "fn") { + display_name.get_or_insert(name); + } else if let Some(k) = base64_key(line) { + key.get_or_insert(k); + } else if let Some(p) = base64_photo(line) { + photo.get_or_insert(p); + } else if let Some((_params, rev)) = vcard_property(line, "rev") { + datetime.get_or_insert(rev); + } else if line.eq_ignore_ascii_case("END:VCARD") { + let (authname, addr) = + sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or("")); + + contacts.push(VcardContact { + authname, + addr, + key: key.map(|s| s.to_string()), + profile_image: photo.map(|s| s.to_string()), + timestamp: datetime + .context("No timestamp in vcard") + .and_then(parse_datetime), + }); + break; + } + } + } + + contacts +} + +#[cfg(test)] +mod vcard_tests; diff --git a/deltachat-contact-tools/src/vcard/vcard_tests.rs b/deltachat-contact-tools/src/vcard/vcard_tests.rs new file mode 100644 index 000000000..cd2742b48 --- /dev/null +++ b/deltachat-contact-tools/src/vcard/vcard_tests.rs @@ -0,0 +1,274 @@ +use chrono::TimeZone as _; + +use super::*; + +#[test] +fn test_vcard_thunderbird() { + let contacts = parse_vcard( + "BEGIN:VCARD +VERSION:4.0 +FN:'Alice Mueller' +EMAIL;PREF=1:alice.mueller@posteo.de +UID:a8083264-ca47-4be7-98a8-8ec3db1447ca +END:VCARD +BEGIN:VCARD +VERSION:4.0 +FN:'bobzzz@freenet.de' +EMAIL;PREF=1:bobzzz@freenet.de +UID:cac4fef4-6351-4854-bbe4-9b6df857eaed +END:VCARD +", + ); + + assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string()); + assert_eq!(contacts[0].authname, "Alice Mueller".to_string()); + assert_eq!(contacts[0].key, None); + assert_eq!(contacts[0].profile_image, None); + assert!(contacts[0].timestamp.is_err()); + + assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string()); + assert_eq!(contacts[1].authname, "".to_string()); + assert_eq!(contacts[1].key, None); + assert_eq!(contacts[1].profile_image, None); + assert!(contacts[1].timestamp.is_err()); + + assert_eq!(contacts.len(), 2); +} + +#[test] +fn test_vcard_simple_example() { + let contacts = parse_vcard( + "BEGIN:VCARD +VERSION:4.0 +FN:Alice Wonderland +N:Wonderland;Alice;;;Ms. +GENDER:W +EMAIL;TYPE=work:alice@example.com +KEY;TYPE=PGP;ENCODING=b:[base64-data] +REV:20240418T184242Z + +END:VCARD", + ); + + assert_eq!(contacts[0].addr, "alice@example.com".to_string()); + assert_eq!(contacts[0].authname, "Alice Wonderland".to_string()); + assert_eq!(contacts[0].key, Some("[base64-data]".to_string())); + assert_eq!(contacts[0].profile_image, None); + assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762); + + assert_eq!(contacts.len(), 1); +} + +#[test] +fn test_vcard_with_trailing_newline() { + let contacts = parse_vcard( + "BEGIN:VCARD\r +VERSION:4.0\r +FN:Alice Wonderland\r +N:Wonderland;Alice;;;Ms.\r +GENDER:W\r +EMAIL;TYPE=work:alice@example.com\r +KEY;TYPE=PGP;ENCODING=b:[base64-data]\r +REV:20240418T184242Z\r +END:VCARD\r +\r", + ); + + assert_eq!(contacts[0].addr, "alice@example.com".to_string()); + assert_eq!(contacts[0].authname, "Alice Wonderland".to_string()); + assert_eq!(contacts[0].key, Some("[base64-data]".to_string())); + assert_eq!(contacts[0].profile_image, None); + assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762); + + assert_eq!(contacts.len(), 1); +} + +#[test] +fn test_make_and_parse_vcard() { + let contacts = [ + VcardContact { + addr: "alice@example.org".to_string(), + authname: "Alice Wonderland".to_string(), + key: Some("[base64-data]".to_string()), + profile_image: Some("image in Base64".to_string()), + timestamp: Ok(1713465762), + }, + VcardContact { + addr: "bob@example.com".to_string(), + authname: "".to_string(), + key: None, + profile_image: None, + timestamp: Ok(0), + }, + ]; + let items = [ + "BEGIN:VCARD\r\n\ + VERSION:4.0\r\n\ + EMAIL:alice@example.org\r\n\ + FN:Alice Wonderland\r\n\ + KEY:data:application/pgp-keys;base64,[base64-data]\r\n\ + PHOTO:data:image/jpeg;base64,image in Base64\r\n\ + REV:20240418T184242Z\r\n\ + END:VCARD\r\n", + "BEGIN:VCARD\r\n\ + VERSION:4.0\r\n\ + EMAIL:bob@example.com\r\n\ + FN:bob@example.com\r\n\ + REV:19700101T000000Z\r\n\ + END:VCARD\r\n", + ]; + let mut expected = "".to_string(); + for len in 0..=contacts.len() { + let contacts = &contacts[0..len]; + let vcard = make_vcard(contacts); + if len > 0 { + expected += items[len - 1]; + } + assert_eq!(vcard, expected); + let parsed = parse_vcard(&vcard); + assert_eq!(parsed.len(), contacts.len()); + for i in 0..parsed.len() { + assert_eq!(parsed[i].addr, contacts[i].addr); + assert_eq!(parsed[i].authname, contacts[i].authname); + assert_eq!(parsed[i].key, contacts[i].key); + assert_eq!(parsed[i].profile_image, contacts[i].profile_image); + assert_eq!( + parsed[i].timestamp.as_ref().unwrap(), + contacts[i].timestamp.as_ref().unwrap() + ); + } + } +} + +#[test] +fn test_vcard_android() { + let contacts = parse_vcard( + "BEGIN:VCARD +VERSION:2.1 +N:;Bob;;; +FN:Bob +TEL;CELL:+1-234-567-890 +EMAIL;HOME:bob@example.org +END:VCARD +BEGIN:VCARD +VERSION:2.1 +N:;Alice;;; +FN:Alice +EMAIL;HOME:alice@example.org +END:VCARD +", + ); + + assert_eq!(contacts[0].addr, "bob@example.org".to_string()); + assert_eq!(contacts[0].authname, "Bob".to_string()); + assert_eq!(contacts[0].key, None); + assert_eq!(contacts[0].profile_image, None); + + assert_eq!(contacts[1].addr, "alice@example.org".to_string()); + assert_eq!(contacts[1].authname, "Alice".to_string()); + assert_eq!(contacts[1].key, None); + assert_eq!(contacts[1].profile_image, None); + + assert_eq!(contacts.len(), 2); +} + +#[test] +fn test_vcard_local_datetime() { + let contacts = parse_vcard( + "BEGIN:VCARD\n\ + VERSION:4.0\n\ + FN:Alice Wonderland\n\ + EMAIL;TYPE=work:alice@example.org\n\ + REV:20240418T184242\n\ + END:VCARD", + ); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].addr, "alice@example.org".to_string()); + assert_eq!(contacts[0].authname, "Alice Wonderland".to_string()); + assert_eq!( + *contacts[0].timestamp.as_ref().unwrap(), + chrono::offset::Local + .with_ymd_and_hms(2024, 4, 18, 18, 42, 42) + .unwrap() + .timestamp() + ); +} + +#[test] +fn test_vcard_with_base64_avatar() { + // This is not an actual base64-encoded avatar, it's just to test the parsing. + // This one is Android-like. + let vcard0 = "BEGIN:VCARD +VERSION:2.1 +N:;Bob;;; +FN:Bob +EMAIL;HOME:bob@example.org +PHOTO;ENCODING=BASE64;JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEU + AAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAA + L8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q== + +END:VCARD +"; + // This one is DOS-like. + let vcard1 = vcard0.replace('\n', "\r\n"); + for vcard in [vcard0, vcard1.as_str()] { + let contacts = parse_vcard(vcard); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].addr, "bob@example.org".to_string()); + assert_eq!(contacts[0].authname, "Bob".to_string()); + assert_eq!(contacts[0].key, None); + assert_eq!(contacts[0].profile_image.as_deref().unwrap(), "/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEUAAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAAL8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q=="); + } +} + +#[test] +fn test_protonmail_vcard() { + let contacts = parse_vcard( + "BEGIN:VCARD +VERSION:4.0 +FN;PREF=1:Alice Wonderland +UID:proton-web-03747582-328d-38dc-5ddd-000000000000 +ITEM1.EMAIL;PREF=1:alice@example.org +ITEM1.KEY;PREF=1:data:application/pgp-keys;base64,aaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +ITEM1.KEY;PREF=2:data:application/pgp-keys;base64,bbbbbbbbbbbbbbbbbbbbbbbbb + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +ITEM1.X-PM-ENCRYPT:true +ITEM1.X-PM-SIGN:true +END:VCARD", + ); + + assert_eq!(contacts.len(), 1); + assert_eq!(&contacts[0].addr, "alice@example.org"); + assert_eq!(&contacts[0].authname, "Alice Wonderland"); + assert_eq!(contacts[0].key.as_ref().unwrap(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + assert!(contacts[0].timestamp.is_err()); + assert_eq!(contacts[0].profile_image, None); +} + +/// Proton at some point slightly changed the format of their vcards +#[test] +fn test_protonmail_vcard2() { + let contacts = parse_vcard( + r"BEGIN:VCARD +VERSION:4.0 +FN;PREF=1:Alice +PHOTO;PREF=1:data:image/jpeg;base64,/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z +REV:Invalid Date +ITEM1.EMAIL;PREF=1:alice@example.org +KEY;PREF=1:data:application/pgp-keys;base64,xsaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa== +UID:proton-web-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa +END:VCARD", + ); + + assert_eq!(contacts.len(), 1); + assert_eq!(&contacts[0].addr, "alice@example.org"); + assert_eq!(&contacts[0].authname, "Alice"); + assert_eq!(contacts[0].key.as_ref().unwrap(), "xsaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=="); + assert!(contacts[0].timestamp.is_err()); + assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z"); +}