use core::fmt;
use core::str::FromStr;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "zeroize")]
use zeroize::Zeroize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub enum EmailError {
Empty,
TooLong(usize),
MissingAtSymbol,
MultipleAtSymbols,
EmptyLocalPart,
EmptyDomain,
LocalPartTooLong(usize),
InvalidLocalPartChar(char),
LocalPartStartsWithDot,
LocalPartEndsWithDot,
LocalPartConsecutiveDots,
InvalidDomain,
}
impl fmt::Display for EmailError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "email cannot be empty"),
Self::TooLong(len) => write!(
f,
"email exceeds maximum length of 254 characters (got {len})"
),
Self::MissingAtSymbol => write!(f, "email must contain an @ symbol"),
Self::MultipleAtSymbols => write!(f, "email must contain exactly one @ symbol"),
Self::EmptyLocalPart => write!(f, "email local part cannot be empty"),
Self::EmptyDomain => write!(f, "email domain part cannot be empty"),
Self::LocalPartTooLong(len) => write!(
f,
"email local part exceeds maximum length of 64 characters (got {len})"
),
Self::InvalidLocalPartChar(c) => {
write!(f, "email local part contains invalid character '{c}'")
}
Self::LocalPartStartsWithDot => {
write!(f, "email local part cannot start with a dot")
}
Self::LocalPartEndsWithDot => write!(f, "email local part cannot end with a dot"),
Self::LocalPartConsecutiveDots => {
write!(f, "email local part cannot contain consecutive dots")
}
Self::InvalidDomain => write!(f, "email domain part is invalid"),
}
}
}
#[cfg(feature = "std")]
impl std::error::Error for EmailError {}
#[repr(transparent)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "zeroize", derive(Zeroize))]
pub struct Email(heapless::String<254>);
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for Email {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
const DIGITS: &[u8] = b"0123456789";
const SPECIAL: &[u8] = b"!#$%&'*+-/=?^_`{|}~";
let local_len = 1 + (u8::arbitrary(u)? % 20).min(19);
let mut local = heapless::String::<64>::new();
for _ in 0..local_len {
let byte = u8::arbitrary(u)?;
let c = match byte % 4 {
0 => ALPHABET[(byte % 26) as usize] as char,
1 => DIGITS[(byte % 10) as usize] as char,
2 => SPECIAL[byte as usize % SPECIAL.len()] as char,
_ => '.',
};
local
.push(c)
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
}
let label_count = 1 + (u8::arbitrary(u)? % 3);
let mut domain = heapless::String::<253>::new();
for label_idx in 0..label_count {
let label_len = 1 + (u8::arbitrary(u)? % 20).min(19);
for _ in 0..label_len {
let byte = u8::arbitrary(u)?;
let c = match byte % 2 {
0 => ALPHABET[(byte % 26) as usize] as char,
_ => DIGITS[(byte % 10) as usize] as char,
};
domain
.push(c)
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
}
if label_idx < label_count - 1 {
domain
.push('.')
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
}
}
let mut email = heapless::String::<254>::new();
email
.push_str(&local)
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
email
.push('@')
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
email
.push_str(&domain)
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
Ok(Self(email))
}
}
impl Email {
#[allow(clippy::missing_panics_doc)]
pub fn new(s: &str) -> Result<Self, EmailError> {
if s.is_empty() {
return Err(EmailError::Empty);
}
if s.len() > 254 {
return Err(EmailError::TooLong(s.len()));
}
let at_count = s.chars().filter(|&c| c == '@').count();
if at_count == 0 {
return Err(EmailError::MissingAtSymbol);
}
if at_count > 1 {
return Err(EmailError::MultipleAtSymbols);
}
let (local_part, domain_part) = s.split_once('@').expect("at_count > 0");
if local_part.is_empty() {
return Err(EmailError::EmptyLocalPart);
}
if domain_part.is_empty() {
return Err(EmailError::EmptyDomain);
}
if local_part.len() > 64 {
return Err(EmailError::LocalPartTooLong(local_part.len()));
}
let mut local_validated = heapless::String::<64>::new();
let mut last_char: Option<char> = None;
for c in local_part.chars() {
if !Self::is_valid_local_char(c) {
return Err(EmailError::InvalidLocalPartChar(c));
}
if c == '.' {
if last_char.is_none() {
return Err(EmailError::LocalPartStartsWithDot);
}
if last_char == Some('.') {
return Err(EmailError::LocalPartConsecutiveDots);
}
}
last_char = Some(c);
local_validated
.push(c)
.map_err(|_| EmailError::LocalPartTooLong(64))?;
}
if local_part.ends_with('.') {
return Err(EmailError::LocalPartEndsWithDot);
}
let mut domain_validated = heapless::String::<253>::new();
for c in domain_part.chars() {
domain_validated
.push(c.to_ascii_lowercase())
.map_err(|_| EmailError::TooLong(253))?;
}
let total_len = local_validated.len() + 1 + domain_validated.len();
if total_len > 254 {
return Err(EmailError::TooLong(total_len));
}
let mut inner = heapless::String::<254>::new();
inner
.push_str(&local_validated)
.map_err(|_| EmailError::TooLong(total_len))?;
inner
.push('@')
.map_err(|_| EmailError::TooLong(total_len))?;
inner
.push_str(&domain_validated)
.map_err(|_| EmailError::TooLong(total_len))?;
Ok(Self(inner))
}
const fn is_valid_local_char(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(
c,
'!' | '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '/'
| '='
| '?'
| '^'
| '_'
| '`'
| '{'
| '|'
| '}'
| '~'
| '.'
)
}
#[must_use]
#[inline]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
#[inline]
pub const fn as_inner(&self) -> &heapless::String<254> {
&self.0
}
#[must_use]
#[inline]
pub fn into_inner(self) -> heapless::String<254> {
self.0
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn local_part(&self) -> &str {
self.as_str()
.split_once('@')
.map(|(local, _)| local)
.expect("email always contains @")
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn domain_part(&self) -> &str {
self.as_str()
.split_once('@')
.map(|(_, domain)| domain)
.expect("email always contains @")
}
}
impl TryFrom<&str> for Email {
type Error = EmailError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl From<Email> for heapless::String<254> {
fn from(email: Email) -> Self {
email.0
}
}
impl FromStr for Email {
type Err = EmailError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl fmt::Display for Email {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_valid_email() {
assert!(Email::new("user@example.com").is_ok());
assert!(Email::new("user.name@example.com").is_ok());
assert!(Email::new("user+tag@example.com").is_ok());
assert!(Email::new("user-name@example.com").is_ok());
assert!(Email::new("user_name@example.com").is_ok());
}
#[test]
fn test_empty_email() {
assert_eq!(Email::new(""), Err(EmailError::Empty));
}
#[test]
fn test_too_long_email() {
let long = format!("{}@example.com", "a".repeat(200));
assert_eq!(Email::new(&long), Err(EmailError::LocalPartTooLong(200)));
}
#[test]
fn test_total_too_long_email() {
let long = format!("user@{}.example.com", "a".repeat(250));
assert_eq!(Email::new(&long), Err(EmailError::TooLong(long.len())));
}
#[test]
fn test_missing_at_symbol() {
assert_eq!(
Email::new("userexample.com"),
Err(EmailError::MissingAtSymbol)
);
}
#[test]
fn test_multiple_at_symbols() {
assert_eq!(
Email::new("user@@example.com"),
Err(EmailError::MultipleAtSymbols)
);
assert_eq!(
Email::new("user@exa@mple.com"),
Err(EmailError::MultipleAtSymbols)
);
}
#[test]
fn test_empty_local_part() {
assert_eq!(Email::new("@example.com"), Err(EmailError::EmptyLocalPart));
}
#[test]
fn test_empty_domain_part() {
assert_eq!(Email::new("user@"), Err(EmailError::EmptyDomain));
}
#[test]
fn test_local_part_too_long() {
let long_local = "a".repeat(65);
assert_eq!(
Email::new(&format!("{long_local}@example.com")),
Err(EmailError::LocalPartTooLong(65))
);
}
#[test]
fn test_invalid_local_part_char() {
assert_eq!(
Email::new("user name@example.com"),
Err(EmailError::InvalidLocalPartChar(' '))
);
assert_eq!(
Email::new("user,name@example.com"),
Err(EmailError::InvalidLocalPartChar(','))
);
}
#[test]
fn test_local_part_starts_with_dot() {
assert_eq!(
Email::new(".user@example.com"),
Err(EmailError::LocalPartStartsWithDot)
);
}
#[test]
fn test_local_part_ends_with_dot() {
assert_eq!(
Email::new("user.@example.com"),
Err(EmailError::LocalPartEndsWithDot)
);
}
#[test]
fn test_local_part_consecutive_dots() {
assert_eq!(
Email::new("user..name@example.com"),
Err(EmailError::LocalPartConsecutiveDots)
);
}
#[test]
fn test_as_str() {
let email = Email::new("user@example.com").unwrap();
assert_eq!(email.as_str(), "user@example.com");
}
#[test]
fn test_as_inner() {
let email = Email::new("user@example.com").unwrap();
let inner = email.as_inner();
assert_eq!(inner.as_str(), "user@example.com");
}
#[test]
fn test_into_inner() {
let email = Email::new("user@example.com").unwrap();
let inner = email.into_inner();
assert_eq!(inner.as_str(), "user@example.com");
}
#[test]
fn test_local_part() {
let email = Email::new("user@example.com").unwrap();
assert_eq!(email.local_part(), "user");
}
#[test]
fn test_domain_part() {
let email = Email::new("user@example.com").unwrap();
assert_eq!(email.domain_part(), "example.com");
}
#[test]
fn test_try_from_str() {
let email = Email::try_from("user@example.com").unwrap();
assert_eq!(email.as_str(), "user@example.com");
}
#[test]
fn test_from_email_to_string() {
let email = Email::new("user@example.com").unwrap();
let inner: heapless::String<254> = email.into();
assert_eq!(inner.as_str(), "user@example.com");
}
#[test]
fn test_from_str() {
let email: Email = "user@example.com".parse().unwrap();
assert_eq!(email.as_str(), "user@example.com");
}
#[test]
fn test_from_str_invalid() {
assert!("".parse::<Email>().is_err());
assert!("userexample.com".parse::<Email>().is_err());
assert!("@example.com".parse::<Email>().is_err());
assert!("user@".parse::<Email>().is_err());
}
#[test]
fn test_display() {
let email = Email::new("user@example.com").unwrap();
assert_eq!(format!("{email}"), "user@example.com");
}
#[test]
fn test_equality() {
let email1 = Email::new("user@example.com").unwrap();
let email2 = Email::new("user@example.com").unwrap();
let email3 = Email::new("user2@example.com").unwrap();
assert_eq!(email1, email2);
assert_ne!(email1, email3);
}
#[test]
fn test_ordering() {
let email1 = Email::new("a@example.com").unwrap();
let email2 = Email::new("b@example.com").unwrap();
assert!(email1 < email2);
}
#[test]
fn test_clone() {
let email = Email::new("user@example.com").unwrap();
let email2 = email.clone();
assert_eq!(email, email2);
}
#[test]
fn test_special_characters() {
assert!(Email::new("user+tag@example.com").is_ok());
assert!(Email::new("user!name@example.com").is_ok());
assert!(Email::new("user#name@example.com").is_ok());
assert!(Email::new("user$name@example.com").is_ok());
assert!(Email::new("user%name@example.com").is_ok());
assert!(Email::new("user&name@example.com").is_ok());
assert!(Email::new("user'name@example.com").is_ok());
assert!(Email::new("user*name@example.com").is_ok());
assert!(Email::new("user-name@example.com").is_ok());
assert!(Email::new("user/name@example.com").is_ok());
assert!(Email::new("user=name@example.com").is_ok());
assert!(Email::new("user?name@example.com").is_ok());
assert!(Email::new("user^name@example.com").is_ok());
assert!(Email::new("user_name@example.com").is_ok());
assert!(Email::new("user`name@example.com").is_ok());
assert!(Email::new("user{name@example.com").is_ok());
assert!(Email::new("user|name@example.com").is_ok());
assert!(Email::new("user}name@example.com").is_ok());
assert!(Email::new("user~name@example.com").is_ok());
}
#[test]
fn test_domain_case_insensitive() {
let email1 = Email::new("user@Example.COM").unwrap();
let email2 = Email::new("user@example.com").unwrap();
assert_eq!(email1, email2);
assert_eq!(email1.as_str(), "user@example.com");
}
#[test]
fn test_local_part_case_sensitive() {
let email1 = Email::new("User@example.com").unwrap();
let email2 = Email::new("user@example.com").unwrap();
assert_ne!(email1, email2);
assert_eq!(email1.as_str(), "User@example.com");
}
#[test]
fn test_maximum_local_part_length() {
let local = "a".repeat(64);
let email = Email::new(&format!("{local}@example.com")).unwrap();
assert_eq!(email.local_part().len(), 64);
}
#[test]
fn test_maximum_email_length() {
let local = "a".repeat(64);
let domain = format!("{}.{}", "b".repeat(63), "c".repeat(63));
let email = Email::new(&format!("{local}@{domain}")).unwrap();
assert_eq!(email.as_str().len(), 64 + 1 + 127);
}
#[test]
fn test_error_display() {
assert_eq!(format!("{}", EmailError::Empty), "email cannot be empty");
assert_eq!(
format!("{}", EmailError::TooLong(300)),
"email exceeds maximum length of 254 characters (got 300)"
);
assert_eq!(
format!("{}", EmailError::MissingAtSymbol),
"email must contain an @ symbol"
);
assert_eq!(
format!("{}", EmailError::MultipleAtSymbols),
"email must contain exactly one @ symbol"
);
assert_eq!(
format!("{}", EmailError::EmptyLocalPart),
"email local part cannot be empty"
);
assert_eq!(
format!("{}", EmailError::EmptyDomain),
"email domain part cannot be empty"
);
assert_eq!(
format!("{}", EmailError::LocalPartTooLong(70)),
"email local part exceeds maximum length of 64 characters (got 70)"
);
assert_eq!(
format!("{}", EmailError::InvalidLocalPartChar(' ')),
"email local part contains invalid character ' '"
);
assert_eq!(
format!("{}", EmailError::LocalPartStartsWithDot),
"email local part cannot start with a dot"
);
assert_eq!(
format!("{}", EmailError::LocalPartEndsWithDot),
"email local part cannot end with a dot"
);
assert_eq!(
format!("{}", EmailError::LocalPartConsecutiveDots),
"email local part cannot contain consecutive dots"
);
assert_eq!(
format!("{}", EmailError::InvalidDomain),
"email domain part is invalid"
);
}
#[test]
fn test_single_character_local() {
assert!(Email::new("a@example.com").is_ok());
assert!(Email::new("1@example.com").is_ok());
assert!(Email::new("!@example.com").is_ok());
}
#[test]
fn test_single_character_domain() {
assert!(Email::new("user@com").is_ok());
}
#[test]
fn test_dots_in_local_part() {
assert!(Email::new("user.name@example.com").is_ok());
assert!(Email::new("u.n.a.m.e@example.com").is_ok());
assert!(Email::new("user.name.last@example.com").is_ok());
}
#[test]
fn test_digits_in_local_part() {
assert!(Email::new("user123@example.com").is_ok());
assert!(Email::new("123user@example.com").is_ok());
assert!(Email::new("123@example.com").is_ok());
}
#[test]
fn test_hash() {
use core::hash::Hash;
use core::hash::Hasher;
#[derive(Default)]
struct SimpleHasher(u64);
impl Hasher for SimpleHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for byte in bytes {
self.0 = self.0.wrapping_mul(31).wrapping_add(*byte as u64);
}
}
}
let email1 = Email::new("user@example.com").unwrap();
let email2 = Email::new("user@example.com").unwrap();
let email3 = Email::new("user2@example.com").unwrap();
let mut hasher1 = SimpleHasher::default();
let mut hasher2 = SimpleHasher::default();
let mut hasher3 = SimpleHasher::default();
email1.hash(&mut hasher1);
email2.hash(&mut hasher2);
email3.hash(&mut hasher3);
assert_eq!(hasher1.finish(), hasher2.finish());
assert_ne!(hasher1.finish(), hasher3.finish());
}
#[test]
fn test_debug() {
let email = Email::new("user@example.com").unwrap();
assert_eq!(format!("{:?}", email), "Email(\"user@example.com\")");
}
#[test]
fn test_from_into_inner_roundtrip() {
let email = Email::new("user@example.com").unwrap();
let inner: heapless::String<254> = email.into();
let email2 = Email::new(inner.as_str()).unwrap();
assert_eq!(email2.as_str(), "user@example.com");
}
}