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");
},
);
}
}