use-email-id 0.1.0

Email Message-ID and threading primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

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

/// Error returned when message identity primitives fail validation.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MessageIdError {
    /// The supplied value was empty after trimming.
    Empty,
    /// The identifier did not contain an at sign.
    MissingAt,
    /// The identifier contained too many at signs.
    TooManyAtSigns,
    /// The local part was invalid.
    InvalidLocal,
    /// The domain part was invalid.
    InvalidDomain,
}

impl fmt::Display for MessageIdError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("message id value cannot be empty"),
            Self::MissingAt => formatter.write_str("message id must contain an at sign"),
            Self::TooManyAtSigns => formatter.write_str("message id must contain only one at sign"),
            Self::InvalidLocal => formatter.write_str("invalid message id local part"),
            Self::InvalidDomain => formatter.write_str("invalid message id domain part"),
        }
    }
}

impl Error for MessageIdError {}

/// Message-ID local part.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MessageIdLocal(String);

impl MessageIdLocal {
    /// Creates a message-id local part.
    pub fn new(value: impl AsRef<str>) -> Result<Self, MessageIdError> {
        validate_local(value.as_ref()).map(|value| Self(value.to_owned()))
    }

    /// Returns the local-part text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

impl FromStr for MessageIdLocal {
    type Err = MessageIdError;

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

/// Message-ID domain part.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MessageIdDomain(String);

impl MessageIdDomain {
    /// Creates a message-id domain part.
    pub fn new(value: impl AsRef<str>) -> Result<Self, MessageIdError> {
        validate_domain(value.as_ref()).map(|value| Self(value.to_owned()))
    }

    /// Returns the domain-part text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

impl FromStr for MessageIdDomain {
    type Err = MessageIdError;

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

/// Message-ID value rendered with angle brackets.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MessageId {
    local: MessageIdLocal,
    domain: MessageIdDomain,
}

impl MessageId {
    /// Creates a message id from separated local and domain text.
    pub fn new(local: impl AsRef<str>, domain: impl AsRef<str>) -> Result<Self, MessageIdError> {
        Ok(Self {
            local: MessageIdLocal::new(local)?,
            domain: MessageIdDomain::new(domain)?,
        })
    }

    /// Returns the local part.
    #[must_use]
    pub const fn local(&self) -> &MessageIdLocal {
        &self.local
    }

    /// Returns the domain part.
    #[must_use]
    pub const fn domain(&self) -> &MessageIdDomain {
        &self.domain
    }

    /// Returns the inner `local@domain` text.
    #[must_use]
    pub fn inner(&self) -> String {
        format!("{}@{}", self.local, self.domain)
    }
}

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

impl FromStr for MessageId {
    type Err = MessageIdError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let trimmed = value.trim().trim_start_matches('<').trim_end_matches('>');
        if trimmed.is_empty() {
            return Err(MessageIdError::Empty);
        }
        let mut parts = trimmed.split('@');
        let local = parts.next().ok_or(MessageIdError::MissingAt)?;
        let domain = parts.next().ok_or(MessageIdError::MissingAt)?;
        if parts.next().is_some() {
            return Err(MessageIdError::TooManyAtSigns);
        }
        Self::new(local, domain)
    }
}

impl TryFrom<&str> for MessageId {
    type Error = MessageIdError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        value.parse()
    }
}

/// Ordered References header values.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct References {
    message_ids: Vec<MessageId>,
}

impl References {
    /// Creates an empty references collection.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            message_ids: Vec::new(),
        }
    }

    /// Adds a message id and returns the updated collection.
    #[must_use]
    pub fn with_message_id(mut self, message_id: MessageId) -> Self {
        self.message_ids.push(message_id);
        self
    }

    /// Appends a message id.
    pub fn push(&mut self, message_id: MessageId) {
        self.message_ids.push(message_id);
    }

    /// Returns all message ids.
    #[must_use]
    pub fn as_slice(&self) -> &[MessageId] {
        &self.message_ids
    }

    /// Returns the number of message ids.
    #[must_use]
    pub fn len(&self) -> usize {
        self.message_ids.len()
    }

    /// Returns true when no references are stored.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.message_ids.is_empty()
    }
}

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

