roadster 0.8.1

A "Batteries Included" web framework for rust designed to get you moving fast.
Documentation
use crate::config::email::Email;
use lettre::SmtpTransport;
use lettre::message::MessageBuilder;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::{PoolConfig, SmtpTransportBuilder};
use serde_derive::{Deserialize, Serialize};
use serde_with::serde_as;
use std::time::Duration;
use url::Url;
use validator::{Validate, ValidationErrors};

#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct Smtp {
    #[validate(nested)]
    pub connection: SmtpConnection,

    #[serde(default)]
    #[validate(nested)]
    pub pool: Option<SmtpPool>,
}

#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged, rename_all = "kebab-case")]
#[non_exhaustive]
pub enum SmtpConnection {
    Fields(SmtpConnectionFields),
    Uri(SmtpConnectionUri),
}

impl Validate for SmtpConnection {
    fn validate(&self) -> Result<(), ValidationErrors> {
        match self {
            SmtpConnection::Fields(fields) => fields.validate(),
            SmtpConnection::Uri(uri) => uri.validate(),
        }
    }
}

#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct SmtpConnectionFields {
    pub host: String,
    pub port: Option<u16>,
    pub username: String,
    pub password: String,
}

#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct SmtpConnectionUri {
    pub uri: Url,
}

#[serde_as]
#[serde_with::skip_serializing_none]
#[derive(Debug, Default, Clone, Validate, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct SmtpPool {
    #[serde(default)]
    pub min_connections: Option<u32>,

    #[serde(default)]
    pub max_connections: Option<u32>,

    #[serde(default)]
    #[serde_as(as = "Option<serde_with::DurationMilliSeconds>")]
    pub idle_timeout: Option<Duration>,
}

impl From<&Email> for MessageBuilder {
    fn from(value: &Email) -> Self {
        let builder = MessageBuilder::new().from(value.from.clone());
        if let Some(reply_to) = value.reply_to.as_ref() {
            builder.reply_to(reply_to.clone())
        } else {
            builder
        }
    }
}

impl TryFrom<&Smtp> for SmtpTransportBuilder {
    type Error = lettre::transport::smtp::Error;

    fn try_from(value: &Smtp) -> Result<Self, Self::Error> {
        let builder = match &value.connection {
            SmtpConnection::Fields(fields) => {
                let credentials =
                    Credentials::new(fields.username.clone(), fields.password.clone());
                SmtpTransport::relay(&fields.host)
                    .map(|builder| {
                        if let Some(port) = fields.port {
                            builder.port(port)
                        } else {
                            builder
                        }
                    })
                    .map(|builder| builder.credentials(credentials))
            }
            SmtpConnection::Uri(fields) => SmtpTransport::from_url(fields.uri.as_ref()),
        }?;

        let builder = if let Some(smtp_pool) = value.pool.as_ref() {
            builder.pool_config(smtp_pool.into())
        } else {
            builder
        };

        Ok(builder)
    }
}

impl From<&SmtpPool> for PoolConfig {
    fn from(value: &SmtpPool) -> Self {
        let pool_config = PoolConfig::new();

        let pool_config = if let Some(min_connections) = value.min_connections {
            pool_config.min_idle(min_connections)
        } else {
            pool_config
        };

        let pool_config = if let Some(max_connections) = value.max_connections {
            pool_config.max_size(max_connections)
        } else {
            pool_config
        };

        if let Some(idle_timeout) = value.idle_timeout {
            pool_config.idle_timeout(idle_timeout)
        } else {
            pool_config
        }
    }
}

impl TryFrom<&Smtp> for SmtpTransport {
    type Error = lettre::transport::smtp::Error;

    fn try_from(value: &Smtp) -> Result<Self, Self::Error> {
        let builder: SmtpTransportBuilder = value.try_into()?;
        Ok(builder.build())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testing::snapshot::TestCase;
    use insta::{assert_debug_snapshot, assert_toml_snapshot};
    use rstest::{fixture, rstest};

    #[fixture]
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn case() -> TestCase {
        Default::default()
    }

    #[rstest]
    #[case(
        r#"
        [connection]
        uri = "smtps://username:password@smtp.example.com:425"
        "#
    )]
    #[case(
        r#"
        [connection]
        host = "smtp.example.com"
        username = "username"
        password = "password"
        "#
    )]
    #[case(
        r#"
        [connection]
        host = "smtp.example.com"
        port = 465
        username = "username"
        password = "password"
        "#
    )]
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn serialization(_case: TestCase, #[case] config: &str) {
        let smtp: Smtp = toml::from_str(config).unwrap();
        smtp.validate().unwrap();

        assert_toml_snapshot!(smtp);
    }

    #[rstest]
    #[case(
        r#"
        from = "foo@example.com"
        [smtp.connection]
        uri = "smtps://username:password@smtp.example.com:425"
        [sendgrid]
        api-key = "api-key"
        "#
    )]
    #[case(
        r#"
        from = "foo@example.com"
        reply-to = "no-reply@example.com"
        [smtp.connection]
        uri = "smtps://username:password@smtp.example.com:425"
        [sendgrid]
        api-key = "api-key"
        "#
    )]
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn message_builder_from_email(_case: TestCase, #[case] email_config: &str) {
        let email: Email = toml::from_str(email_config).unwrap();
        let msg_builder: MessageBuilder = (&email).into();
        assert_debug_snapshot!(msg_builder);
    }

    #[rstest]
    #[case(
        r#"
        [connection]
        uri = "smtps://username:password@smtp.example.com:425"
        "#
    )]
    #[case(
        r#"
        [connection]
        host = "smtp.example.com"
        port = 465
        username = "username"
        password = "password"
        "#
    )]
    #[case(
        r#"
        [connection]
        host = "smtp.example.com"
        username = "username"
        password = "password"
        "#
    )]
    #[case(
        r#"
        [connection]
        uri = "smtps://username:password@smtp.example.com:425"
        [pool]
        min-connections = 1
        "#
    )]
    #[case(
        r#"
        [connection]
        uri = "smtps://username:password@smtp.example.com:425"
        [pool]
        max-connections = 100
        "#
    )]
    #[case(
        r#"
        [connection]
        uri = "smtps://username:password@smtp.example.com:425"
        [pool]
        idle-timeout = 60000
        "#
    )]
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn smtp_connection_from_config(_case: TestCase, #[case] smtp_config: &str) {
        let smtp: Smtp = toml::from_str(smtp_config).unwrap();
        let _smtp_transport: SmtpTransport = (&smtp).try_into().unwrap();
    }

    #[rstest]
    #[case(
        r#"
        [connection]
        uri = "https://username:password@smtp.example.com:425"
        "#
    )]
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn smtp_connection_from_config_err(_case: TestCase, #[case] smtp_config: &str) {
        let smtp: Smtp = toml::from_str(smtp_config).unwrap();
        assert!(SmtpTransport::try_from(&smtp).is_err());
    }
}