use crate::{Error, Result};
use std::str::FromStr;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "sqlx")]
use sqlx::{Database, Decode, Encode, Type, encode::IsNull, error::BoxDynError};
#[cfg(feature = "sqlx_postgres")]
use sqlx::postgres::{PgHasArrayType, PgTypeInfo};
const LOCAL_PART_MAX_LEN: usize = 64;
const DOMAIN_MAX_LEN: usize = 254;
const SUB_DOMAIN_MAX_LENGTH: usize = 63;
#[expect(dead_code)]
const CR: char = '\r';
#[expect(dead_code)]
const LF: char = '\n';
const SP: char = ' ';
const HTAB: char = '\t';
const ESC: char = '\\';
const AT: char = '@';
const DOT: char = '.';
const DQUOTE: char = '"';
const LBRACKET: char = '[';
const RBRACKET: char = ']';
#[expect(dead_code)]
const LPAREN: char = '(';
#[expect(dead_code)]
const RPAREN: char = ')';
const MAILTO_URI_PREFIX: &str = "mailto:";
fn is_atext(c: char) -> bool {
c.is_alphanumeric()
|| c == '!'
|| c == '#'
|| c == '$'
|| c == '%'
|| c == '&'
|| c == '\''
|| c == '*'
|| c == '+'
|| c == '-'
|| c == '/'
|| c == '='
|| c == '?'
|| c == '^'
|| c == '_'
|| c == '`'
|| c == '{'
|| c == '|'
|| c == '}'
|| c == '~'
|| !c.is_ascii()
}
fn is_atom(s: &str) -> bool {
!s.is_empty() && s.chars().all(is_atext)
}
fn is_dot_atom_text(s: &str) -> bool {
s.split(DOT).all(is_atom)
}
fn parse_unquoted_local_part(part: &str) -> Result<()> {
if is_dot_atom_text(part) {
Ok(())
} else {
Err(Error::InvalidCharacter)
}
}
fn is_vchar(c: char) -> bool {
('\x21'..='\x7E').contains(&c)
}
fn is_wsp(c: char) -> bool {
c == SP || c == HTAB
}
fn is_qtext_char(c: char) -> bool {
c == '\x21' || ('\x23'..='\x5B').contains(&c) || ('\x5D'..='\x7E').contains(&c) || !c.is_ascii()
}
fn is_qcontent(s: &str) -> bool {
let mut char_iter = s.chars();
while let Some(c) = &char_iter.next() {
if c == &ESC {
match char_iter.next() {
Some(c2) if is_vchar(c2) => (),
_ => return false,
}
} else if !(is_wsp(*c) || is_qtext_char(*c)) {
return false;
}
}
true
}
fn parse_quoted_local_part(part: &str) -> Result<()> {
if is_qcontent(part) {
Ok(())
} else {
Err(Error::InvalidCharacter)
}
}
fn check_local_part(part: &str) -> Result<()> {
if part.is_empty() {
Err(Error::LocalPartEmpty)
} else if part.len() > LOCAL_PART_MAX_LEN {
Err(Error::LocalPartTooLong(LOCAL_PART_MAX_LEN, part.len()))
} else if part.starts_with(DQUOTE) && part.ends_with(DQUOTE) {
if part.len() <= 2 {
Err(Error::LocalPartEmpty)
} else {
parse_quoted_local_part(&part[1..part.len() - 1])
}
} else {
parse_unquoted_local_part(part)
}
}
fn is_dtext_char(c: char) -> bool {
('\x21'..='\x5A').contains(&c) || ('\x5E'..='\x7E').contains(&c) || !c.is_ascii()
}
fn parse_literal_domain(part: &str) -> Result<()> {
if part.chars().all(is_dtext_char) {
return Ok(());
}
Err(Error::InvalidCharacter)
}
fn parse_text_domain(part: &str) -> Result<()> {
for subpart in part.split(DOT) {
if subpart.is_empty() {
return Err(Error::SubDomainEmpty);
}
if !subpart.starts_with(char::is_alphanumeric) {
return Err(Error::InvalidCharacter);
}
if !subpart.ends_with(char::is_alphanumeric) {
return Err(Error::InvalidCharacter);
}
if subpart.len() > SUB_DOMAIN_MAX_LENGTH {
return Err(Error::SubDomainTooLong(
SUB_DOMAIN_MAX_LENGTH,
subpart.len(),
));
}
if !is_atom(subpart) {
return Err(Error::InvalidCharacter);
}
}
Ok(())
}
fn check_domain(domain: &str) -> Result<()> {
if domain.is_empty() {
Err(Error::DomainEmpty)
} else if domain.len() > DOMAIN_MAX_LEN {
Err(Error::DomainTooLong(DOMAIN_MAX_LEN, domain.len()))
} else if domain.starts_with(LBRACKET) && domain.ends_with(RBRACKET) {
parse_literal_domain(&domain[1..domain.len() - 1])
} else {
parse_text_domain(domain)
}
}
fn check_address(input: &str) -> Result<usize> {
let (user, domain) = input.rsplit_once(AT).ok_or(Error::MissingSeparator)?;
check_local_part(user)?;
check_domain(domain)?;
Ok(user.len())
}
#[derive(Debug, Clone)]
pub struct EmailAddress {
serialized: String,
at_start: usize,
}
impl EmailAddress {
pub fn new(local_part: &str, domain: &str) -> Result<EmailAddress> {
check_local_part(local_part)?;
check_domain(domain)?;
Ok(Self::new_unchecked(local_part, domain))
}
pub fn new_unchecked(local_part: &str, domain: &str) -> EmailAddress {
let mut serialized = String::with_capacity(local_part.len() + 1 + domain.len());
serialized.push_str(local_part);
serialized.push(AT);
serialized.push_str(&domain.to_lowercase());
Self {
serialized,
at_start: local_part.len(),
}
}
pub fn user(&self) -> &str {
&self.serialized[..self.at_start]
}
pub fn domain(&self) -> &str {
&self.serialized[self.at_start + 1..]
}
pub fn is_valid(address: &str) -> bool {
check_address(address).is_ok()
}
pub fn to_uri(&self) -> String {
format!("{}{}", MAILTO_URI_PREFIX, encode(&self.serialized))
}
}
fn is_uri_reserved(c: char) -> bool {
c == '!'
|| c == '#'
|| c == '$'
|| c == '%'
|| c == '&'
|| c == '\''
|| c == '('
|| c == ')'
|| c == '*'
|| c == '+'
|| c == ','
|| c == '/'
|| c == ':'
|| c == ';'
|| c == '='
|| c == '?'
|| c == '['
|| c == ']'
}
fn encode(address: &str) -> String {
let mut result = String::new();
for c in address.chars() {
if is_uri_reserved(c) {
result.push_str(&format!("%{:02X}", c as u8))
} else {
result.push(c);
}
}
result
}
impl FromStr for EmailAddress {
type Err = Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let at_start = check_address(s)?;
Ok(EmailAddress::new_unchecked(
&s[..at_start],
&s[at_start + 1..],
))
}
}
impl AsRef<str> for EmailAddress {
fn as_ref(&self) -> &str {
&self.serialized
}
}
impl From<EmailAddress> for String {
fn from(value: EmailAddress) -> Self {
value.serialized
}
}
impl std::fmt::Display for EmailAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.serialized)
}
}
impl PartialEq for EmailAddress {
fn eq(&self, other: &Self) -> bool {
self.serialized == other.serialized
}
}
impl Eq for EmailAddress {}
impl PartialOrd for EmailAddress {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.serialized.cmp(&other.serialized))
}
}
impl Ord for EmailAddress {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.serialized.cmp(&other.serialized)
}
}
impl std::hash::Hash for EmailAddress {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.serialized.hash(state);
}
}
#[cfg(feature = "lettre")]
impl TryFrom<EmailAddress> for lettre::Address {
type Error = lettre::address::AddressError;
fn try_from(value: EmailAddress) -> std::result::Result<Self, Self::Error> {
Self::new(value.user(), value.domain())
}
}
#[cfg(feature = "serde")]
impl Serialize for EmailAddress {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.serialized)
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for EmailAddress {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{Error, Unexpected, Visitor};
use std::{fmt::Formatter, str::FromStr};
struct EmailAddressVisitor;
impl Visitor<'_> for EmailAddressVisitor {
type Value = EmailAddress;
fn expecting(&self, fmt: &mut Formatter<'_>) -> std::fmt::Result {
fmt.write_str("string containing a valid email address")
}
fn visit_str<E>(self, s: &str) -> std::result::Result<Self::Value, E>
where
E: Error,
{
EmailAddress::from_str(s).map_err(|err| {
let exp = format!("{}", err);
Error::invalid_value(Unexpected::Str(s), &exp.as_ref())
})
}
}
deserializer.deserialize_str(EmailAddressVisitor)
}
}
#[cfg(feature = "sqlx")]
impl<DB: Database> Type<DB> for EmailAddress
where
String: Type<DB>,
{
fn type_info() -> DB::TypeInfo {
<String as Type<DB>>::type_info()
}
fn compatible(ty: &DB::TypeInfo) -> bool {
<String as Type<DB>>::compatible(ty)
}
}
#[cfg(feature = "sqlx")]
impl<'a, DB: Database> Encode<'a, DB> for EmailAddress
where
String: Encode<'a, DB>,
{
fn encode_by_ref(
&self,
buf: &mut <DB as Database>::ArgumentBuffer<'a>,
) -> std::result::Result<IsNull, BoxDynError> {
<String as Encode<'a, DB>>::encode_by_ref(&self.serialized, buf)
}
fn produces(&self) -> Option<DB::TypeInfo> {
<String as Encode<'a, DB>>::produces(&self.serialized)
}
fn size_hint(&self) -> usize {
<String as Encode<'a, DB>>::size_hint(&self.serialized)
}
}
#[cfg(feature = "sqlx")]
impl<'a, DB: Database> Decode<'a, DB> for EmailAddress
where
String: Decode<'a, DB>,
{
fn decode(value: <DB as Database>::ValueRef<'a>) -> std::result::Result<Self, BoxDynError> {
Ok(Self::from_str(&<String as Decode<'a, DB>>::decode(value)?)?)
}
}
#[cfg(feature = "sqlx_postgres")]
impl PgHasArrayType for EmailAddress
where
String: PgHasArrayType,
{
fn array_type_info() -> PgTypeInfo {
<String as PgHasArrayType>::array_type_info()
}
}
#[cfg(feature = "sqlx")]
#[cfg(test)]
mod sqlx_tests {
use super::*;
use claims::{assert_err, assert_matches, assert_ok, assert_ok_eq};
use sqlx::{
Any, Decode, Encode, Value,
any::{AnyArguments, AnyValue},
};
use sqlx_core::any::AnyValueKind;
use std::borrow::Cow;
#[test]
fn test_encode() {
let email = assert_ok!(EmailAddress::from_str("simple@example.com"));
let mut buf = AnyArguments::default().values;
let _ = assert_ok!(<EmailAddress as Encode<'_, Any>>::encode(email, &mut buf));
assert_eq!(buf.0.len(), 1);
assert_matches!(&buf.0[0], AnyValueKind::Text(text) if text == "simple@example.com");
}
#[test]
fn test_decode() {
let value = AnyValue {
kind: AnyValueKind::Text(Cow::from("simple@example.com")),
};
let email = assert_ok!(EmailAddress::from_str("simple@example.com"));
assert_ok_eq!(
<EmailAddress as Decode<'_, Any>>::decode(value.as_ref()),
email
);
}
#[test]
fn test_decode_invalid_value() {
let value = AnyValue {
kind: AnyValueKind::Text(Cow::from("Abc.example.com")),
};
let boxed_error = assert_err!(<EmailAddress as Decode<'_, Any>>::decode(value.as_ref()));
let error = assert_ok!(boxed_error.downcast::<Error>());
assert_matches!(*error, Error::MissingSeparator);
}
#[test]
fn test_decode_invalid_type() {
let value = AnyValue {
kind: AnyValueKind::Integer(42),
};
let boxed_error = assert_err!(<EmailAddress as Decode<'_, Any>>::decode(value.as_ref()));
assert_eq!(boxed_error.to_string(), "expected TEXT, got Integer(42)");
}
}
#[cfg(feature = "serde")]
#[cfg(test)]
mod serde_tests {
use super::*;
use claims::assert_err_eq;
use serde::de::{Error as _, Unexpected};
use serde_assert::{Deserializer, Token};
#[test]
fn test_roundtrip() {
let email = EmailAddress::from_str("alice@example.org").unwrap();
let ser = serde_json::to_string(&email).unwrap();
let de = serde_json::from_str::<EmailAddress>(&ser).unwrap();
assert_eq!(email, de);
}
#[test]
fn test_serialize() {
let email = EmailAddress::from_str("alice@example.org").unwrap();
let ser = serde_json::to_string(&email).unwrap();
assert_eq!(ser, "\"alice@example.org\"");
}
#[test]
fn test_deserialize() {
let ser = "\"john.doe@example.org\"";
let de = serde_json::from_str::<EmailAddress>(&ser).unwrap();
assert_eq!(de.to_string(), "john.doe@example.org");
}
#[test]
fn test_deserialize_invalid_value() {
let mut deserializer =
Deserializer::builder([Token::Str("Abc.example.com".to_owned())]).build();
assert_err_eq!(
EmailAddress::deserialize(&mut deserializer),
serde_assert::de::Error::invalid_value(
Unexpected::Str("Abc.example.com"),
&"Missing separator character"
)
);
}
#[test]
fn test_deserialize_invalid_type() {
let mut deserializer = Deserializer::builder([Token::U64(42)]).build();
assert_err_eq!(
EmailAddress::deserialize(&mut deserializer),
serde_assert::de::Error::invalid_type(
Unexpected::Unsigned(42),
&"string containing a valid email address"
)
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_str() {
let addr = EmailAddress::from_str("alice@example.org");
assert!(matches!(addr, Ok(..)));
}
#[test]
fn case_insensitive_domain() {
let low = EmailAddress::new("alice", "example.org").unwrap();
let up = EmailAddress::new("alice", "EXAMPLE.ORG").unwrap();
assert_eq!(low, up);
}
#[test]
fn case_insensitive_domain_utf8() {
let low = EmailAddress::new("коля", "пример.рф").unwrap();
let up = EmailAddress::new("коля", "ПРИМЕР.РФ").unwrap();
assert_eq!(low, up);
}
#[test]
fn case_sensitive_local_part() {
let low = EmailAddress::new("alice", "example.org").unwrap();
let up = EmailAddress::new("ALICE", "example.org").unwrap();
assert_ne!(low, up);
}
#[test]
fn test_good_example_from_wikipedia_01() {
assert!(EmailAddress::is_valid("simple@example.com"));
}
#[test]
fn test_good_example_from_wikipedia_02() {
assert!(EmailAddress::is_valid("very.common@example.com"));
}
#[test]
fn test_good_example_from_wikipedia_03() {
assert!(EmailAddress::is_valid(
"disposable.style.email.with+symbol@example.com"
));
}
#[test]
fn test_good_example_from_wikipedia_04() {
assert!(EmailAddress::is_valid(
"other.email-with-hyphen@example.com"
));
}
#[test]
fn test_good_example_from_wikipedia_05() {
assert!(EmailAddress::is_valid("fully-qualified-domain@example.com"));
}
#[test]
fn test_good_example_from_wikipedia_06() {
assert!(EmailAddress::is_valid("user.name+tag+sorting@example.com"));
}
#[test]
fn test_good_example_from_wikipedia_07() {
assert!(EmailAddress::is_valid("x@example.com"));
}
#[test]
fn test_good_example_from_wikipedia_08() {
assert!(EmailAddress::is_valid("example-indeed@strange-example.com"));
}
#[test]
fn test_good_example_from_wikipedia_09() {
assert!(EmailAddress::is_valid("admin@mailserver1"));
}
#[test]
fn test_good_example_from_wikipedia_10() {
assert!(EmailAddress::is_valid("example@s.example"));
}
#[test]
fn test_good_example_from_wikipedia_11() {
assert_eq!(check_address("\" \"@example.org"), Ok(3));
}
#[test]
fn test_good_example_from_wikipedia_12() {
assert!(EmailAddress::is_valid("\"john..doe\"@example.org"));
}
#[test]
fn test_good_example_from_wikipedia_13() {
assert!(EmailAddress::is_valid("mailhost!username@example.org"));
}
#[test]
fn test_good_example_from_wikipedia_14() {
assert!(EmailAddress::is_valid("user%example.com@example.org"));
}
#[test]
fn test_good_example_from_wikipedia_15() {
assert!(EmailAddress::is_valid("jsmith@[192.168.2.1]"));
}
#[test]
fn test_good_example_from_wikipedia_16() {
assert!(EmailAddress::is_valid("jsmith@[IPv6:2001:db8::1]"));
}
#[test]
fn test_good_example_from_wikipedia_17() {
assert!(EmailAddress::is_valid(
"user+mailbox/department=shipping@example.com"
));
}
#[test]
fn test_good_example_from_wikipedia_18() {
assert!(EmailAddress::is_valid("!#$%&'*+-/=?^_`.{|}~@example.com"));
}
#[test]
fn test_good_example_from_wikipedia_19() {
assert!(EmailAddress::is_valid("\"Abc@def\"@example.com"));
}
#[test]
fn test_good_example_from_wikipedia_20() {
assert!(EmailAddress::is_valid("\"Joe.\\\\Blow\"@example.com"));
}
#[test]
fn test_good_example_from_wikipedia_21() {
assert!(EmailAddress::is_valid("用户@例子.广告"));
}
#[test]
fn test_good_example_from_wikipedia_22() {
assert!(EmailAddress::is_valid("अजय@डाटा.भारत"));
}
#[test]
fn test_good_example_from_wikipedia_23() {
assert!(EmailAddress::is_valid("квіточка@пошта.укр"));
}
#[test]
fn test_good_example_from_wikipedia_24() {
assert!(EmailAddress::is_valid("θσερ@εχαμπλε.ψομ"));
}
#[test]
fn test_good_example_from_wikipedia_25() {
assert!(EmailAddress::is_valid("Dörte@Sörensen.example.com"));
}
#[test]
fn test_good_example_from_wikipedia_26() {
assert!(EmailAddress::is_valid("коля@пример.рф"));
}
#[test]
fn test_bad_examples_from_wikipedia_00() {
assert_eq!(
EmailAddress::from_str("Abc.example.com"),
Err(Error::MissingSeparator)
);
}
#[test]
fn test_bad_examples_from_wikipedia_01() {
assert_eq!(
EmailAddress::from_str("A@b@c@example.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_examples_from_wikipedia_02() {
assert_eq!(
EmailAddress::from_str("a\"b(c)d,e:f;g<h>i[j\\k]l@example.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_examples_from_wikipedia_03() {
assert_eq!(
EmailAddress::from_str("just\"not\"right@example.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_examples_from_wikipedia_04() {
assert_eq!(
EmailAddress::from_str("this is\"not\\allowed@example.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_examples_from_wikipedia_05() {
assert_eq!(
EmailAddress::from_str("this\\ still\"not\\allowed@example.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_examples_from_wikipedia_06() {
assert_eq!(
EmailAddress::from_str(
"1234567890123456789012345678901234567890123456789012345678901234+x@example.com"
),
Err(Error::LocalPartTooLong(LOCAL_PART_MAX_LEN, 66))
);
}
#[test]
fn test_bad_example_01() {
assert_eq!(
EmailAddress::from_str(
"foo@example.v1234567890123456789012345678901234567890123456789012345678901234v.com"
),
Err(Error::SubDomainTooLong(SUB_DOMAIN_MAX_LENGTH, 66))
);
}
#[test]
fn test_bad_example_02() {
assert_eq!(
EmailAddress::from_str("@example.com"),
Err(Error::LocalPartEmpty)
);
}
#[test]
fn test_bad_example_03() {
assert_eq!(
EmailAddress::from_str("\"\"@example.com"),
Err(Error::LocalPartEmpty)
);
assert_eq!(
EmailAddress::from_str("\"@example.com"),
Err(Error::LocalPartEmpty)
);
}
#[test]
fn test_bad_example_04() {
assert_eq!(EmailAddress::from_str("simon@"), Err(Error::DomainEmpty));
}
#[test]
fn test_bad_example_05() {
assert_eq!(
EmailAddress::from_str("example@invalid-.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_example_06() {
assert_eq!(
EmailAddress::from_str("example@-invalid.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_example_07() {
assert_eq!(
EmailAddress::from_str("example@invalid.com-"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_example_08() {
assert_eq!(
EmailAddress::from_str("example@inv-.alid-.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_example_09() {
assert_eq!(
EmailAddress::from_str("example@-inv.alid-.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_example_10() {
assert_eq!(
EmailAddress::from_str("example@-.com"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_example_11() {
assert_eq!(
EmailAddress::from_str("example@-"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_example_12() {
assert_eq!(
EmailAddress::from_str("example@-abc"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_example_13() {
assert_eq!(
EmailAddress::from_str("example@abc-"),
Err(Error::InvalidCharacter)
);
}
#[test]
fn test_bad_example_14() {
assert_eq!(
EmailAddress::from_str("example@.com"),
Err(Error::SubDomainEmpty)
);
}
#[test]
fn test_to_uri() {
let addr = EmailAddress::from_str("alice@example.org").unwrap();
assert_eq!(addr.to_uri(), "mailto:alice@example.org".to_string());
}
}