use-email-message 0.1.0

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

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

use use_email_header::{HeaderField, HeaderParseError};

/// Error returned by message builders.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MessageBuildError {
    /// The message body was not supplied.
    MissingBody,
}

impl fmt::Display for MessageBuildError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingBody => formatter.write_str("email message body is required"),
        }
    }
}

impl Error for MessageBuildError {}

/// Message kind metadata.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MessageKind {
    /// Plain text body.
    #[default]
    PlainText,
    /// HTML body.
    Html,
    /// Multipart message metadata.
    Multipart,
    /// Raw message metadata.
    Raw,
}

impl MessageKind {
    /// Returns the default content type for the kind when one is known.
    #[must_use]
    pub const fn default_content_type(self) -> Option<&'static str> {
        match self {
            Self::PlainText => Some("text/plain; charset=utf-8"),
            Self::Html => Some("text/html; charset=utf-8"),
            Self::Multipart | Self::Raw => None,
        }
    }
}

/// Message body text.
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MessageBody(String);

impl MessageBody {
    /// Creates a message body.
    #[must_use]
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

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

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

/// Header collection for a message.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MessageHeaders {
    fields: Vec<HeaderField>,
}

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

    /// Adds a header field and returns the updated collection.
    #[must_use]
    pub fn with_field(mut self, field: HeaderField) -> Self {
        self.fields.push(field);
        self
    }

    /// Appends a field.
    pub fn push(&mut self, field: HeaderField) {
        self.fields.push(field);
    }

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

    /// Finds the first header value by case-insensitive name.
    #[must_use]
    pub fn first_value(&self, name: &str) -> Option<&str> {
        self.fields
            .iter()
            .find(|field| field.name().as_str().eq_ignore_ascii_case(name))
            .map(|field| field.value().as_str())
    }
}

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

/// Structured email message primitive.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EmailMessage {
    headers: MessageHeaders,
    body: MessageBody,
    kind: MessageKind,
}

impl EmailMessage {
    /// Creates a plain text message with subject and body.
    #[must_use]
    pub fn plain_text(subject: impl Into<String>, body: impl Into<String>) -> Self {
        Self::with_kind_subject_body(MessageKind::PlainText, subject, body)
    }

    /// Creates an HTML message with subject and body.
    #[must_use]
    pub fn html(subject: impl Into<String>, body: impl Into<String>) -> Self {
        Self::with_kind_subject_body(MessageKind::Html, subject, body)
    }

    /// Creates a message from parts.
    #[must_use]
    pub const fn new(headers: MessageHeaders, body: MessageBody, kind: MessageKind) -> Self {
        Self {
            headers,
            body,
            kind,
        }
    }

    /// Adds a header and returns the updated message.
    pub fn with_header(
        mut self,
        name: impl AsRef<str>,
        value: impl AsRef<str>,
    ) -> Result<Self, HeaderParseError> {
        self.headers.push(HeaderField::new(name, value)?);
        Ok(self)
    }

    /// Returns message headers.
    #[must_use]
    pub const fn headers(&self) -> &MessageHeaders {
        &self.headers
    }

    /// Returns the message body.
    #[must_use]
    pub const fn body(&self) -> &MessageBody {
        &self.body
    }

    /// Returns the message kind.
    #[must_use]
    pub const fn kind(&self) -> MessageKind {
        self.kind
    }

    /// Returns the Subject header, when present.
    #[must_use]
    pub fn subject(&self) -> Option<&str> {
        self.headers.first_value("Subject")
    }

    /// Parses the Content-Type header using the shared `use-mime` primitive.
    #[must_use]
    pub fn body_mime(&self) -> Option<use_mime::MimeType> {
        self.headers
            .first_value("Content-Type")
            .and_then(use_mime::parse_mime)
    }

    fn with_kind_subject_body(
        kind: MessageKind,
        subject: impl Into<String>,
        body: impl Into<String>,
    ) -> Self {
        let mut headers = MessageHeaders::new().with_field(
            HeaderField::new("Subject", subject.into()).expect("Subject header is valid"),
        );
        if let Some(content_type) = kind.default_content_type() {
            headers.push(content_type_field(content_type));
        }
        Self {
            headers,
            body: MessageBody::new(body),
            kind,
        }
    }
}

/// Raw message text container.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RawMessage(String);

impl RawMessage {
    /// Creates raw message text.
    #[must_use]
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

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

/// Parsed message wrapper.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParsedMessage(EmailMessage);

impl ParsedMessage {
    /// Creates parsed message metadata from a structured message.
    #[must_use]
    pub const fn new(message: EmailMessage) -> Self {
        Self(message)
    }

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

/// Builder for simple structured messages.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MessageBuilder {
    kind: MessageKind,
    headers: MessageHeaders,
    body: Option<MessageBody>,
}

impl MessageBuilder {
    /// Creates a builder for the requested message kind.
    #[must_use]
    pub const fn new(kind: MessageKind) -> Self {
        Self {
            kind,
            headers: MessageHeaders::new(),
            body: None,
        }
    }

    /// Adds a Subject header.
    pub fn subject(self, value: impl AsRef<str>) -> Result<Self, HeaderParseError> {
        self.header("Subject", value)
    }

    /// Adds a header field.
    pub fn header(
        mut self,
        name: impl AsRef<str>,
        value: impl AsRef<str>,
    ) -> Result<Self, HeaderParseError> {
        self.headers.push(HeaderField::new(name, value)?);
        Ok(self)
    }

    /// Sets the body.
    #[must_use]
    pub fn body(mut self, value: impl Into<String>) -> Self {
        self.body = Some(MessageBody::new(value));
        self
    }

    /// Builds the message.
    pub fn build(mut self) -> Result<EmailMessage, MessageBuildError> {
        if self.headers.first_value("Content-Type").is_none()
            && let Some(content_type) = self.kind.default_content_type()
        {
            self.headers.push(content_type_field(content_type));
        }
        Ok(EmailMessage::new(
            self.headers,
            self.body.ok_or(MessageBuildError::MissingBody)?,
            self.kind,
        ))
    }
}

fn content_type_field(content_type: &str) -> HeaderField {
    HeaderField::new("Content-Type", content_type).expect("Content-Type header is valid")
}

#[cfg(test)]
mod tests {
    use super::{EmailMessage, MessageBuildError, MessageBuilder, MessageKind};

    #[test]
    fn creates_plain_text_and_html_messages() {
        let plain = EmailMessage::plain_text("Hello", "A short note.");
        let html = EmailMessage::html("Hello", "<p>A short note.</p>");

        assert_eq!(plain.subject(), Some("Hello"));
        assert_eq!(plain.kind(), MessageKind::PlainText);
        assert_eq!(plain.body_mime().expect("mime").subtype, "plain");
        assert_eq!(html.body_mime().expect("mime").subtype, "html");
    }

    #[test]
    fn builds_messages_with_headers() -> Result<(), Box<dyn std::error::Error>> {
        let message = MessageBuilder::new(MessageKind::Html)
            .subject("Hello")?
            .header("From", "jane@example.com")?
            .body("<p>Hello</p>")
            .build()?;

        assert_eq!(message.subject(), Some("Hello"));
        assert_eq!(
            message.headers().first_value("from"),
            Some("jane@example.com")
        );
        Ok(())
    }

    #[test]
    fn builder_requires_body() {
        assert_eq!(
            MessageBuilder::new(MessageKind::PlainText).build(),
            Err(MessageBuildError::MissingBody)
        );
    }
}