use-mailto 0.1.0

mailto URI primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

use use_email_address::{AddressValidationError, EmailAddress};

/// Error returned when mailto primitives fail validation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MailtoError {
    /// Address validation failed.
    Address(AddressValidationError),
    /// A field name or value was empty.
    EmptyField,
    /// The URI did not start with `mailto:`.
    InvalidScheme,
}

impl fmt::Display for MailtoError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Address(error) => write!(formatter, "{error}"),
            Self::EmptyField => formatter.write_str("mailto field cannot be empty"),
            Self::InvalidScheme => formatter.write_str("mailto URI must start with mailto:"),
        }
    }
}

impl Error for MailtoError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Address(error) => Some(error),
            Self::EmptyField | Self::InvalidScheme => None,
        }
    }
}

impl From<AddressValidationError> for MailtoError {
    fn from(value: AddressValidationError) -> Self {
        Self::Address(value)
    }
}

/// Address component in a `mailto:` URI.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MailtoAddress(EmailAddress);

impl MailtoAddress {
    /// Creates a mailto address from address text.
    pub fn new(value: impl AsRef<str>) -> Result<Self, MailtoError> {
        Ok(Self(EmailAddress::new(value)?))
    }

    /// Returns the email address.
    #[must_use]
    pub const fn email_address(&self) -> &EmailAddress {
        &self.0
    }
}

impl fmt::Display for MailtoAddress {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}", self.0)
    }
}

impl FromStr for MailtoAddress {
    type Err = MailtoError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::new(value)
    }
}

/// Standard `mailto:` query field names plus custom fields.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MailtoField {
    /// `to` field.
    To,
    /// `cc` field.
    Cc,
    /// `bcc` field.
    Bcc,
    /// `subject` field.
    Subject,
    /// `body` field.
    Body,
    /// Custom field name.
    Other(String),
}

impl MailtoField {
    /// Creates a custom field name.
    pub fn other(value: impl AsRef<str>) -> Result<Self, MailtoError> {
        let trimmed = value.as_ref().trim();
        if trimmed.is_empty() {
            return Err(MailtoError::EmptyField);
        }
        Ok(Self::Other(trimmed.to_ascii_lowercase()))
    }

    /// Returns the field name.
    #[must_use]
    pub fn as_str(&self) -> &str {
        match self {
            Self::To => "to",
            Self::Cc => "cc",
            Self::Bcc => "bcc",
            Self::Subject => "subject",
            Self::Body => "body",
            Self::Other(value) => value.as_str(),
        }
    }
}

impl fmt::Display for MailtoField {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// Query component for a `mailto:` URI.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MailtoQuery {
    fields: Vec<(MailtoField, String)>,
}

impl MailtoQuery {
    /// Creates an empty query.
    #[must_use]
    pub const fn new() -> Self {
        Self { fields: Vec::new() }
    }

    /// Adds a query field and returns the updated query.
    pub fn with_field(
        mut self,
        field: MailtoField,
        value: impl AsRef<str>,
    ) -> Result<Self, MailtoError> {
        self.push(field, value)?;
        Ok(self)
    }

    /// Appends a query field.
    pub fn push(&mut self, field: MailtoField, value: impl AsRef<str>) -> Result<(), MailtoError> {
        let value = value.as_ref();
        if value.is_empty() {
            return Err(MailtoError::EmptyField);
        }
        self.fields.push((field, value.to_owned()));
        Ok(())
    }

    /// Returns query fields.
    #[must_use]
    pub fn fields(&self) -> &[(MailtoField, String)] {
        &self.fields
    }

    /// Returns true when the query has no fields.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.fields.is_empty()
    }
}

impl fmt::Display for MailtoQuery {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        for (index, (field, value)) in self.fields.iter().enumerate() {
            if index > 0 {
                formatter.write_str("&")?;
            }
            write!(
                formatter,
                "{}={}",
                encode_component(field.as_str()),
                encode_component(value)
            )?;
        }
        Ok(())
    }
}

/// Complete `mailto:` URI primitive.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MailtoUri {
    addresses: Vec<MailtoAddress>,
    query: MailtoQuery,
}

impl MailtoUri {
    /// Creates an empty mailto URI.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            addresses: Vec::new(),
            query: MailtoQuery::new(),
        }
    }

    /// Adds an address and returns the updated URI.
    #[must_use]
    pub fn with_address(mut self, address: MailtoAddress) -> Self {
        self.addresses.push(address);
        self
    }

    /// Sets the query.
    #[must_use]
    pub fn with_query(mut self, query: MailtoQuery) -> Self {
        self.query = query;
        self
    }

    /// Returns addresses.
    #[must_use]
    pub fn addresses(&self) -> &[MailtoAddress] {
        &self.addresses
    }

    /// Returns the query.
    #[must_use]
    pub const fn query(&self) -> &MailtoQuery {
        &self.query
    }
}