/// In-Reply-To message id wrapper.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct InReplyTo(MessageId);

impl InReplyTo {
    /// Creates an In-Reply-To value.
    #[must_use]
    pub const fn new(message_id: MessageId) -> Self {
        Self(message_id)
    }

    /// Returns the referenced message id.
    #[must_use]
    pub const fn message_id(&self) -> &MessageId {
        &self.0
    }
}

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

/// Simple thread reference path rooted at one message id.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ThreadReference {
    root: MessageId,
    replies: Vec<MessageId>,
}

impl ThreadReference {
    /// Creates a thread reference from the root message id.
    #[must_use]
    pub const fn new(root: MessageId) -> Self {
        Self {
            root,
            replies: Vec::new(),
        }
    }

    /// Adds a reply id and returns the updated thread reference.
    #[must_use]
    pub fn with_reply(mut self, reply: MessageId) -> Self {
        self.replies.push(reply);
        self
    }

    /// Returns the root id.
    #[must_use]
    pub const fn root(&self) -> &MessageId {
        &self.root
    }

    /// Returns reply ids.
    #[must_use]
    pub fn replies(&self) -> &[MessageId] {
        &self.replies
    }

    /// Converts the thread reference into a References value.
    #[must_use]
    pub fn references(&self) -> References {
        let mut references = References::new().with_message_id(self.root.clone());
        for reply in &self.replies {
            references.push(reply.clone());
        }
        references
    }
}

fn validate_local(value: &str) -> Result<&str, MessageIdError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(MessageIdError::InvalidLocal);
    }
    if trimmed.chars().any(|character| {
        character.is_control() || character.is_whitespace() || matches!(character, '<' | '>' | '@')
    }) {
        return Err(MessageIdError::InvalidLocal);
    }
    Ok(trimmed)
}

fn validate_domain(value: &str) -> Result<&str, MessageIdError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(MessageIdError::InvalidDomain);
    }
    if trimmed.starts_with('.')
        || trimmed.ends_with('.')
        || trimmed.contains("..")
        || trimmed.chars().any(|character| {
            character.is_control()
                || character.is_whitespace()
                || matches!(character, '<' | '>' | '@' | '_')
        })
    {
        return Err(MessageIdError::InvalidDomain);
    }
    Ok(trimmed)
}

#[cfg(test)]
mod tests {
    use super::{InReplyTo, MessageId, MessageIdError, References, ThreadReference};

    #[test]
    fn parses_and_formats_message_ids() -> Result<(), MessageIdError> {
        let message_id: MessageId = "root@example.com".parse()?;

        assert_eq!(message_id.inner(), "root@example.com");
        assert_eq!(message_id.to_string(), "<root@example.com>");
        Ok(())
    }

    #[test]
    fn builds_references_and_threads() -> Result<(), MessageIdError> {
        let root: MessageId = "<root@example.com>".parse()?;
        let reply: MessageId = "reply@example.com".parse()?;
        let references = References::new()
            .with_message_id(root.clone())
            .with_message_id(reply.clone());
        let thread = ThreadReference::new(root.clone()).with_reply(reply);
        let in_reply_to = InReplyTo::new(root);

        assert_eq!(
            references.to_string(),
            "<root@example.com> <reply@example.com>"
        );
        assert_eq!(thread.references(), references);
        assert_eq!(in_reply_to.to_string(), "<root@example.com>");
        Ok(())
    }

    #[test]
    fn rejects_invalid_message_ids() {
        assert_eq!(
            "missing-domain@".parse::<MessageId>(),
            Err(MessageIdError::InvalidDomain)
        );
        assert_eq!(
            "missing-at".parse::<MessageId>(),
            Err(MessageIdError::MissingAt)
        );
        assert_eq!(
            "a@b@c".parse::<MessageId>(),
            Err(MessageIdError::TooManyAtSigns)
        );
    }
}