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",
"regex",
"serde",
"slog",
"tokio",
]

View File

@ -17,9 +17,10 @@ path = "lib.rs"
[dependencies]
config = { 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"] }
anyhow = { workspace = true }
regex = { workspace = true }
slog = { workspace = true }
[lints]
workspace = true

View File

@ -1,7 +1,5 @@
use config::AppConfig;
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;
use regex::Regex;
@ -26,7 +24,7 @@ pub struct 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_port = cfg.smtp_port()?;
let smtp_username = cfg.smtp_username()?;
@ -35,24 +33,28 @@ impl AppEmail {
let smtp_tls = cfg.smtp_tls()?;
let smtp_timeout = cfg.smtp_timeout()?;
let cred = Credentials::new(smtp_username, smtp_password);
let tls_param = if smtp_tls {
Tls::Required(
lettre::transport::smtp::client::TlsParameters::builder(smtp_host.clone())
.build()
.map_err(|e| anyhow::anyhow!("TLS build error: {}", e))?,
// Port 465 = SMTPS (implicit TLS via smtps://), others = STARTTLS via smtp://
let url = if smtp_port == 465 && smtp_tls {
format!(
"smtps://{}:{}@{}:{}",
smtp_username, smtp_password, smtp_host, smtp_port
)
} 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)
.port(smtp_port)
.tls(tls_param)
let mailer = SmtpTransport::from_url(&url)
.map_err(|e| anyhow::anyhow!("SMTP transport build error: {}", e))?
.timeout(Some(Duration::from_secs(smtp_timeout)))
.credentials(cred)
.pool_config(PoolConfig::new().min_idle(5).max_size(100))
.pool_config(PoolConfig::new().min_idle(0).max_size(10))
.build();
let from: Mailbox = smtp_from.parse()?;
@ -72,13 +74,15 @@ impl AppEmail {
.body(msg.clone().body)
{
Ok(e) => e,
Err(_) => continue,
Err(_) => {
slog::warn!(logs, "Email build error: to={}", msg.to);
continue;
}
};
let mut success = false;
for i in 0..3 {
let mailer = mailer.clone();
let email = email.clone();
let result = tokio::task::spawn_blocking(move || mailer.send(&email)).await;
match result {
@ -86,15 +90,26 @@ impl AppEmail {
success = true;
break;
}
_ => {
let backoff = 100 * (i + 1);
tokio::time::sleep(Duration::from_millis(backoff)).await;
Ok(Err(e)) => {
if i == 2 {
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 {
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()
.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 {
id: Uuid::new_v4(),

View File

@ -122,7 +122,7 @@ impl AppService {
.map_err(|_| AppError::DoMainNotSet)?;
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 {
id: Uuid::new_v4(),

View File

@ -95,12 +95,13 @@ impl AppService {
pub async fn new(config: AppConfig) -> anyhow::Result<Self> {
let db = AppDatabase::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 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
let get_redis: Arc<
dyn Fn() -> tokio::task::JoinHandle<anyhow::Result<deadpool_redis::cluster::Connection>>

View File

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