Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 95 additions & 2 deletions src/mail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,45 @@
//! 配 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;

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<F>(mut f: F, delay: Duration) -> Result<()>
where
F: FnMut() -> Result<()>,
{
let mut last_err: Option<anyhow::Error> = 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() {
Expand Down Expand Up @@ -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(),
Expand All @@ -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"));
}
}
56 changes: 55 additions & 1 deletion src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down Expand Up @@ -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:已注册(含未验证)账号应可走「邮箱验证码 → 重置密码」,
Expand Down
Loading