fix(email): resolve SMTP connection failures (port 465 SMTPS, URL double scheme, retry backoff)

This commit is contained in:
ZhenYi 2026-04-19 01:04:11 +08:00
parent 882e86dc33
commit 39d30678b5
7 changed files with 46 additions and 28 deletions

1
Cargo.lock generated
View File

@ -2277,6 +2277,7 @@ dependencies = [
"lettre", "lettre",
"regex", "regex",
"serde", "serde",
"slog",
"tokio", "tokio",
] ]

View File

@ -17,9 +17,10 @@ path = "lib.rs"
[dependencies] [dependencies]
config = { workspace = true } config = { workspace = true }
lettre = { workspace = true } lettre = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "rt"] } tokio = { workspace = true, features = ["rt-multi-thread", "rt", "sync", "macros"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
anyhow = { workspace = true } anyhow = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
slog = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View File

@ -1,7 +1,5 @@
use config::AppConfig; use config::AppConfig;
use lettre::message::Mailbox; use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::Tls;
use lettre::transport::smtp::{PoolConfig, SmtpTransport}; use lettre::transport::smtp::{PoolConfig, SmtpTransport};
use lettre::Transport; use lettre::Transport;
use regex::Regex; use regex::Regex;
@ -26,7 +24,7 @@ pub struct AppEmail {
} }
impl AppEmail { impl AppEmail {
pub async fn init(cfg: &AppConfig) -> anyhow::Result<Self> { pub async fn init(cfg: &AppConfig, logs: slog::Logger) -> anyhow::Result<Self> {
let smtp_host = cfg.smtp_host()?; let smtp_host = cfg.smtp_host()?;
let smtp_port = cfg.smtp_port()?; let smtp_port = cfg.smtp_port()?;
let smtp_username = cfg.smtp_username()?; let smtp_username = cfg.smtp_username()?;
@ -35,24 +33,28 @@ impl AppEmail {
let smtp_tls = cfg.smtp_tls()?; let smtp_tls = cfg.smtp_tls()?;
let smtp_timeout = cfg.smtp_timeout()?; let smtp_timeout = cfg.smtp_timeout()?;
let cred = Credentials::new(smtp_username, smtp_password); // Port 465 = SMTPS (implicit TLS via smtps://), others = STARTTLS via smtp://
let url = if smtp_port == 465 && smtp_tls {
let tls_param = if smtp_tls { format!(
Tls::Required( "smtps://{}:{}@{}:{}",
lettre::transport::smtp::client::TlsParameters::builder(smtp_host.clone()) smtp_username, smtp_password, smtp_host, smtp_port
.build()
.map_err(|e| anyhow::anyhow!("TLS build error: {}", e))?,
) )
} else { } else {
Tls::None let tls_mode = if smtp_tls {
"required"
} else {
"opportunistic"
};
format!(
"smtp://{}:{}@{}:{}?tls={}",
smtp_username, smtp_password, smtp_host, smtp_port, tls_mode
)
}; };
let mailer = SmtpTransport::builder_dangerous(smtp_host) let mailer = SmtpTransport::from_url(&url)
.port(smtp_port) .map_err(|e| anyhow::anyhow!("SMTP transport build error: {}", e))?
.tls(tls_param)
.timeout(Some(Duration::from_secs(smtp_timeout))) .timeout(Some(Duration::from_secs(smtp_timeout)))
.credentials(cred) .pool_config(PoolConfig::new().min_idle(0).max_size(10))
.pool_config(PoolConfig::new().min_idle(5).max_size(100))
.build(); .build();
let from: Mailbox = smtp_from.parse()?; let from: Mailbox = smtp_from.parse()?;
@ -72,13 +74,15 @@ impl AppEmail {
.body(msg.clone().body) .body(msg.clone().body)
{ {
Ok(e) => e, Ok(e) => e,
Err(_) => continue, Err(_) => {
slog::warn!(logs, "Email build error: to={}", msg.to);
continue;
}
}; };
let mut success = false; let mut success = false;
for i in 0..3 { for i in 0..3 {
let mailer = mailer.clone(); let mailer = mailer.clone();
let email = email.clone(); let email = email.clone();
let result = tokio::task::spawn_blocking(move || mailer.send(&email)).await; let result = tokio::task::spawn_blocking(move || mailer.send(&email)).await;
match result { match result {
@ -86,15 +90,26 @@ impl AppEmail {
success = true; success = true;
break; break;
} }
_ => { Ok(Err(e)) => {
let backoff = 100 * (i + 1); if i == 2 {
tokio::time::sleep(Duration::from_millis(backoff)).await; slog::error!(
logs,
"Email send failed after retries: to={}, error={}",
msg.to,
e
);
}
tokio::time::sleep(Duration::from_secs((1 << i) as u64)).await;
}
Err(e) => {
slog::error!(logs, "Email spawn error: to={}, err={}", msg.to, e);
break;
} }
} }
} }
if !success { if !success {
println!("[System] email send fail: {:?}", msg.clone()); slog::warn!(logs, "Email send permanently failed: to={}", msg.to);
} }
} }
}); });

