oxidite-mail 2.2.0

Email sending for Oxidite with SMTP support
Documentation
use crate::{Message, Result, MailError};
use async_trait::async_trait;
use lettre::{
    AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
    message::{header::ContentType, Attachment as LettreAttachment, Mailbox, MultiPart, SinglePart},
};
use lettre::transport::smtp::authentication::Credentials;

/// Transport trait for sending emails
#[async_trait]
pub trait Transport: Send + Sync {
    async fn send(&self, message: Message) -> Result<()>;
    async fn verify(&self) -> Result<()>;
}

/// SMTP transport configuration
#[derive(Debug, Clone)]
pub struct SmtpConfig {
    pub host: String,
    pub port: u16,
    pub username: Option<String>,
    pub password: Option<String>,
    pub use_tls: bool,
}

impl SmtpConfig  {
    pub fn new(host: impl Into<String>, port: u16) -> Self {
        Self {
            host: host.into(),
            port,
            username: None,
            password: None,
            use_tls: true,
        }
    }

    pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
        self.username = Some(username.into());
        self.password = Some(password.into());
        self
    }

    pub fn use_tls(mut self, use_tls: bool) -> Self {
        self.use_tls = use_tls;
        self
    }
}

/// SMTP transport
pub struct SmtpTransport {
    config: SmtpConfig,
    transport: AsyncSmtpTransport<Tokio1Executor>,
}

impl SmtpTransport {
    pub fn new(host: impl Into<String>, port: u16) -> Result<Self> {
        let config = SmtpConfig::new(host, port);
        let transport = Self::build_transport(&config)?;
        
        Ok(Self { config, transport })
    }

    pub fn from_config(config: SmtpConfig) -> Result<Self> {
        let transport = Self::build_transport(&config)?;
        Ok(Self { config, transport })
    }

    /// Access the active SMTP transport configuration.
    pub fn config(&self) -> &SmtpConfig {
        &self.config
    }

    fn build_transport(config: &SmtpConfig) -> Result<AsyncSmtpTransport<Tokio1Executor>> {
        let mut builder = if config.use_tls {
            AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
                .map_err(|e| MailError::Smtp(e.to_string()))?
        } else {
            AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host)
        };

        builder = builder.port(config.port);

        if let (Some(username), Some(password)) = (&config.username, &config.password) {
            builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
        }

        Ok(builder.build())
    }

    fn build_email(&self, message: Message) -> Result<lettre::Message> {
        message.validate()?;

        let from: Mailbox = message.from.as_ref().unwrap().parse()?;
        let to: Vec<Mailbox> = message.to.iter()
            .map(|addr| addr.parse())
            .collect::<std::result::Result<Vec<_>, _>>()?;

        let mut email_builder = lettre::Message::builder()
            .from(from)
            .subject(message.subject.as_ref().unwrap());

        for recipient in to {
            email_builder = email_builder.to(recipient);
        }

        for cc in &message.cc {
            email_builder = email_builder.cc(cc.parse()?);
        }

        for bcc in &message.bcc {
            email_builder = email_builder.bcc(bcc.parse()?);
        }

        if let Some(reply_to) = &message.reply_to {
            email_builder = email_builder.reply_to(reply_to.parse()?);
        }

        // Build body
        let mut body = if let (Some(text), Some(html)) = (&message.text, &message.html) {
            MultiPart::alternative_plain_html(text.clone(), html.clone())
        } else if let Some(html) = &message.html {
            MultiPart::alternative()
                .singlepart(SinglePart::html(html.clone()))
        } else if let Some(text) = &message.text {
            MultiPart::alternative()
                .singlepart(SinglePart::plain(text.clone()))
        } else {
            return Err(MailError::MissingField("text or html".to_string()));
        };

        // Add attachments
        if !message.attachments.is_empty() {
            let mut multipart = MultiPart::mixed().multipart(body);

            for attachment in &message.attachments {
                let content_type = if let Some(ct) = &attachment.content_type {
                    ContentType::parse(ct)
                        .map_err(|_| MailError::Attachment(format!("Invalid content type: {ct}")))?
                } else {
                    ContentType::TEXT_PLAIN
                };

                let part = if attachment.inline {
                    if let Some(cid) = &attachment.content_id {
                        LettreAttachment::new_inline_with_name(cid.clone(), attachment.filename.clone())
                            .body(attachment.content.clone(), content_type)
                    } else {
                        LettreAttachment::new_inline_with_name(
                            attachment.filename.clone(),
                            attachment.filename.clone(),
                        )
                        .body(attachment.content.clone(), content_type)
                    }
                } else {
                    LettreAttachment::new(attachment.filename.clone())
                        .body(attachment.content.clone(), content_type)
                };

                multipart = multipart.singlepart(part);
            }

            body = multipart;
        }

        let email = email_builder.multipart(body)?;
        Ok(email)
    }

    /// Build a lettre message without sending it (useful for preflight tests).
    pub fn try_build(&self, message: Message) -> Result<lettre::Message> {
        self.build_email(message)
    }
}

#[async_trait]
impl Transport for SmtpTransport {
    async fn send(&self, message: Message) -> Result<()> {
        let email = self.build_email(message)?;
        self.transport.send(email).await?;
        Ok(())
    }

    async fn verify(&self) -> Result<()> {
        self.transport.test_connection().await
            .map_err(|e| MailError::Smtp(e.to_string()))?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::SmtpTransport;
    use crate::{Attachment, Message};

    #[tokio::test]
    async fn build_email_rejects_invalid_attachment_content_type() {
        let transport = SmtpTransport::new("localhost", 1025).expect("transport init");
        let message = Message::new()
            .from("sender@example.com")
            .to("recipient@example.com")
            .subject("subject")
            .text("body")
            .attach(
                Attachment::new("file.bin")
                    .content(vec![1, 2, 3])
                    .content_type("not/a valid mime"),
            );

        let result = transport.build_email(message);
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn try_build_valid_message() {
        let transport = SmtpTransport::new("localhost", 1025).expect("transport init");
        let message = Message::new()
            .from("sender@example.com")
            .to("recipient@example.com")
            .subject("subject")
            .text("body");
        assert!(transport.try_build(message).is_ok());
    }
}