gitdataai/lib/email/smtp.rs
2026-05-30 01:38:40 +08:00

136 lines
4.2 KiB
Rust

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<Self> {
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 <noreply@example.com>".to_string(),
),
("APP_SMTP_TLS".to_string(), "true".to_string()),
]),
};
assert!(SmtpEmailSender::new(&config).is_ok());
}
}