gitdataai/libs/email/lib.rs
ZhenYi 09645d8641 fix: resolve multiple bugs across backend and frontend
Security fixes:
- Remove WS token from plaintext log output (ws_universal.rs)
- Replace weak LCG PRNG with rand::thread_rng() for access key generation
- Add project membership check to issue triage endpoint (prevent unauthorized AI usage)
- Validate deepLinkUrl to prevent javascript: navigation (XSS defense-in-depth)

Data integrity fixes:
- Fix UUID truncation in AI model sync (as_u128() as i64 -> timestamp_millis)
- Wrap PR cascade delete in database transaction
- Add missing cascade deletes for room_message_reaction, room_message_edit_history, room_notifications
- Fix N+1 query for last_commit_times (single grouped query instead of per-repo)

Panic prevention:
- Replace unwrap() with safe fallbacks in health/metrics endpoints (email, git-hook apps)
- Replace unwrap() in access key scopes serialization
- Replace expect() in tool executor result map with synthetic error
- Replace expect() in log level parsing with default fallback

Logic bugs:
- Fix users_online metric double-decrement (decrement only when count reaches 0)
- Fix Map iteration + deletion bug in universal-ws.ts onclose handler
- Fix stale audioStream reference in catch block (use local stream variable)
- Add missing reInit event cleanup in carousel.tsx
- Fix email retry backoff integer overflow ((1 << i) as u64 -> 1u64 << i)

React fixes:
- Use message.id instead of index as key in message-list
- Add audio stream cleanup on unmount in use-audio-recording
2026-04-27 13:54:21 +08:00

131 lines
4.5 KiB
Rust

use config::AppConfig;
use lettre::message::Mailbox;
use lettre::transport::smtp::{PoolConfig, SmtpTransport};
use lettre::Transport;
use metrics::counter;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use std::time::Duration;
use tokio::sync::mpsc;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct EmailMessage {
pub to: String,
pub subject: String,
pub body: String,
}
static EMAIL_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap());
#[derive(Clone)]
pub struct AppEmail {
sender: mpsc::Sender<EmailMessage>,
}
impl AppEmail {
pub async fn init(cfg: &AppConfig) -> anyhow::Result<Self> {
let smtp_host = cfg.smtp_host()?;
let smtp_port = cfg.smtp_port()?;
let smtp_username = cfg.smtp_username()?;
let smtp_password = cfg.smtp_password()?;
let smtp_from = cfg.smtp_from()?;
let smtp_tls = cfg.smtp_tls()?;
let smtp_timeout = cfg.smtp_timeout()?;
// 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 {
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::from_url(&url)
.map_err(|e| anyhow::anyhow!("SMTP transport build error: {}", e))?
.timeout(Some(Duration::from_secs(smtp_timeout)))
.pool_config(PoolConfig::new().min_idle(0).max_size(10))
.build();
let from: Mailbox = smtp_from.parse()?;
let (tx, mut rx) = mpsc::channel::<EmailMessage>(100);
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
let recipient: Mailbox = match msg.to.parse() {
Ok(addr) => addr,
Err(_) => {
counter!("email_validation_skipped_total").increment(1);
tracing::warn!(to = %msg.to, "Invalid recipient address");
continue;
}
};
let email = match lettre::Message::builder()
.from(from.clone())
.to(recipient)
.subject(msg.subject)
.body(msg.body)
{
Ok(e) => e,
Err(_) => {
counter!("email_build_errors_total").increment(1);
tracing::warn!(to = %msg.to, "Email build error");
continue;
}
};
let mut success = false;
for i in 0..3 {
counter!("email_send_attempts_total").increment(1);
let mailer = mailer.clone();
let email = email.clone();
let result = tokio::task::spawn_blocking(move || mailer.send(&email)).await;
match result {
Ok(Ok(_)) => {
success = true;
break;
}
Ok(Err(e)) => {
if i == 2 {
counter!("email_send_failures_total").increment(1);
tracing::error!(to = %msg.to, error = %e, "Email send failed after retries");
}
tokio::time::sleep(Duration::from_secs(1u64 << i)).await;
}
Err(e) => {
tracing::error!(to = %msg.to, error = %e, "Email spawn error");
break;
}
}
}
if success {
counter!("email_sent_total").increment(1);
}
}
});
Ok(Self { sender: tx })
}
pub async fn send(&self, msg: EmailMessage) -> anyhow::Result<()> {
self.sender
.send(msg)
.await
.map_err(|e| anyhow::anyhow!("queue send error: {}", e))
}
}