use std::time::Duration; use config::AppConfig; use lettre::{ Message, Transport, message::{Mailbox, header::ContentType}, transport::smtp::{PoolConfig, SmtpTransport, authentication::Credentials}, }; use tracing::warn; use crate::EmailMessage; #[derive(Clone)] pub struct SmtpEmailSender { mailer: SmtpTransport, from: Mailbox, retry_attempts: u32, retry_base_delay: Duration, } impl SmtpEmailSender { pub fn new(config: &AppConfig) -> anyhow::Result { let smtp_host = config.smtp_host()?; let smtp_port = config.smtp_port()?; let smtp_username = config.smtp_username()?; let smtp_password = config.smtp_password()?; let smtp_from = config.smtp_from()?; let smtp_tls = config.smtp_tls()?; let smtp_timeout = config.smtp_timeout()?; let builder = if smtp_tls { if smtp_port == 465 { SmtpTransport::relay(&smtp_host)? } else { SmtpTransport::starttls_relay(&smtp_host)? } } else { SmtpTransport::builder_dangerous(&smtp_host) }; let mailer = builder .credentials(Credentials::new(smtp_username, smtp_password)) .port(smtp_port) .timeout(Some(Duration::from_secs(smtp_timeout))) .pool_config(PoolConfig::new().min_idle(0).max_size(10)) .build(); Ok(Self { mailer, from: smtp_from.parse()?, retry_attempts: config.email_send_retry_attempts(), retry_base_delay: Duration::from_secs( config.email_send_retry_base_delay_secs(), ), }) } pub async fn send(&self, message: EmailMessage) -> anyhow::Result<()> { let recipient: Mailbox = message.to.parse()?; let email = Message::builder() .from(self.from.clone()) .to(recipient) .subject(message.subject) .header(ContentType::TEXT_PLAIN) .body(message.body)?; let attempts = self.retry_attempts.max(1); let mut last_error = None; for attempt in 0..attempts { let mailer = self.mailer.clone(); let email = email.clone(); let result = tokio::task::spawn_blocking(move || mailer.send(&email)).await; match result { Ok(Ok(_)) => return Ok(()), Ok(Err(error)) => { last_error = Some(anyhow::anyhow!(error)); warn!(attempt = attempt + 1, "email send attempt failed"); } Err(error) => { return Err(anyhow::anyhow!( "email send task failed: {error}" )); } } if attempt + 1 < attempts { let multiplier = 1u64.checked_shl(attempt).unwrap_or(u64::MAX).max(1); tokio::time::sleep( self.retry_base_delay.saturating_mul(multiplier as u32), ) .await; } } Err(last_error.unwrap_or_else(|| anyhow::anyhow!("email send failed"))) } } #[cfg(test)] mod tests { use std::collections::HashMap; use config::AppConfig; use super::SmtpEmailSender; #[test] fn smtp_config_accepts_credentials_with_url_special_characters() { let config = AppConfig { env: HashMap::from([ ("APP_SMTP_HOST".to_string(), "smtp.example.com".to_string()), ("APP_SMTP_PORT".to_string(), "587".to_string()), ( "APP_SMTP_USERNAME".to_string(), "user@example.com".to_string(), ), ( "APP_SMTP_PASSWORD".to_string(), "p@ss:word/with?chars".to_string(), ), ( "APP_SMTP_FROM".to_string(), "Gitdata ".to_string(), ), ("APP_SMTP_TLS".to_string(), "true".to_string()), ]), }; assert!(SmtpEmailSender::new(&config).is_ok()); } }