use crate::encode::{encode_word, sanitize_header_text};
use rustlavel_core::{Error, Result};
use std::fmt;
const SPECIALS: &[char] = &['(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '[', ']', '.'];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Address {
name: Option<String>,
email: String,
}
impl Address {
pub fn new(email: impl Into<String>) -> Result<Address> {
let email = email.into().trim().to_string();
validate(&email)?;
Ok(Address { name: None, email })
}
pub fn named(name: impl Into<String>, email: impl Into<String>) -> Result<Address> {
let mut address = Address::new(email)?;
let name = sanitize_header_text(&name.into());
address.name = (!name.is_empty()).then_some(name);
Ok(address)
}
pub fn parse(input: &str) -> Result<Address> {
let input = input.trim();
let Some(open) = input.rfind('<') else {
return Address::new(input);
};
let close = input[open..].find('>').map(|at| open + at).ok_or_else(|| {
Error::msg(format!("`{input}` is missing the closing `>` around the address"))
})?;
let email = &input[open + 1..close];
let name = input[..open].trim().trim_matches('"');
if name.is_empty() { Address::new(email) } else { Address::named(name, email) }
}
pub fn email(&self) -> &str {
&self.email
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn domain(&self) -> &str {
self.email.split_once('@').map(|(_, domain)| domain).unwrap_or("localhost")
}
pub fn to_header(&self) -> String {
match &self.name {
None => self.email.clone(),
Some(name) => format!("{} <{}>", encode_display_name(name), self.email),
}
}
}
impl fmt::Display for Address {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_header())
}
}
pub trait IntoAddress {
fn into_address(self) -> Result<Address>;
}
impl IntoAddress for Address {
fn into_address(self) -> Result<Address> {
Ok(self)
}
}
impl IntoAddress for &Address {
fn into_address(self) -> Result<Address> {
Ok(self.clone())
}
}
impl IntoAddress for &str {
fn into_address(self) -> Result<Address> {
Address::parse(self)
}
}
impl IntoAddress for String {
fn into_address(self) -> Result<Address> {
Address::parse(&self)
}
}
impl IntoAddress for (&str, &str) {
fn into_address(self) -> Result<Address> {
Address::named(self.0, self.1)
}
}
fn encode_display_name(name: &str) -> String {
if !name.is_ascii() {
return encode_word(name);
}
if name.contains(SPECIALS) {
let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
return format!("\"{escaped}\"");
}
name.to_string()
}
fn validate(email: &str) -> Result<()> {
if email.is_empty() {
return Err(Error::msg("an email address cannot be empty"));
}
if email.len() > 320 {
return Err(Error::msg(format!(
"`{email}` is longer than the 320 characters an address may have"
)));
}
if email.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(Error::msg(format!(
"`{email}` contains whitespace or a control character — an address may contain neither"
)));
}
let Some((local, domain)) = email.split_once('@') else {
return Err(Error::msg(format!("`{email}` is not an email address — it has no `@`")));
};
if local.is_empty() {
return Err(Error::msg(format!("`{email}` has nothing before the `@`")));
}
if domain.is_empty() {
return Err(Error::msg(format!("`{email}` has no domain after the `@`")));
}
if domain.contains('@') {
return Err(Error::msg(format!("`{email}` has more than one `@`")));
}
if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
return Err(Error::msg(format!("`{email}` has a malformed domain")));
}
Ok(())
}
pub fn address_list(addresses: &[Address]) -> String {
addresses.iter().map(Address::to_header).collect::<Vec<_>>().join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_address_needs_no_name() {
let address = Address::new("ada@example.com").unwrap();
assert_eq!(address.to_header(), "ada@example.com");
assert_eq!(address.domain(), "example.com");
assert_eq!(address.name(), None);
}
#[test]
fn a_display_name_is_written_before_the_angle_brackets() {
let address = Address::named("Ada Lovelace", "ada@example.com").unwrap();
assert_eq!(address.to_header(), "Ada Lovelace <ada@example.com>");
}
#[test]
fn a_name_containing_specials_is_quoted() {
let address = Address::named("Lovelace, Ada", "ada@example.com").unwrap();
assert_eq!(address.to_header(), "\"Lovelace, Ada\" <ada@example.com>");
let awkward = Address::named("Ada \"The Countess\"", "ada@example.com").unwrap();
assert_eq!(
awkward.to_header(),
"\"Ada \\\"The Countess\\\"\" <ada@example.com>"
);
}
#[test]
fn a_non_ascii_name_becomes_an_encoded_word_and_is_not_quoted() {
let address = Address::named("Bj\u{f6}rn Str\u{f6}m", "bjorn@example.com").unwrap();
assert_eq!(address.to_header(), "=?UTF-8?B?QmrDtnJuIFN0csO2bQ==?= <bjorn@example.com>");
assert!(!address.to_header().contains('"'));
}
#[test]
fn addresses_parse_out_of_the_three_shapes_that_occur_in_practice() {
assert_eq!(Address::parse("ada@example.com").unwrap().email(), "ada@example.com");
assert_eq!(Address::parse("<ada@example.com>").unwrap().email(), "ada@example.com");
let named = Address::parse("Ada Lovelace <ada@example.com>").unwrap();
assert_eq!((named.name(), named.email()), (Some("Ada Lovelace"), "ada@example.com"));
let quoted = Address::parse("\"Lovelace, Ada\" <ada@example.com>").unwrap();
assert_eq!(quoted.name(), Some("Lovelace, Ada"));
}
#[test]
fn a_malformed_address_is_refused_with_a_reason() {
for bad in ["", "no-at-sign", "@example.com", "ada@", "ada@@example.com", "a b@x.com"] {
let error = Address::new(bad).unwrap_err().to_string();
assert!(!error.is_empty(), "`{bad}` should have been refused");
}
let error = Address::new("no-at-sign").unwrap_err().to_string();
assert!(error.contains("no `@`"), "{error}");
}
#[test]
fn an_address_cannot_carry_a_newline_into_a_smtp_command() {
let error = Address::new("ada@example.com\r\nRCPT TO:<evil@example.com>").unwrap_err();
assert!(error.to_string().contains("whitespace"), "{error}");
}
#[test]
fn a_newline_in_a_display_name_is_flattened_rather_than_folded() {
let address = Address::named("Ada\r\nBcc: evil@example.com", "ada@example.com").unwrap();
assert!(!address.to_header().contains('\n'));
assert!(address.to_header().starts_with("\"Ada Bcc"));
}
#[test]
fn a_list_of_addresses_is_comma_separated() {
let list = address_list(&[
Address::named("Ada", "ada@example.com").unwrap(),
Address::new("grace@example.com").unwrap(),
]);
assert_eq!(list, "Ada <ada@example.com>, grace@example.com");
}
#[test]
fn the_conversion_trait_accepts_the_shapes_a_caller_reaches_for() {
assert_eq!("ada@example.com".into_address().unwrap().email(), "ada@example.com");
assert_eq!(
("Ada", "ada@example.com").into_address().unwrap().to_header(),
"Ada <ada@example.com>"
);
assert_eq!(
String::from("Ada <ada@example.com>").into_address().unwrap().email(),
"ada@example.com"
);
}
}