use std::fmt::Display;
use std::str::FromStr;
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct EmailAddress {
value: String,
}
impl EmailAddress {
#[must_use]
pub fn as_str(&self) -> &str {
self.value.as_str()
}
}
impl Display for EmailAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for EmailAddress {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl From<EmailAddress> for String {
fn from(value: EmailAddress) -> Self {
value.value
}
}
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub struct EmailAddressParseError(#[from] addr_spec::ParseError);
impl FromStr for EmailAddress {
type Err = EmailAddressParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parsed = addr_spec::AddrSpec::from_str(s)?;
let is_literal = parsed.is_literal();
let (local, domain) = parsed.into_serialized_parts();
let value = if is_literal {
format!("{local}@{domain}")
} else {
format!("{local}@{}", domain.to_ascii_lowercase())
};
Ok(Self { value })
}
}
impl TryFrom<&str> for EmailAddress {
type Error = EmailAddressParseError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::from_str(value)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for EmailAddress {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for EmailAddress {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for EmailAddress {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
let local = u64::arbitrary(u)?;
let domain = u32::arbitrary(u)?;
format!("user{local}@domain{domain}.test")
.parse()
.map_err(|_| arbitrary::Error::IncorrectFormat)
}
}
#[cfg(test)]
mod tests {
use super::EmailAddress;
const RFC_VALID_EMAILS: &[&str] = &[
"jdoe@one.test",
"simple@example.com",
"very.common@example.com",
"disposable.style.email.with+symbol@example.com",
"other.email-with-hyphen@example.com",
"fully-qualified-domain@example.com",
"user.name+tag+sorting@example.com",
"x@example.com",
"example-indeed@strange-example.com",
"admin@mailserver1",
"example@s.example",
"\"john..doe\"@example.org",
"mailhost!username@example.org",
"user%example.com@example.org",
];
const INVALID_EMAILS: &[&str] = &[
"plainaddress",
"@missing-local.org",
"A@b@c@example.com",
"john..doe@example.org",
"john.doe@example..org",
"john.doe.@example.org",
".john.doe@example.org",
];
#[test]
fn email_from_str_accepts_rfc_examples() {
for input in RFC_VALID_EMAILS {
let parsed = input.parse::<EmailAddress>();
assert!(parsed.is_ok(), "expected valid email: {input}");
}
}
#[test]
fn email_from_str_rejects_invalid_examples() {
for input in INVALID_EMAILS {
let parsed = input.parse::<EmailAddress>();
assert!(parsed.is_err(), "expected invalid email: {input}");
}
}
#[test]
fn email_from_str_accepts_ipv4_literal_domain() {
let parsed = "user@[192.168.1.1]".parse::<EmailAddress>();
assert!(parsed.is_ok(), "expected IPv4 literal to parse: {parsed:?}");
}
#[test]
fn email_from_str_accepts_ipv6_literal_domain() {
let parsed = "user@[IPv6:fe80::1]".parse::<EmailAddress>();
assert!(parsed.is_ok(), "expected IPv6 literal to parse: {parsed:?}");
}
#[test]
fn email_domain_is_case_folded_for_eq_and_hash() {
let a: EmailAddress = "User.Name@Example.COM".parse().unwrap();
let b: EmailAddress = "User.Name@example.com".parse().unwrap();
assert_eq!(a, b);
assert_eq!(a.as_str(), "User.Name@example.com");
assert_eq!(b.as_str(), "User.Name@example.com");
use std::collections::HashSet;
let mut set: HashSet<EmailAddress> = HashSet::new();
set.insert(a);
assert!(set.contains(&b));
}
#[test]
fn email_local_part_case_is_preserved() {
let upper: EmailAddress = "John.Doe@example.com".parse().unwrap();
let lower: EmailAddress = "john.doe@example.com".parse().unwrap();
assert_ne!(upper, lower);
assert_eq!(upper.as_str(), "John.Doe@example.com");
assert_eq!(lower.as_str(), "john.doe@example.com");
}
#[test]
fn email_ipv6_literal_domain_is_not_case_folded() {
let parsed: EmailAddress = "user@[IPv6:Fe80::1]".parse().unwrap();
assert!(parsed.as_str().contains("IPv6"));
}
}