fix(email): resolve SMTP connection failures (port 465 SMTPS, URL double scheme, retry backoff)
This commit is contained in:
parent
882e86dc33
commit
39d30678b5
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2277,6 +2277,7 @@ dependencies = [
|
|||||||
"lettre",
|
"lettre",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
"slog",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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>>
|
||||||
|
|||||||
@ -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()
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user