rust_smtp_utils 0.1.0

Reusable SMTP helper crate for Rust (lettre-based) with STARTTLS, timeout, and retry.
Documentation
use lettre::message::{Mailbox, Message as LettreMessage};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{SmtpTransport, Transport};
use std::thread::sleep;
use std::time::Duration;

#[derive(Clone, Debug)]
pub struct SmtpConfig {
    pub host: String,
    pub port: u16,
    pub username: String,
    pub password: String,
    pub timeout_secs: u64,
    pub retries: u8,
    pub retry_delay_ms: u64,
}

impl SmtpConfig {
    pub fn from_env() -> Result<Self, std::env::VarError> {
        let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.gmail.com".into());
        let port = std::env::var("SMTP_PORT")
            .ok()
            .and_then(|v| v.parse().ok())
            .unwrap_or(587);
        let username = std::env::var("SMTP_USERNAME")?;
        let password = std::env::var("SMTP_PASSWORD")?;

        Ok(Self {
            host,
            port,
            username,
            password,
            timeout_secs: 10,
            retries: 2,
            retry_delay_ms: 500,
        })
    }
}

pub fn send_text_email(
    config: &SmtpConfig,
    from: Mailbox,
    to: Mailbox,
    subject: String,
    body: String,
    reply_to: Option<Mailbox>,
) -> Result<(), lettre::transport::smtp::Error> {
    let mut builder = LettreMessage::builder().from(from).to(to).subject(subject);
    if let Some(rt) = reply_to {
        builder = builder.reply_to(rt);
    }
    let email = builder.body(body).unwrap();

    let creds = Credentials::new(config.username.clone(), config.password.clone());
    let mailer = SmtpTransport::starttls_relay(&config.host)
        .unwrap()
        .port(config.port)
        .credentials(creds)
        .timeout(Some(Duration::from_secs(config.timeout_secs)))
        .build();

    let mut last_err: Option<lettre::transport::smtp::Error> = None;
    for attempt in 1..=config.retries {
        match mailer.send(&email) {
            Ok(_) => return Ok(()),
            Err(e) => {
                last_err = Some(e);
                if attempt < config.retries {
                    sleep(Duration::from_millis(config.retry_delay_ms));
                }
            }
        }
    }

    Err(last_err.expect("SMTP send failed without error"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Mutex, OnceLock};

    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

    fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
        let _guard = ENV_LOCK
            .get_or_init(|| Mutex::new(()))
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let mut saved: Vec<(String, Option<String>)> = Vec::with_capacity(vars.len());

        for (key, val) in vars {
            saved.push(((*key).to_string(), std::env::var(key).ok()));
            match val {
                Some(v) => std::env::set_var(key, v),
                None => std::env::remove_var(key),
            }
        }

        f();

        for (key, val) in saved {
            match val {
                Some(v) => std::env::set_var(&key, v),
                None => std::env::remove_var(&key),
            }
        }
    }

    #[test]
    fn from_env_missing_required_vars_returns_err() {
        with_env(
            &[
                ("SMTP_USERNAME", None),
                ("SMTP_PASSWORD", None),
                ("SMTP_HOST", None),
                ("SMTP_PORT", None),
            ],
            || {
                let err = SmtpConfig::from_env().unwrap_err();
                assert_eq!(err, std::env::VarError::NotPresent);
            },
        );
    }

    #[test]
    fn from_env_defaults_port_on_bad_port() {
        with_env(
            &[
                ("SMTP_USERNAME", Some("user@example.com")),
                ("SMTP_PASSWORD", Some("secret")),
                ("SMTP_HOST", Some("smtp.example.com")),
                ("SMTP_PORT", Some("not-a-number")),
            ],
            || {
                let cfg = SmtpConfig::from_env().unwrap();
                assert_eq!(cfg.host, "smtp.example.com");
                assert_eq!(cfg.port, 587);
                assert_eq!(cfg.username, "user@example.com");
                assert_eq!(cfg.password, "secret");
                assert_eq!(cfg.timeout_secs, 10);
                assert_eq!(cfg.retries, 2);
                assert_eq!(cfg.retry_delay_ms, 500);
            },
        );
    }

    #[test]
    fn from_env_uses_provided_host_and_port() {
        with_env(
            &[
                ("SMTP_USERNAME", Some("user@example.com")),
                ("SMTP_PASSWORD", Some("secret")),
                ("SMTP_HOST", Some("smtp.example.com")),
                ("SMTP_PORT", Some("587")),
            ],
            || {
                let cfg = SmtpConfig::from_env().unwrap();
                assert_eq!(cfg.host, "smtp.example.com");
                assert_eq!(cfg.port, 587);
                assert_eq!(cfg.username, "user@example.com");
                assert_eq!(cfg.password, "secret");
            },
        );
    }
}