View File

@ -104,7 +104,7 @@ impl AppService {
.main_domain() .main_domain()
.map_err(|_| AppError::DoMainNotSet)?; .map_err(|_| AppError::DoMainNotSet)?;
let verify_link = format!("https://{}/auth/verify-email?token={}", domain, token); let verify_link = format!("{}/auth/verify-email?token={}", domain, token);
let envelope = queue::EmailEnvelope { let envelope = queue::EmailEnvelope {
id: Uuid::new_v4(), id: Uuid::new_v4(),

View File

@ -122,7 +122,7 @@ impl AppService {
.map_err(|_| AppError::DoMainNotSet)?; .map_err(|_| AppError::DoMainNotSet)?;
let email_address = params.email.clone(); let email_address = params.email.clone();
let reset_link = format!("https://{}/auth/reset-password?token={}", domain, token); let reset_link = format!("{}/auth/reset-password?token={}", domain, token);
let envelope = queue::EmailEnvelope { let envelope = queue::EmailEnvelope {
id: Uuid::new_v4(), id: Uuid::new_v4(),

View File

@ -95,12 +95,13 @@ impl AppService {
pub async fn new(config: AppConfig) -> anyhow::Result<Self> { pub async fn new(config: AppConfig) -> anyhow::Result<Self> {
let db = AppDatabase::init(&config).await?; let db = AppDatabase::init(&config).await?;
let cache = AppCache::init(&config).await?; let cache = AppCache::init(&config).await?;
let email = AppEmail::init(&config).await?;
let avatar = AppAvatar::init(&config).await?;
let log_level = config.log_level().unwrap_or_else(|_| "info".to_string()); let log_level = config.log_level().unwrap_or_else(|_| "info".to_string());
let logs = Self::build_slog_logger(&log_level); let logs = Self::build_slog_logger(&log_level);
let email = AppEmail::init(&config, logs.clone()).await?;
let avatar = AppAvatar::init(&config).await?;
// Build get_redis closure for MessageProducer // Build get_redis closure for MessageProducer
let get_redis: Arc< let get_redis: Arc<
dyn Fn() -> tokio::task::JoinHandle<anyhow::Result<deadpool_redis::cluster::Connection>> dyn Fn() -> tokio::task::JoinHandle<anyhow::Result<deadpool_redis::cluster::Connection>>

View File

@ -350,7 +350,7 @@ impl AppService {
.map_err(|_| AppError::DoMainNotSet)?; .map_err(|_| AppError::DoMainNotSet)?;
let invite_link = format!( let invite_link = format!(
"https://{}/auth/accept-workspace-invite?token={}", "{}/auth/accept-workspace-invite?token={}",
domain, domain,
token.clone() token.clone()
); );