#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AddressValidationMode {
#[default]
Practical,
StrictAscii,
Internationalized,
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AddressValidationError {
Empty,
MissingAt,
TooManyAtSigns,
EmptyLocalPart,
EmptyDomain,
InvalidLocalPart,
InvalidDomain,
InvalidDisplayName,
NonAscii,
}
impl fmt::Display for AddressValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("email address value cannot be empty"),
Self::MissingAt => formatter.write_str("email address must contain an at sign"),
Self::TooManyAtSigns => {
formatter.write_str("email address must contain only one at sign")
}
Self::EmptyLocalPart => formatter.write_str("email local part cannot be empty"),
Self::EmptyDomain => formatter.write_str("email domain part cannot be empty"),
Self::InvalidLocalPart => formatter.write_str("invalid email local part"),
Self::InvalidDomain => formatter.write_str("invalid email domain part"),
Self::InvalidDisplayName => formatter.write_str("invalid email display name"),
Self::NonAscii => {
formatter.write_str("email value must be ASCII for this validation mode")
}
}
}
}
impl Error for AddressValidationError {}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct LocalPart(String);
impl LocalPart {
pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
Self::new_with_mode(value, AddressValidationMode::Practical)
}
pub fn new_with_mode(
value: impl AsRef<str>,
mode: AddressValidationMode,
) -> Result<Self, AddressValidationError> {
validate_local_part(value.as_ref(), mode).map(|value| Self(value.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for LocalPart {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for LocalPart {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for LocalPart {
type Err = AddressValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl TryFrom<&str> for LocalPart {
type Error = AddressValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DomainPart(String);
impl DomainPart {
pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
Self::new_with_mode(value, AddressValidationMode::Practical)
}
pub fn new_with_mode(
value: impl AsRef<str>,
mode: AddressValidationMode,
) -> Result<Self, AddressValidationError> {
validate_domain_part(value.as_ref(), mode).map(|value| Self(value.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for DomainPart {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for DomainPart {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for DomainPart {
type Err = AddressValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl TryFrom<&str> for DomainPart {
type Error = AddressValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct EmailAddress {
local_part: LocalPart,
domain_part: DomainPart,
}
impl EmailAddress {
pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
Self::new_with_mode(value, AddressValidationMode::Practical)
}
pub fn new_with_mode(
value: impl AsRef<str>,
mode: AddressValidationMode,
) -> Result<Self, AddressValidationError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
return Err(AddressValidationError::Empty);
}
let mut parts = trimmed.split('@');
let local = parts.next().ok_or(AddressValidationError::MissingAt)?;
let domain = parts.next().ok_or(AddressValidationError::MissingAt)?;
if parts.next().is_some() {
return Err(AddressValidationError::TooManyAtSigns);
}
Self::from_parts_with_mode(local, domain, mode)
}
pub fn from_parts(
local_part: impl AsRef<str>,
domain_part: impl AsRef<str>,
) -> Result<Self, AddressValidationError> {
Self::from_parts_with_mode(local_part, domain_part, AddressValidationMode::Practical)
}
pub fn from_parts_with_mode(
local_part: impl AsRef<str>,
domain_part: impl AsRef<str>,
mode: AddressValidationMode,
) -> Result<Self, AddressValidationError> {
Ok(Self {
local_part: LocalPart::new_with_mode(local_part, mode)?,
domain_part: DomainPart::new_with_mode(domain_part, mode)?,
})
}
#[must_use]
pub const fn local_part(&self) -> &LocalPart {
&self.local_part
}
#[must_use]
pub const fn domain_part(&self) -> &DomainPart {
&self.domain_part
}
#[must_use]
pub fn into_string(self) -> String {
self.to_string()
}
}
impl fmt::Display for EmailAddress {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}@{}", self.local_part, self.domain_part)
}
}
impl FromStr for EmailAddress {
type Err = AddressValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl TryFrom<&str> for EmailAddress {
type Error = AddressValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DisplayName(String);
impl DisplayName {
pub fn new(value: impl AsRef<str>) -> Result<Self, AddressValidationError> {
validate_display_name(value.as_ref()).map(|value| Self(value.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for DisplayName {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for DisplayName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for DisplayName {
type Err = AddressValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Mailbox {
display_name: Option<DisplayName>,
address: EmailAddress,
}
impl Mailbox {
pub fn new(
display_name: Option<&str>,
address: impl AsRef<str>,
) -> Result<Self, AddressValidationError> {
Ok(Self {
display_name: display_name.map(DisplayName::new).transpose()?,
address: EmailAddress::new(address)?,
})
}
#[must_use]
pub const fn from_address(address: EmailAddress) -> Self {
Self {
display_name: None,
address,
}
}
pub fn with_display_name(
mut self,
display_name: impl AsRef<str>,
) -> Result<Self, AddressValidationError> {
self.display_name = Some(DisplayName::new(display_name)?);
Ok(self)
}
#[must_use]
pub const fn display_name(&self) -> Option<&DisplayName> {
self.display_name.as_ref()
}
#[must_use]
pub const fn address(&self) -> &EmailAddress {
&self.address
}
}
impl fmt::Display for Mailbox {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(display_name) = &self.display_name {
write!(
formatter,
"\"{}\" <{}>",
escape_display_name(display_name.as_str()),
self.address
)
} else {
write!(formatter, "{}", self.address)
}
}
}
impl FromStr for Mailbox {
type Err = AddressValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AddressValidationError::Empty);
}
if let Some(start) = trimmed.rfind('<') {
let end = trimmed
.rfind('>')
.ok_or(AddressValidationError::InvalidLocalPart)?;
if end <= start {
return Err(AddressValidationError::InvalidLocalPart);
}
let display = trimmed[..start].trim().trim_matches('"').trim();
let address = trimmed[start + 1..end].trim();
let display_name = if display.is_empty() {
None
} else {
Some(display)
};
Self::new(display_name, address)
} else {
Self::new(None, trimmed)
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MailboxList {
mailboxes: Vec<Mailbox>,
}
impl MailboxList {
#[must_use]
pub const fn new() -> Self {
Self {
mailboxes: Vec::new(),
}
}
#[must_use]
pub fn with_mailbox(mut self, mailbox: Mailbox) -> Self {
self.mailboxes.push(mailbox);
self
}
pub fn push(&mut self, mailbox: Mailbox) {
self.mailboxes.push(mailbox);
}
#[must_use]
pub fn as_slice(&self) -> &[Mailbox] {
&self.mailboxes
}
#[must_use]
pub fn len(&self) -> usize {
self.mailboxes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.mailboxes.is_empty()
}
}
impl fmt::Display for MailboxList {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, mailbox) in self.mailboxes.iter().enumerate() {
if index > 0 {
formatter.write_str(", ")?;
}
write!(formatter, "{mailbox}")?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AddressGroup {
name: DisplayName,
members: MailboxList,
}
impl AddressGroup {
pub fn new(
name: impl AsRef<str>,
members: MailboxList,
) -> Result<Self, AddressValidationError> {
Ok(Self {
name: DisplayName::new(name)?,
members,
})
}
#[must_use]
pub const fn name(&self) -> &DisplayName {
&self.name
}
#[must_use]
pub const fn members(&self) -> &MailboxList {
&self.members
}
}
impl fmt::Display for AddressGroup {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {};", self.name, self.members)
}
}
fn validate_local_part(
value: &str,
mode: AddressValidationMode,
) -> Result<&str, AddressValidationError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AddressValidationError::EmptyLocalPart);
}
if mode != AddressValidationMode::Internationalized && !trimmed.is_ascii() {
return Err(AddressValidationError::NonAscii);
}
if trimmed.starts_with('.') || trimmed.ends_with('.') || trimmed.contains("..") {
return Err(AddressValidationError::InvalidLocalPart);
}
if trimmed.chars().any(|character| {
character.is_control()
|| character.is_whitespace()
|| matches!(character, '@' | '<' | '>' | ',' | ';')
|| (mode != AddressValidationMode::Internationalized && !is_local_ascii(character))
}) {
return Err(AddressValidationError::InvalidLocalPart);
}
Ok(trimmed)
}
fn validate_domain_part(
value: &str,
mode: AddressValidationMode,
) -> Result<&str, AddressValidationError> {
let trimmed = value.trim().trim_end_matches('.');
if trimmed.is_empty() {
return Err(AddressValidationError::EmptyDomain);
}
if mode != AddressValidationMode::Internationalized && !trimmed.is_ascii() {
return Err(AddressValidationError::NonAscii);
}
if trimmed.starts_with('.') || trimmed.contains("..") {
return Err(AddressValidationError::InvalidDomain);
}
for label in trimmed.split('.') {
if label.is_empty() || label.starts_with('-') || label.ends_with('-') {
return Err(AddressValidationError::InvalidDomain);
}
if label.chars().any(|character| {
character.is_control()
|| character.is_whitespace()
|| matches!(character, '@' | '<' | '>' | ',' | ';' | '_')
|| (mode != AddressValidationMode::Internationalized && !is_domain_ascii(character))
}) {
return Err(AddressValidationError::InvalidDomain);
}
}
Ok(trimmed)
}
fn validate_display_name(value: &str) -> Result<&str, AddressValidationError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AddressValidationError::Empty);
}
if trimmed
.chars()
.any(|character| character.is_control() || matches!(character, '<' | '>' | '\r' | '\n'))
{
return Err(AddressValidationError::InvalidDisplayName);
}
Ok(trimmed)
}
fn is_local_ascii(character: char) -> bool {
character.is_ascii_alphanumeric()
|| matches!(
character,
'!' | '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '/'
| '='
| '?'
| '^'
| '_'
| '`'
| '{'
| '|'
| '}'
| '~'
| '.'
)
}
fn is_domain_ascii(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '-' | '.')
}
fn escape_display_name(value: &str) -> String {
let mut escaped = String::new();
for character in value.chars() {
if matches!(character, '\\' | '"') {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
#[cfg(test)]
mod tests {
use super::{
AddressGroup, AddressValidationError, AddressValidationMode, EmailAddress, Mailbox,
MailboxList,
};
#[test]
fn parses_practical_addresses() -> Result<(), AddressValidationError> {
let address: EmailAddress = "jane.doe+notes@example.com".parse()?;
assert_eq!(address.local_part().as_str(), "jane.doe+notes");
assert_eq!(address.domain_part().as_str(), "example.com");
assert_eq!(address.to_string(), "jane.doe+notes@example.com");
Ok(())
}
#[test]
fn validation_modes_are_explicit() {
assert_eq!(
EmailAddress::new_with_mode("jane@exämple.test", AddressValidationMode::StrictAscii),
Err(AddressValidationError::NonAscii)
);
assert!(
EmailAddress::new_with_mode(
"jane@exämple.test",
AddressValidationMode::Internationalized
)
.is_ok()
);
}
#[test]
fn renders_mailbox_lists_and_groups() -> Result<(), AddressValidationError> {
let jane = Mailbox::new(Some("Jane Doe"), "jane@example.com")?;
let ada: Mailbox = "Ada <ada@example.com>".parse()?;
let list = MailboxList::new().with_mailbox(jane).with_mailbox(ada);
let group = AddressGroup::new("Team", list)?;
assert_eq!(
group.to_string(),
"Team: \"Jane Doe\" <jane@example.com>, \"Ada\" <ada@example.com>;"
);
Ok(())
}
#[test]
fn rejects_obvious_invalid_addresses() {
assert_eq!(
EmailAddress::new("jane.example.com"),
Err(AddressValidationError::MissingAt)
);
assert_eq!(
EmailAddress::new("jane@@example.com"),
Err(AddressValidationError::TooManyAtSigns)
);
assert_eq!(
EmailAddress::new("jane@-example.com"),
Err(AddressValidationError::InvalidDomain)
);
}
}