#![cfg_attr(
not(test),
deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
)]
mod config;
mod error;
mod normalize;
mod parser;
mod validate;
pub use config::{
CasePolicy, Config, ConfigBuilder, DomainCheck, DotPolicy, Strictness, SubaddressPolicy,
};
pub use error::{Error, ErrorKind};
pub use normalize::confusable_skeleton;
#[derive(Debug, Clone)]
pub struct EmailAddress {
original: String,
local_part: String,
tag: Option<String>,
domain: String,
domain_unicode: Option<String>,
display_name: Option<String>,
skeleton: Option<String>,
}
impl EmailAddress {
pub fn parse_with(input: &str, config: &Config) -> Result<Self, Error> {
let parsed = parser::parse(
input,
config.strictness,
config.allow_display_name,
config.allow_domain_literal,
)?;
let normalized = normalize::normalize(&parsed, config)?;
validate::validate(&parsed, &normalized, config)?;
Ok(Self {
original: parsed.input.to_string(),
local_part: normalized.local_part,
tag: normalized.tag,
domain: normalized.domain,
domain_unicode: normalized.domain_unicode,
display_name: normalized.display_name,
skeleton: normalized.skeleton,
})
}
pub fn local_part(&self) -> &str {
&self.local_part
}
pub fn tag(&self) -> Option<&str> {
self.tag.as_deref()
}
pub fn domain(&self) -> &str {
&self.domain
}
pub fn domain_unicode(&self) -> &str {
self.domain_unicode.as_deref().unwrap_or(&self.domain)
}
pub fn display_name(&self) -> Option<&str> {
self.display_name.as_deref()
}
pub fn canonical(&self) -> String {
if needs_quoting(&self.local_part) {
let escaped = escape_local_part(&self.local_part);
format!("\"{}\"@{}", escaped, self.domain)
} else {
format!("{}@{}", self.local_part, self.domain)
}
}
pub fn original(&self) -> &str {
&self.original
}
pub fn skeleton(&self) -> Option<&str> {
self.skeleton.as_deref()
}
pub fn is_freemail(&self) -> bool {
is_freemail_domain(&self.domain)
}
pub fn parse_batch(inputs: &[&str], config: &Config) -> Vec<Result<Self, Error>> {
inputs
.iter()
.map(|input| Self::parse_with(input, config))
.collect()
}
#[cfg(feature = "rayon")]
pub fn parse_batch_par(inputs: &[&str], config: &Config) -> Vec<Result<Self, Error>> {
use rayon::prelude::*;
inputs
.par_iter()
.map(|input| Self::parse_with(input, config))
.collect()
}
}
impl std::fmt::Display for EmailAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let local = if needs_quoting(&self.local_part) {
format!("\"{}\"", escape_local_part(&self.local_part))
} else {
self.local_part.clone()
};
match &self.display_name {
Some(name) => write!(
f,
"\"{}\" <{}@{}>",
escape_display_name(name),
local,
self.domain
),
None => write!(f, "{}@{}", local, self.domain),
}
}
}
fn needs_quoting(local: &str) -> bool {
if local.is_empty() {
return true;
}
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
return true;
}
local.chars().any(|ch| {
!ch.is_ascii_alphanumeric()
&& !matches!(
ch,
'!' | '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '/'
| '='
| '?'
| '^'
| '_'
| '`'
| '{'
| '|'
| '}'
| '~'
| '.'
)
&& (ch as u32) < 0x80 })
}
fn escape_local_part(local: &str) -> String {
let mut escaped = String::with_capacity(local.len());
for ch in local.chars() {
match ch {
'"' | '\\' => {
escaped.push('\\');
escaped.push(ch);
}
'\r' | '\n' => {} _ => escaped.push(ch),
}
}
escaped
}
fn escape_display_name(name: &str) -> String {
let mut escaped = String::with_capacity(name.len());
for ch in name.chars() {
match ch {
'"' => {
escaped.push('\\');
escaped.push('"');
}
'\\' => {
escaped.push('\\');
escaped.push('\\');
}
'\r' | '\n' => {} _ => escaped.push(ch),
}
}
escaped
}
impl PartialEq for EmailAddress {
fn eq(&self, other: &Self) -> bool {
self.local_part == other.local_part && self.domain == other.domain
}
}
impl Eq for EmailAddress {}
impl std::hash::Hash for EmailAddress {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.local_part.hash(state);
self.domain.hash(state);
}
}
impl std::str::FromStr for EmailAddress {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_with(s, &Config::default())
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for EmailAddress {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.canonical().serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for EmailAddress {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
fn is_freemail_domain(domain: &str) -> bool {
matches!(
domain,
"gmail.com"
| "googlemail.com"
| "yahoo.com"
| "yahoo.co.uk"
| "yahoo.co.jp"
| "outlook.com"
| "hotmail.com"
| "live.com"
| "msn.com"
| "aol.com"
| "protonmail.com"
| "proton.me"
| "icloud.com"
| "me.com"
| "mac.com"
| "mail.com"
| "zoho.com"
| "yandex.ru"
| "yandex.com"
| "mail.ru"
| "gmx.com"
| "gmx.de"
| "web.de"
| "tutanota.com"
| "tuta.io"
| "fastmail.com"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple() {
let email: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
assert_eq!(email.local_part(), "user");
assert_eq!(email.domain(), "example.com");
assert_eq!(email.tag(), None);
assert_eq!(email.canonical(), "user@example.com");
}
#[test]
fn parse_with_tag() {
let email: EmailAddress = "user+newsletter@example.com"
.parse()
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(email.local_part(), "user+newsletter");
assert_eq!(email.tag(), Some("newsletter"));
}
#[test]
fn display_format() {
let email: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
assert_eq!(format!("{email}"), "user@example.com");
}
#[test]
fn display_name_escaping() {
let config = Config::builder().allow_display_name().build();
let email = EmailAddress::parse_with("John \"Johnny\" Doe <user@example.com>", &config)
.unwrap_or_else(|e| panic!("{e}"));
let formatted = format!("{email}");
assert!(
formatted.contains("\\\"Johnny\\\""),
"Expected escaped quotes in: {formatted}"
);
}
#[test]
fn equality_by_canonical() {
let a: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
let b: EmailAddress = "user@Example.COM".parse().unwrap_or_else(|e| panic!("{e}"));
assert_eq!(a, b);
}
#[test]
fn freemail_detection() {
let email: EmailAddress = "user@gmail.com".parse().unwrap_or_else(|e| panic!("{e}"));
assert!(email.is_freemail());
let email: EmailAddress = "user@company.com".parse().unwrap_or_else(|e| panic!("{e}"));
assert!(!email.is_freemail());
}
#[test]
fn full_normalization_pipeline() {
let config = Config::builder()
.strip_subaddress()
.dots_gmail_only()
.lowercase_all()
.check_confusables()
.build();
let email = EmailAddress::parse_with("A.L.I.C.E+promo@Gmail.COM", &config)
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(email.canonical(), "alice@gmail.com");
assert_eq!(email.tag(), Some("promo"));
assert!(email.skeleton().is_some());
}
#[test]
fn display_name_parsing() {
let config = Config::builder().allow_display_name().build();
let email = EmailAddress::parse_with("John Doe <user@example.com>", &config)
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(email.display_name(), Some("John Doe"));
assert_eq!(email.local_part(), "user");
assert_eq!(email.domain(), "example.com");
}
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let email: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
let json = serde_json::to_string(&email).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(json, "\"user@example.com\"");
let back: EmailAddress = serde_json::from_str(&json).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(email, back);
}
#[test]
fn rejects_empty() {
let result: Result<EmailAddress, _> = "".parse();
assert!(result.is_err());
}
#[test]
fn rejects_no_domain_dot() {
let result: Result<EmailAddress, _> = "user@localhost".parse();
assert!(result.is_err());
assert!(matches!(result.unwrap_err().kind(), ErrorKind::DomainNoDot));
}
#[test]
fn allows_single_label_when_configured() {
let config = Config::builder().allow_single_label_domain().build();
let email =
EmailAddress::parse_with("user@localhost", &config).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(email.domain(), "localhost");
}
#[test]
fn batch_parse_mixed_results() {
let config = Config::default();
let results = EmailAddress::parse_batch(
&["alice@example.com", "invalid", "bob@example.org"],
&config,
);
assert_eq!(results.len(), 3);
assert!(results[0].is_ok());
assert!(results[1].is_err());
assert!(results[2].is_ok());
assert_eq!(results[0].as_ref().map(|e| e.domain()), Ok("example.com"));
assert_eq!(results[2].as_ref().map(|e| e.domain()), Ok("example.org"));
}
#[test]
fn batch_parse_empty_input() {
let config = Config::default();
let results = EmailAddress::parse_batch(&[], &config);
assert!(results.is_empty());
}
#[test]
fn batch_parse_all_valid() {
let config = Config::default();
let inputs = &["a@b.com", "x@y.org", "test+tag@example.com"];
let results = EmailAddress::parse_batch(inputs, &config);
assert!(results.iter().all(|r| r.is_ok()));
}
#[test]
fn batch_parse_all_invalid() {
let config = Config::default();
let results = EmailAddress::parse_batch(&["", "noatsign", "@missing-local.com"], &config);
assert!(results.iter().all(|r| r.is_err()));
}
#[test]
fn batch_parse_with_config() {
let config = Config::builder()
.strip_subaddress()
.dots_gmail_only()
.lowercase_all()
.build();
let results =
EmailAddress::parse_batch(&["A.L.I.C.E+promo@Gmail.COM", "BOB@example.com"], &config);
assert_eq!(results.len(), 2);
assert_eq!(
results[0].as_ref().map(|e| e.canonical()),
Ok("alice@gmail.com".to_string())
);
assert_eq!(
results[1].as_ref().map(|e| e.canonical()),
Ok("bob@example.com".to_string())
);
}
#[test]
fn domain_unicode_roundtrip() {
let email: EmailAddress = "user@münchen.de".parse().unwrap_or_else(|e| panic!("{e}"));
assert_eq!(email.domain(), "xn--mnchen-3ya.de");
assert_eq!(email.domain_unicode(), "münchen.de");
}
#[test]
fn domain_unicode_ascii_fallback() {
let email: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
assert_eq!(email.domain_unicode(), "example.com");
assert_eq!(email.domain_unicode(), email.domain());
}
#[test]
fn domain_unicode_mixed_labels() {
let email: EmailAddress = "user@über.example.com"
.parse()
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(email.domain(), "xn--ber-goa.example.com");
assert_eq!(email.domain_unicode(), "über.example.com");
}
#[test]
fn domain_unicode_japanese() {
let email: EmailAddress = "user@例え.jp".parse().unwrap_or_else(|e| panic!("{e}"));
assert!(email.domain().contains("xn--"));
assert_eq!(email.domain_unicode(), "例え.jp");
}
#[cfg(feature = "rayon")]
#[test]
fn batch_par_matches_sequential() {
let config = Config::builder().strip_subaddress().lowercase_all().build();
let inputs = &[
"alice@example.com",
"invalid",
"BOB+tag@Example.ORG",
"",
"user@test.com",
];
let seq = EmailAddress::parse_batch(inputs, &config);
let par = EmailAddress::parse_batch_par(inputs, &config);
assert_eq!(seq.len(), par.len());
for (i, (s, p)) in seq.iter().zip(par.iter()).enumerate() {
match (s, p) {
(Ok(a), Ok(b)) => assert_eq!(a, b, "result {i} diverges"),
(Err(a), Err(b)) => assert_eq!(a, b, "error {i} diverges: {a} vs {b}"),
_ => panic!("result {i}: one Ok, one Err"),
}
}
}
}