use-smtp 0.1.0

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

use core::fmt;
use std::error::Error;

use use_email_address::AddressValidationError;
use use_email_envelope::{MailFromPath, RcptToPath};

/// Error returned by SMTP vocabulary constructors.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SmtpError {
    /// Address validation failed.
    Address(AddressValidationError),
    /// Reply code was outside the SMTP reply-code range.
    InvalidReplyCode,
    /// The supplied text value was empty.
    Empty,
    /// The supplied enhanced status code was invalid.
    InvalidEnhancedStatus,
}

impl fmt::Display for SmtpError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Address(error) => write!(formatter, "{error}"),
            Self::InvalidReplyCode => {
                formatter.write_str("SMTP reply code must be between 100 and 599")
            }
            Self::Empty => formatter.write_str("SMTP value cannot be empty"),
            Self::InvalidEnhancedStatus => formatter.write_str("invalid SMTP enhanced status code"),
        }
    }
}

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

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

/// SMTP command vocabulary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SmtpCommand {
    /// `HELO` command.
    Helo(String),
    /// `EHLO` command.
    Ehlo(String),
    /// `MAIL FROM` command.
    MailFrom(MailFrom),
    /// `RCPT TO` command.
    RcptTo(RcptTo),
    /// `DATA` command.
    Data(DataCommand),
    /// `QUIT` command.
    Quit(QuitCommand),
    /// `STARTTLS` command label.
    StartTls,
}

impl fmt::Display for SmtpCommand {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Helo(domain) => write!(formatter, "HELO {domain}"),
            Self::Ehlo(domain) => write!(formatter, "EHLO {domain}"),
            Self::MailFrom(command) => write!(formatter, "{command}"),
            Self::RcptTo(command) => write!(formatter, "{command}"),
            Self::Data(command) => write!(formatter, "{command}"),
            Self::Quit(command) => write!(formatter, "{command}"),
            Self::StartTls => formatter.write_str("STARTTLS"),
        }
    }
}

/// SMTP reply code.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SmtpReplyCode(u16);

impl SmtpReplyCode {
    /// Creates a reply code in the inclusive range 100..=599.
    pub const fn new(value: u16) -> Result<Self, SmtpError> {
        if value >= 100 && value <= 599 {
            Ok(Self(value))
        } else {
            Err(SmtpError::InvalidReplyCode)
        }
    }

    /// Returns the numeric code.
    #[must_use]
    pub const fn value(self) -> u16 {
        self.0
    }
}

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

/// SMTP enhanced status code.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SmtpEnhancedStatusCode {
    class: u8,
    subject: u16,
    detail: u16,
}

impl SmtpEnhancedStatusCode {
    /// Creates an enhanced status code such as `2.1.0`.
    pub const fn new(class: u8, subject: u16, detail: u16) -> Result<Self, SmtpError> {
        if matches!(class, 2 | 4 | 5) {
            Ok(Self {
                class,
                subject,
                detail,
            })
        } else {
            Err(SmtpError::InvalidEnhancedStatus)
        }
    }
}

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

/// SMTP reply line metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SmtpReply {
    code: SmtpReplyCode,
    enhanced_status: Option<SmtpEnhancedStatusCode>,
    text: String,
}

impl SmtpReply {
    /// Creates a reply without enhanced status metadata.
    pub fn new(code: SmtpReplyCode, text: impl AsRef<str>) -> Result<Self, SmtpError> {
        Self::with_enhanced_status(code, None, text)
    }

    /// Creates a reply with optional enhanced status metadata.
    pub fn with_enhanced_status(
        code: SmtpReplyCode,
        enhanced_status: Option<SmtpEnhancedStatusCode>,
        text: impl AsRef<str>,
    ) -> Result<Self, SmtpError> {
        let text = validate_text(text.as_ref())?;
        Ok(Self {
            code,
            enhanced_status,
            text: text.to_owned(),
        })
    }

    /// Returns the reply code.
    #[must_use]
    pub const fn code(&self) -> SmtpReplyCode {
        self.code
    }
}

impl fmt::Display for SmtpReply {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(status) = self.enhanced_status {
            write!(formatter, "{} {} {}", self.code, status, self.text)
        } else {
            write!(formatter, "{} {}", self.code, self.text)
        }
    }
}

