136 lines
4.2 KiB
Rust
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());
|
|
}
|
|
}
|