mirror of
https://github.com/chatmail/core.git
synced 2026-05-03 21:36:29 +03:00
454 lines
13 KiB
Rust
454 lines
13 KiB
Rust
//! Contact-related tools, like parsing vcards and sanitizing name and address
|
|
|
|
#![forbid(unsafe_code)]
|
|
#![warn(
|
|
unused,
|
|
clippy::correctness,
|
|
missing_debug_implementations,
|
|
missing_docs,
|
|
clippy::all,
|
|
clippy::wildcard_imports,
|
|
clippy::needless_borrow,
|
|
clippy::cast_lossless,
|
|
clippy::unused_async,
|
|
clippy::explicit_iter_loop,
|
|
clippy::explicit_into_iter_loop,
|
|
clippy::cloned_instead_of_copied
|
|
)]
|
|
#![cfg_attr(not(test), warn(clippy::indexing_slicing))]
|
|
#![allow(
|
|
clippy::match_bool,
|
|
clippy::mixed_read_write_in_expression,
|
|
clippy::bool_assert_comparison,
|
|
clippy::manual_split_once,
|
|
clippy::format_push_string,
|
|
clippy::bool_to_int_with_if
|
|
)]
|
|
|
|
use std::fmt;
|
|
use std::ops::Deref;
|
|
|
|
use anyhow::bail;
|
|
use anyhow::Context as _;
|
|
use anyhow::Result;
|
|
use chrono::DateTime;
|
|
use once_cell::sync::Lazy;
|
|
use regex::Regex;
|
|
|
|
// TODOs to clean up:
|
|
// - Check if sanitizing is done correctly everywhere
|
|
// - Apply lints everywhere (https://doc.rust-lang.org/cargo/reference/workspaces.html#the-lints-table)
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct Contact {
|
|
addr: String,
|
|
display_name: String,
|
|
key: String,
|
|
profile_photo: String,
|
|
timestamp: u64,
|
|
}
|
|
|
|
pub fn parse_vcard(vcard: String) -> Result<Vec<Contact>> {
|
|
let reader = ical::VcardParser::new(vcard.as_bytes());
|
|
|
|
let mut contacts = Vec::new();
|
|
for vcard_contact in reader {
|
|
let vcard_contact = vcard_contact?; // TODO should just continue with the next contact
|
|
let mut new_contact = Contact::default();
|
|
|
|
let mut display_name = None;
|
|
let mut addr = None;
|
|
let mut key = None;
|
|
let mut photo = None;
|
|
let mut timestamp = None;
|
|
|
|
for property in vcard_contact.properties {
|
|
match &*property.name.to_lowercase() {
|
|
"email" => addr = addr.or(property.value),
|
|
"fn" => display_name = display_name.or(property.value),
|
|
"key" => key = key.or(dbg!(property).value), // TODO hmmm, the ical crate can apparently only parse version 3.0
|
|
"photo" => photo = photo.or(property.value),
|
|
"rev" => {
|
|
timestamp = timestamp.or(property.value);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let (display_name, addr) =
|
|
sanitize_name_and_addr(&display_name.unwrap_or_default(), &addr.unwrap_or_default());
|
|
new_contact.display_name = display_name;
|
|
new_contact.addr = addr;
|
|
|
|
if let Some(key) = key {
|
|
if let Some(base64_key) = key
|
|
.strip_prefix("PGP;ENCODING=BASE64:")
|
|
.or(key.strip_prefix("TYPE=PGP;ENCODING=b:"))
|
|
.or(key.strip_prefix("data:application/pgp-keys;base64,"))
|
|
{
|
|
new_contact.key = base64_key.to_string();
|
|
}
|
|
}
|
|
|
|
if let Some(photo) = photo {
|
|
if let Some(base64_photo) = photo
|
|
.strip_prefix("PGP;ENCODING=BASE64:")
|
|
.or(photo.strip_prefix("TYPE=PGP;ENCODING=b:"))
|
|
.or(photo.strip_prefix("data:application/pgp-keys;base64,"))
|
|
{}
|
|
}
|
|
|
|
contacts.push(new_contact);
|
|
}
|
|
|
|
Ok(contacts)
|
|
}
|
|
|
|
/// Valid contact address.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ContactAddress(String);
|
|
|
|
impl Deref for ContactAddress {
|
|
type Target = str;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl AsRef<str> for ContactAddress {
|
|
fn as_ref(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ContactAddress {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl ContactAddress {
|
|
/// Constructs a new contact address from string,
|
|
/// normalizing and validating it.
|
|
pub fn new(s: &str) -> Result<Self> {
|
|
let addr = addr_normalize(s);
|
|
if !may_be_valid_addr(&addr) {
|
|
bail!("invalid address {:?}", s);
|
|
}
|
|
Ok(Self(addr.to_string()))
|
|
}
|
|
}
|
|
|
|
/// Allow converting [`ContactAddress`] to an SQLite type.
|
|
impl rusqlite::types::ToSql for ContactAddress {
|
|
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
|
|
let val = rusqlite::types::Value::Text(self.0.to_string());
|
|
let out = rusqlite::types::ToSqlOutput::Owned(val);
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
/// Make the name and address
|
|
pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
|
|
static ADDR_WITH_NAME_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("(.*)<(.*)>").unwrap());
|
|
let (name, addr) = if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) {
|
|
(
|
|
if name.is_empty() {
|
|
strip_rtlo_characters(captures.get(1).map_or("", |m| m.as_str()))
|
|
} else {
|
|
strip_rtlo_characters(name)
|
|
},
|
|
captures
|
|
.get(2)
|
|
.map_or("".to_string(), |m| m.as_str().to_string()),
|
|
)
|
|
} else {
|
|
(
|
|
strip_rtlo_characters(&normalize_name(name)),
|
|
addr.to_string(),
|
|
)
|
|
};
|
|
let mut name = normalize_name(&name);
|
|
|
|
// If the 'display name' is just the address, remove it:
|
|
// Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)" (see `get_name_n_addr()`).
|
|
// If the display name is empty, DC will just show the address when it needs a display name.
|
|
if name == addr {
|
|
name = "".to_string();
|
|
}
|
|
|
|
(name, addr)
|
|
}
|
|
|
|
/// Normalize a name.
|
|
///
|
|
/// - Remove quotes (come from some bad MUA implementations)
|
|
/// - Trims the resulting string
|
|
///
|
|
/// Typically, this function is not needed as it is called implicitly by `Contact::add_address_book`.
|
|
pub fn normalize_name(full_name: &str) -> String {
|
|
let full_name = full_name.trim();
|
|
if full_name.is_empty() {
|
|
return full_name.into();
|
|
}
|
|
|
|
match full_name.as_bytes() {
|
|
[b'\'', .., b'\''] | [b'\"', .., b'\"'] | [b'<', .., b'>'] => full_name
|
|
.get(1..full_name.len() - 1)
|
|
.map_or("".to_string(), |s| s.trim().to_string()),
|
|
_ => full_name.to_string(),
|
|
}
|
|
}
|
|
|
|
const RTLO_CHARACTERS: [char; 5] = ['\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}'];
|
|
/// This method strips all occurrences of the RTLO Unicode character.
|
|
/// [Why is this needed](https://github.com/deltachat/deltachat-core-rust/issues/3479)?
|
|
pub fn strip_rtlo_characters(input_str: &str) -> String {
|
|
input_str.replace(|char| RTLO_CHARACTERS.contains(&char), "")
|
|
}
|
|
|
|
/// Returns false if addr is an invalid address, otherwise true.
|
|
pub fn may_be_valid_addr(addr: &str) -> bool {
|
|
let res = EmailAddress::new(addr);
|
|
res.is_ok()
|
|
}
|
|
|
|
/// Returns address lowercased,
|
|
/// with whitespace trimmed and `mailto:` prefix removed.
|
|
pub fn addr_normalize(addr: &str) -> String {
|
|
let norm = addr.trim().to_lowercase();
|
|
|
|
if norm.starts_with("mailto:") {
|
|
norm.get(7..).unwrap_or(&norm).to_string()
|
|
} else {
|
|
norm
|
|
}
|
|
}
|
|
|
|
/// Compares two email addresses, normalizing them beforehand.
|
|
pub fn addr_cmp(addr1: &str, addr2: &str) -> bool {
|
|
let norm1 = addr_normalize(addr1);
|
|
let norm2 = addr_normalize(addr2);
|
|
|
|
norm1 == norm2
|
|
}
|
|
|
|
///
|
|
/// Represents an email address, right now just the `name@domain` portion.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use deltachat_contact_tools::EmailAddress;
|
|
/// let email = match EmailAddress::new("someone@example.com") {
|
|
/// Ok(addr) => addr,
|
|
/// Err(e) => panic!("Error parsing address, error was {}", e),
|
|
/// };
|
|
/// assert_eq!(&email.local, "someone");
|
|
/// assert_eq!(&email.domain, "example.com");
|
|
/// assert_eq!(email.to_string(), "someone@example.com");
|
|
/// ```
|
|
#[derive(Debug, PartialEq, Eq, Clone)]
|
|
pub struct EmailAddress {
|
|
/// Local part of the email address.
|
|
pub local: String,
|
|
|
|
/// Email address domain.
|
|
pub domain: String,
|
|
}
|
|
|
|
impl fmt::Display for EmailAddress {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
write!(f, "{}@{}", self.local, self.domain)
|
|
}
|
|
}
|
|
|
|
impl EmailAddress {
|
|
/// Performs a dead-simple parse of an email address.
|
|
pub fn new(input: &str) -> Result<EmailAddress> {
|
|
if input.is_empty() {
|
|
bail!("empty string is not valid");
|
|
}
|
|
let parts: Vec<&str> = input.rsplitn(2, '@').collect();
|
|
|
|
if input
|
|
.chars()
|
|
.any(|c| c.is_whitespace() || c == '<' || c == '>')
|
|
{
|
|
bail!("Email {:?} must not contain whitespaces, '>' or '<'", input);
|
|
}
|
|
|
|
match &parts[..] {
|
|
[domain, local] => {
|
|
if local.is_empty() {
|
|
bail!("empty string is not valid for local part in {:?}", input);
|
|
}
|
|
if domain.is_empty() {
|
|
bail!("missing domain after '@' in {:?}", input);
|
|
}
|
|
if domain.ends_with('.') {
|
|
bail!("Domain {domain:?} should not contain the dot in the end");
|
|
}
|
|
Ok(EmailAddress {
|
|
local: (*local).to_string(),
|
|
domain: (*domain).to_string(),
|
|
})
|
|
}
|
|
_ => bail!("Email {:?} must contain '@' character", input),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl rusqlite::types::ToSql for EmailAddress {
|
|
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
|
|
let val = rusqlite::types::Value::Text(self.to_string());
|
|
let out = rusqlite::types::ToSqlOutput::Owned(val);
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_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
|
|
"
|
|
.to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string());
|
|
assert_eq!(contacts[0].display_name, "Alice Mueller".to_string());
|
|
assert_eq!(contacts[0].key, "".to_string());
|
|
assert_eq!(contacts[0].profile_photo, "".to_string());
|
|
|
|
assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string());
|
|
assert_eq!(contacts[1].display_name, "".to_string());
|
|
assert_eq!(contacts[1].key, "".to_string());
|
|
assert_eq!(contacts[1].profile_photo, "".to_string());
|
|
|
|
assert_eq!(contacts.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_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"
|
|
.to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
|
|
assert_eq!(contacts[0].display_name, "Alice Wonderland".to_string());
|
|
assert_eq!(contacts[0].key, "[base64-data]".to_string());
|
|
assert_eq!(contacts[0].profile_photo, "".to_string());
|
|
assert_eq!(contacts[0].timestamp, 1713465762); // I did not check whether this timestamp is correct
|
|
|
|
assert_eq!(contacts.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_contact_address() -> Result<()> {
|
|
let alice_addr = "alice@example.org";
|
|
let contact_address = ContactAddress::new(alice_addr)?;
|
|
assert_eq!(contact_address.as_ref(), alice_addr);
|
|
|
|
let invalid_addr = "<> foobar";
|
|
assert!(ContactAddress::new(invalid_addr).is_err());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_emailaddress_parse() {
|
|
assert_eq!(EmailAddress::new("").is_ok(), false);
|
|
assert_eq!(
|
|
EmailAddress::new("user@domain.tld").unwrap(),
|
|
EmailAddress {
|
|
local: "user".into(),
|
|
domain: "domain.tld".into(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
EmailAddress::new("user@localhost").unwrap(),
|
|
EmailAddress {
|
|
local: "user".into(),
|
|
domain: "localhost".into()
|
|
}
|
|
);
|
|
assert_eq!(EmailAddress::new("uuu").is_ok(), false);
|
|
assert_eq!(EmailAddress::new("dd.tt").is_ok(), false);
|
|
assert!(EmailAddress::new("tt.dd@uu").is_ok());
|
|
assert!(EmailAddress::new("u@d").is_ok());
|
|
assert!(EmailAddress::new("u@d.").is_err());
|
|
assert!(EmailAddress::new("u@d.t").is_ok());
|
|
assert_eq!(
|
|
EmailAddress::new("u@d.tt").unwrap(),
|
|
EmailAddress {
|
|
local: "u".into(),
|
|
domain: "d.tt".into(),
|
|
}
|
|
);
|
|
assert!(EmailAddress::new("u@tt").is_ok());
|
|
assert_eq!(EmailAddress::new("@d.tt").is_ok(), false);
|
|
}
|
|
|
|
#[test]
|
|
fn test_android_contact_export() {
|
|
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
|
|
"
|
|
.to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(contacts[0].addr, "bob@example.org".to_string());
|
|
assert_eq!(contacts[0].display_name, "Bob".to_string());
|
|
assert_eq!(contacts[0].key, "".to_string());
|
|
assert_eq!(contacts[0].profile_photo, "".to_string());
|
|
|
|
assert_eq!(contacts[1].addr, "alice@example.org".to_string());
|
|
assert_eq!(contacts[1].display_name, "Alice".to_string());
|
|
assert_eq!(contacts[1].key, "".to_string());
|
|
assert_eq!(contacts[1].profile_photo, "".to_string());
|
|
|
|
assert_eq!(contacts.len(), 2);
|
|
}
|
|
}
|