impl fmt::Display for MailtoUri {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("mailto:")?;
        for (index, address) in self.addresses.iter().enumerate() {
            if index > 0 {
                formatter.write_str(",")?;
            }
            write!(formatter, "{address}")?;
        }
        if !self.query.is_empty() {
            write!(formatter, "?{}", self.query)?;
        }
        Ok(())
    }
}

impl FromStr for MailtoUri {
    type Err = MailtoError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let trimmed = value.trim();
        let rest = trimmed
            .strip_prefix("mailto:")
            .ok_or(MailtoError::InvalidScheme)?;
        let (address_text, query_text) = rest.split_once('?').unwrap_or((rest, ""));
        let mut uri = Self::new();
        for address in address_text
            .split(',')
            .filter(|part| !part.trim().is_empty())
        {
            uri = uri.with_address(MailtoAddress::new(address)?);
        }
        if !query_text.is_empty() {
            let mut query = MailtoQuery::new();
            for pair in query_text.split('&') {
                let (field, value) = pair.split_once('=').ok_or(MailtoError::EmptyField)?;
                let field = match field.to_ascii_lowercase().as_str() {
                    "to" => MailtoField::To,
                    "cc" => MailtoField::Cc,
                    "bcc" => MailtoField::Bcc,
                    "subject" => MailtoField::Subject,
                    "body" => MailtoField::Body,
                    _ => MailtoField::other(field)?,
                };
                query.push(field, value)?;
            }
            uri = uri.with_query(query);
        }
        Ok(uri)
    }
}

/// Builder for common `mailto:` URIs.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MailtoBuilder {
    uri: MailtoUri,
}

impl MailtoBuilder {
    /// Creates an empty builder.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            uri: MailtoUri::new(),
        }
    }

    /// Adds a primary recipient.
    pub fn to(mut self, address: impl AsRef<str>) -> Result<Self, MailtoError> {
        self.uri.addresses.push(MailtoAddress::new(address)?);
        Ok(self)
    }

    /// Adds a `cc` field.
    #[must_use]
    pub fn cc(mut self, value: impl Into<String>) -> Self {
        self.uri.query.fields.push((MailtoField::Cc, value.into()));
        self
    }

    /// Adds a `bcc` field.
    #[must_use]
    pub fn bcc(mut self, value: impl Into<String>) -> Self {
        self.uri.query.fields.push((MailtoField::Bcc, value.into()));
        self
    }

    /// Adds a subject field.
    #[must_use]
    pub fn subject(mut self, value: impl Into<String>) -> Self {
        self.uri
            .query
            .fields
            .push((MailtoField::Subject, value.into()));
        self
    }

    /// Adds a body field.
    #[must_use]
    pub fn body(mut self, value: impl Into<String>) -> Self {
        self.uri
            .query
            .fields
            .push((MailtoField::Body, value.into()));
        self
    }

    /// Builds the URI.
    #[must_use]
    pub fn build(self) -> MailtoUri {
        self.uri
    }
}

fn encode_component(value: &str) -> String {
    const HEX: &[u8; 16] = b"0123456789ABCDEF";

    let mut encoded = String::new();
    for byte in value.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
            encoded.push(char::from(byte));
        } else {
            encoded.push('%');
            encoded.push(char::from(HEX[(byte >> 4) as usize]));
            encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
        }
    }
    encoded
}

#[cfg(test)]
mod tests {
    use super::{MailtoBuilder, MailtoError, MailtoField, MailtoQuery, MailtoUri};

    #[test]
    fn builds_mailto_uris() -> Result<(), MailtoError> {
        let uri = MailtoBuilder::new()
            .to("jane@example.com")?
            .subject("Hello there")
            .body("A short note.")
            .build();

        assert_eq!(
            uri.to_string(),
            "mailto:jane@example.com?subject=Hello%20there&body=A%20short%20note."
        );
        Ok(())
    }

    #[test]
    fn renders_query_fields() -> Result<(), MailtoError> {
        let query = MailtoQuery::new().with_field(MailtoField::Subject, "Hi there")?;

        assert_eq!(query.to_string(), "subject=Hi%20there");
        Ok(())
    }

    #[test]
    fn parses_simple_mailto_uri() -> Result<(), MailtoError> {
        let uri: MailtoUri = "mailto:jane@example.com?subject=Hello".parse()?;

        assert_eq!(uri.addresses().len(), 1);
        assert_eq!(uri.to_string(), "mailto:jane@example.com?subject=Hello");
        Ok(())
    }
}