diff --git a/src/mail.rs b/src/mail.rs index 4f0118a..8edeeee 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -8,6 +8,11 @@ //! 配 587(STARTTLS 端口)会直接对 587 做 TLS 握手 → Gmail 返回明文 → //! rustls 报 InvalidContentType。若要用 587 STARTTLS 需改用 //! `SmtpTransport::builder_dangerous(...).tls(Tls::Opportunistic(...))`。 +//! +//! ⚠️ 重试(rant 2026-08-21T23:52:17):Gmail 等 SMTP 对数据中心 IP(阿里云/腾讯云等) +//! 会**间歇性静默丢弃**(TCP/TLS 成功但 SMTP banner 不响应 → 15s 超时)。这不是配置错误, +//! 是外部服务不可靠 → 应用层容错:发送失败后延迟 2s 重试,最多 2 次重试(共 3 次尝试); +//! 每次重试重建 transport(新 TCP+TLS 连接),天然应对瞬时故障。重试仍失败才报错。 use anyhow::{Context, Result}; use std::time::Duration; @@ -15,6 +20,33 @@ use std::time::Duration; use crate::config::Mail; use lettre::Transport; +/// SMTP 发送失败后的重试间隔(固定 2s,不用退避——Gmail 静默丢弃是瞬时的) +const RETRY_DELAY: Duration = Duration::from_secs(2); +/// 最大尝试次数(1 次初始 + 2 次重试) +const MAX_ATTEMPTS: usize = 3; + +/// 通用带重试执行器:`f` 失败 → 延迟 `delay` 重试,最多共 `MAX_ATTEMPTS` 次尝试; +/// 全部失败返回最后一次错误。测试可传 `Duration::ZERO` 避免慢测试。 +fn send_with_retry(mut f: F, delay: Duration) -> Result<()> +where + F: FnMut() -> Result<()>, +{ + let mut last_err: Option = None; + for attempt in 1..=MAX_ATTEMPTS { + match f() { + Ok(()) => return Ok(()), + Err(e) => { + log::warn!("SMTP 发送失败(attempt {attempt}/{MAX_ATTEMPTS}):{e:#}"); + last_err = Some(e); + if attempt < MAX_ATTEMPTS { + std::thread::sleep(delay); + } + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("SMTP 发送失败(未知错误)"))) +} + /// 发送验证码邮件;dev 模式(未配置 SMTP)时仅打日志并返回 Ok pub fn send_verification_code(cfg: &Mail, to: &str, code: &str) -> Result<()> { if !cfg.configured() { @@ -54,6 +86,19 @@ pub fn send_verification_code(cfg: &Mail, to: &str, code: &str) -> Result<()> { .body(body) .context("构建邮件失败")?; + // 发送带重试:失败 → 2s 后重试,最多 2 次重试(rant 2026-08-21T23:52:17) + send_with_retry( + || { + send_once(cfg, &email, to)?; + log::info!("验证码邮件已发送: {to}"); + Ok(()) + }, + RETRY_DELAY, + ) +} + +/// 单次发送:构建 transport(每次全新连接)+ 发送 +fn send_once(cfg: &Mail, email: &lettre::Message, to: &str) -> Result<()> { let creds = lettre::transport::smtp::authentication::Credentials::new( cfg.smtp_user.clone(), cfg.smtp_password.clone(), @@ -65,10 +110,58 @@ pub fn send_verification_code(cfg: &Mail, to: &str, code: &str) -> Result<()> { // 显式 15s 超时:避免无 pool 时挂住/长等(rant 2026-08-21T14:08:03) .timeout(Some(Duration::from_secs(15))) .build(); - mailer.send(&email).map_err(|e| { + mailer.send(email).map_err(|e| { log::error!("SMTP 发送验证码到 {to} 失败: {e:?}"); anyhow::anyhow!("发送验证码到 {to} 失败: {e}") })?; - log::info!("验证码邮件已发送: {to}"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retry_succeeds_after_failures() { + // 前两次失败、第三次成功 → 应重试后成功(共 3 次调用) + let mut calls = 0; + let r = send_with_retry( + || { + calls += 1; + if calls < 3 { + Err(anyhow::anyhow!("transient failure")) + } else { + Ok(()) + } + }, + Duration::ZERO, + ); + assert!(r.is_ok(), "重试后应成功: {r:?}"); + assert_eq!(calls, 3, "应恰好尝试 3 次(1 次初始 + 2 次重试)"); + } + + #[test] + fn retry_gives_up_after_max_attempts() { + // 恒失败 → 应报错且不无限重试(恰好 3 次尝试) + let mut calls = 0; + let r = send_with_retry( + || { + calls += 1; + Err(anyhow::anyhow!("always fails")) + }, + Duration::ZERO, + ); + assert!(r.is_err(), "恒失败最终应报错"); + assert_eq!( + calls, MAX_ATTEMPTS, + "应恰好尝试 {MAX_ATTEMPTS} 次,不无限重试" + ); + } + + #[test] + fn retry_returns_last_error() { + // 最后一次错误应被返回(便于上层日志/定位) + let r = send_with_retry(|| Err(anyhow::anyhow!("final-err")), Duration::ZERO); + assert!(r.unwrap_err().to_string().contains("final-err")); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index f323c7c..7061241 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -182,7 +182,18 @@ fn send_code(st: &AppState, email: &str) -> Result<(bool, String), ApiErr> { .map_err(internal)?; } let dev = !st.cfg.mail.configured(); - crate::mail::send_verification_code(&st.cfg.mail, email, &code).map_err(internal)?; + if let Err(e) = crate::mail::send_verification_code(&st.cfg.mail, email, &code) { + // SMTP 发送失败(重试后仍失败)→ 清除验证码记录(解除 60s 重发限频,用户可立即重发), + // 返回 502 + 明确错误提示(rant 2026-08-21T23:52:17:半注册账号兜底) + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let _ = dao::clear_verification(&conn, email); + drop(conn); + log::error!("验证码发送失败({email},重试后仍失败): {e:#}"); + return Err(( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": "验证码发送失败,请重试" })), + )); + } Ok((dev, code)) } @@ -1251,6 +1262,49 @@ mod tests { assert_eq!(s, StatusCode::BAD_REQUEST, "弱密码应 400"); } + #[tokio::test] + async fn register_smtp_failure_502() { + // SMTP 指向不可达主机 → 发送失败(重试后仍失败)→ 注册返回 502 + 明确错误, + // 且验证码记录被清除(解除 60s 限频,用户可立即重发)——rant 2026-08-21T23:52:17 + let p = + std::env::temp_dir().join(format!("atp_route_{}_{}.db", std::process::id(), "reg502")); + let _ = std::fs::remove_file(&p); + let conn = crate::db::open(p.to_str().unwrap()).expect("open tmp db"); + crate::db::seed_test_users(&conn).expect("seed test users"); + let mut cfg = crate::config::Config::load("config/config.example.toml").unwrap(); + cfg.mail.smtp_host = "127.0.0.1".to_string(); + cfg.mail.smtp_port = 1; // 不可达端口:连接立即失败 + cfg.mail.from = "noreply@test.local".to_string(); + crate::db::seed_models(&conn, &cfg).expect("seed models"); + let crypto = crate::crypto::Crypto::new([9u8; 32]); + let st = AppState::new(conn, Arc::new(cfg), crypto); + let (s, body) = post( + st.clone(), + "/api/auth/register", + r#"{"email":"smtp502@test.local","password":"password123"}"#, + None, + ) + .await; + assert_eq!(s, StatusCode::BAD_GATEWAY, "SMTP 发送失败应 502: {body}"); + assert!( + body.contains("验证码发送失败"), + "错误信息应明确可操作: {body}" + ); + // 验证码记录应已被清除 → 同邮箱立即 resend 不应被 60s 限频卡住(429)而是走到发送(502) + let (s2, _) = post( + st, + "/api/auth/resend-code", + r#"{"email":"smtp502@test.local"}"#, + None, + ) + .await; + assert_ne!( + s2, + StatusCode::TOO_MANY_REQUESTS, + "发送失败后重发不应被限频卡住(验证码记录已清除)" + ); + } + #[tokio::test] async fn forgot_reset_password_full_flow() { // 宿主 2026-08-20:已注册(含未验证)账号应可走「邮箱验证码 → 重置密码」,