-
-
Notifications
You must be signed in to change notification settings - Fork 90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Split off functional contact tools into its own crate #5444
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
[package] | ||
name = "deltachat-contact-tools" | ||
version = "0.1.0" | ||
edition = "2021" | ||
description = "Contact-related tools, like parsing vcards and sanitizing name and address" | ||
license = "MPL-2.0" | ||
# TODO maybe it should be called "deltachat-text-utils" or similar? | ||
|
||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||
|
||
[dependencies] | ||
anyhow = { workspace = true } | ||
once_cell = { workspace = true } | ||
regex = { workspace = true } | ||
rusqlite = { workspace = true } # Needed in order to `impl rusqlite::types::ToSql for EmailAddress`. Could easily be put behind a feature. | ||
|
||
[dev-dependencies] | ||
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,280 @@ | ||
//! 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::Result; | ||
use once_cell::sync::Lazy; | ||
use regex::Regex; | ||
|
||
/// 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()); | ||
if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) { | ||
( | ||
if name.is_empty() { | ||
strip_rtlo_characters( | ||
&captures | ||
.get(1) | ||
.map_or("".to_string(), |m| normalize_name(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(name), addr.to_string()) | ||
} | ||
} | ||
|
||
/// 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("[email protected]") { | ||
/// 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(), "[email protected]"); | ||
/// ``` | ||
#[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_contact_address() -> Result<()> { | ||
let alice_addr = "[email protected]"; | ||
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("[email protected]").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("[email protected]").is_ok()); | ||
assert_eq!( | ||
EmailAddress::new("[email protected]").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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Specifying the version here lets workspace members say "I'd like the same version, please" with
workspace = true
, this prevents out-of-sync version numbers.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so this states a preference but versions will be resolved across all crates of the workspace?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's more than just a preference (if I understood the documentation correctly, which I do assume) - since it says
regex = "1.10"
here, writingregex = { workspace = true }
in a workspace member is equivalent to writingregex = "1.10"