/// SMTP extension labels.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SmtpExtension {
    /// STARTTLS extension.
    StartTls,
    /// 8BITMIME extension.
    EightBitMime,
    /// SMTPUTF8 extension.
    SmtpUtf8,
    /// SIZE extension with optional advertised limit.
    Size(Option<u64>),
    /// AUTH extension with mechanism list metadata.
    Auth(String),
    /// Other EHLO extension label.
    Other(String),
}

impl fmt::Display for SmtpExtension {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::StartTls => formatter.write_str("STARTTLS"),
            Self::EightBitMime => formatter.write_str("8BITMIME"),
            Self::SmtpUtf8 => formatter.write_str("SMTPUTF8"),
            Self::Size(Some(limit)) => write!(formatter, "SIZE {limit}"),
            Self::Size(None) => formatter.write_str("SIZE"),
            Self::Auth(mechanisms) => write!(formatter, "AUTH {mechanisms}"),
            Self::Other(value) => formatter.write_str(value),
        }
    }
}

/// EHLO keyword metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct EhloKeyword(String);

impl EhloKeyword {
    /// Creates an EHLO keyword.
    pub fn new(value: impl AsRef<str>) -> Result<Self, SmtpError> {
        validate_text(value.as_ref()).map(|value| Self(value.to_ascii_uppercase()))
    }

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

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

/// MAIL FROM command.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MailFrom(MailFromPath);

impl MailFrom {
    /// Creates a MAIL FROM command from address text.
    pub fn new(value: impl AsRef<str>) -> Result<Self, SmtpError> {
        Ok(Self(MailFromPath::new(value)?))
    }

    /// Creates a null MAIL FROM command.
    #[must_use]
    pub const fn null() -> Self {
        Self(MailFromPath::null())
    }

    /// Returns the path.
    #[must_use]
    pub const fn path(&self) -> &MailFromPath {
        &self.0
    }
}

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

/// RCPT TO command.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RcptTo(RcptToPath);

impl RcptTo {
    /// Creates a RCPT TO command from address text.
    pub fn new(value: impl AsRef<str>) -> Result<Self, SmtpError> {
        Ok(Self(RcptToPath::new(value)?))
    }

    /// Returns the path.
    #[must_use]
    pub const fn path(&self) -> &RcptToPath {
        &self.0
    }
}

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

/// DATA command marker.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DataCommand;

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

/// QUIT command marker.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct QuitCommand;

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

/// STARTTLS capability marker.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct StartTlsCapability;

/// 8BITMIME capability marker.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct EightBitMimeCapability;

/// SMTPUTF8 capability marker.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SmtpUtf8Capability;

fn validate_text(value: &str) -> Result<&str, SmtpError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(SmtpError::Empty);
    }
    if trimmed
        .chars()
        .any(|character| matches!(character, '\r' | '\n'))
    {
        return Err(SmtpError::Empty);
    }
    Ok(trimmed)
}

#[cfg(test)]
mod tests {
    use super::{MailFrom, RcptTo, SmtpCommand, SmtpEnhancedStatusCode, SmtpReply, SmtpReplyCode};

    #[test]
    fn renders_commands_and_replies() -> Result<(), Box<dyn std::error::Error>> {
        let mail_from = SmtpCommand::MailFrom(MailFrom::new("bounce@example.com")?);
        let rcpt_to = SmtpCommand::RcptTo(RcptTo::new("jane@example.com")?);
        let reply = SmtpReply::new(SmtpReplyCode::new(250)?, "OK")?;
        let enhanced = SmtpReply::with_enhanced_status(
            SmtpReplyCode::new(250)?,
            Some(SmtpEnhancedStatusCode::new(2, 0, 0)?),
            "OK",
        )?;

        assert_eq!(mail_from.to_string(), "MAIL FROM:<bounce@example.com>");
        assert_eq!(rcpt_to.to_string(), "RCPT TO:<jane@example.com>");
        assert_eq!(reply.to_string(), "250 OK");
        assert_eq!(enhanced.to_string(), "250 2.0.0 OK");
        Ok(())
    }
}