mailbridge 0.1.2

Provider-neutral transactional email library for Rust services
Documentation
use serde::{Deserialize, Serialize};

use crate::error::{MailError, Result};

const MAX_EMAIL_BYTES: usize = 254;
const MAX_LOCAL_BYTES: usize = 64;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmailAddress {
    name: Option<String>,
    email: String,
    domain: String,
}

impl EmailAddress {
    /// Creates and validates an address with an optional display name.
    ///
    /// # Errors
    ///
    /// Returns an error when the display name contains control characters or
    /// the mailbox is malformed.
    pub fn new(name: impl Into<String>, email: impl Into<String>) -> Result<Self> {
        let name = normalize_name(&name.into())?;
        let email = email.into();
        let domain = parse_domain(&email)?;

        Ok(Self {
            name,
            email,
            domain,
        })
    }

    /// Creates and validates an address without a display name.
    ///
    /// # Errors
    ///
    /// Returns an error when the mailbox is malformed.
    pub fn without_name(email: impl Into<String>) -> Result<Self> {
        let email = email.into();
        let domain = parse_domain(&email)?;

        Ok(Self {
            name: None,
            email,
            domain,
        })
    }

    #[must_use]
    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    #[must_use]
    pub fn email(&self) -> &str {
        &self.email
    }

    #[must_use]
    pub fn domain(&self) -> &str {
        &self.domain
    }

    #[must_use]
    pub fn formatted(&self) -> String {
        match self.name() {
            Some(name) => format!("{} <{}>", format_display_name(name), self.email),
            None => self.email.clone(),
        }
    }
}

fn normalize_name(name: &str) -> Result<Option<String>> {
    validate_no_control_chars(name, "address name")?;

    if name.trim().is_empty() {
        return Ok(None);
    }

    Ok(Some(name.trim().to_owned()))
}

fn parse_domain(email: &str) -> Result<String> {
    validate_no_control_chars(email, "email address")?;

    let trimmed = email.trim();
    if trimmed != email {
        return Err(MailError::Validation(
            "email address must not contain surrounding whitespace".to_owned(),
        ));
    }

    let (local, domain) = trimmed
        .rsplit_once('@')
        .ok_or_else(|| MailError::Validation("email address must contain @".to_owned()))?;

    if local.is_empty() || domain.is_empty() || domain.contains('@') {
        return Err(MailError::Validation(
            "email address must contain one local part and one domain".to_owned(),
        ));
    }

    if trimmed.len() > MAX_EMAIL_BYTES {
        return Err(MailError::Validation(format!(
            "email address must not exceed {MAX_EMAIL_BYTES} bytes"
        )));
    }

    validate_local_part(local)?;
    validate_domain(domain)?;

    Ok(domain.to_ascii_lowercase())
}

fn validate_local_part(local: &str) -> Result<()> {
    if local.len() > MAX_LOCAL_BYTES {
        return Err(MailError::Validation(format!(
            "email local part must not exceed {MAX_LOCAL_BYTES} bytes"
        )));
    }

    if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
        return Err(MailError::Validation(
            "email local part has invalid dot placement".to_owned(),
        ));
    }

    if !local.bytes().all(is_allowed_local_byte) {
        return Err(MailError::Validation(
            "email local part contains unsupported characters".to_owned(),
        ));
    }

    Ok(())
}

fn validate_domain(domain: &str) -> Result<()> {
    if domain.starts_with('.') || domain.ends_with('.') || !domain.contains('.') {
        return Err(MailError::Validation(
            "email domain must contain at least one dot and no edge dots".to_owned(),
        ));
    }

    for label in domain.split('.') {
        validate_domain_label(label)?;
    }

    Ok(())
}

fn validate_domain_label(label: &str) -> Result<()> {
    if label.is_empty() || label.len() > 63 {
        return Err(MailError::Validation(
            "email domain labels must be between 1 and 63 bytes".to_owned(),
        ));
    }

    if label.starts_with('-')
        || label.ends_with('-')
        || !label
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
    {
        return Err(MailError::Validation(
            "email domain labels must contain only letters, digits, or interior hyphens".to_owned(),
        ));
    }

    Ok(())
}

fn validate_no_control_chars(value: &str, field: &str) -> Result<()> {
    if value.chars().any(char::is_control) {
        return Err(MailError::Validation(format!(
            "{field} must not contain control characters"
        )));
    }

    Ok(())
}

fn is_allowed_local_byte(byte: u8) -> bool {
    byte.is_ascii_alphanumeric()
        || matches!(
            byte,
            b'.' | b'!'
                | b'#'
                | b'$'
                | b'%'
                | b'&'
                | b'\''
                | b'*'
                | b'+'
                | b'-'
                | b'/'
                | b'='
                | b'?'
                | b'^'
                | b'_'
                | b'`'
                | b'{'
                | b'|'
                | b'}'
                | b'~'
        )
}

fn format_display_name(name: &str) -> String {
    if name.bytes().all(is_safe_atom_display_byte) {
        return name.to_owned();
    }

    let escaped = name
        .chars()
        .fold(String::with_capacity(name.len() + 2), |mut out, ch| {
            if ch == '"' || ch == '\\' {
                out.push('\\');
            }
            out.push(ch);
            out
        });
    format!("\"{escaped}\"")
}

fn is_safe_atom_display_byte(byte: u8) -> bool {
    byte.is_ascii_alphanumeric() || matches!(byte, b' ' | b'-' | b'_' | b'.' | b'\'')
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn address_formats_named_recipient() {
        let address = EmailAddress::new("User", "user@example.com").expect("valid address");

        assert_eq!(address.formatted(), "User <user@example.com>");
        assert_eq!(address.domain(), "example.com");
    }

    #[test]
    fn address_rejects_header_injection() {
        let error = EmailAddress::new("User\r\nBcc: x@example.com", "user@example.com")
            .expect_err("header injection should fail");

        assert!(matches!(error, MailError::Validation(_)));
    }

    #[test]
    fn address_rejects_malformed_mailboxes() {
        for email in [
            "user example.com",
            "user@example",
            ".user@example.com",
            "user..name@example.com",
            "user@example..com",
            "user@-example.com",
        ] {
            let error = EmailAddress::without_name(email).expect_err("invalid mailbox");

            assert!(matches!(error, MailError::Validation(_)));
        }
    }

    #[test]
    fn display_name_is_quoted_when_needed() {
        let address = EmailAddress::new("Example, Inc.", "user@example.com").expect("valid");

        assert_eq!(address.formatted(), "\"Example, Inc.\" <user@example.com>");
    }
}