use std::time::{Duration, Instant}; use base64::Engine; use config::smtp::SmtpSecurity; use lettre::address::Address; use lettre::message::header::ContentType; use lettre::message::{ Attachment as LettreAttachment, Mailbox, Message, MessageBuilder, MultiPart, SinglePart, }; use lettre::transport::smtp::AsyncSmtpTransport; use lettre::transport::smtp::authentication::{Credentials, Mechanism}; use lettre::{AsyncTransport, Tokio1Executor}; use tracing::debug; use crate::error::EmailError; use crate::events::{EmailAddress, SendEmailRequest}; pub struct EmailTransport { inner: AsyncSmtpTransport, } impl EmailTransport { #[tracing::instrument(skip_all, fields(request_id = %_request_id.unwrap_or_default()))] pub async fn send( &self, msg: Message, _request_id: Option<&str>, ) -> Result { let started = Instant::now(); let response = self.inner.send(msg).await?; let id = response.message().next().unwrap_or_default().to_string(); let id_short: String = id.chars().take(16).collect(); debug!( elapsed_ms = started.elapsed().as_millis() as u64, message_id = %id_short, "smtp response received" ); Ok(id) } } impl Clone for EmailTransport { fn clone(&self) -> Self { Self { inner: self.inner.clone(), } } } pub struct EmailTransportBuilder { host: String, port: u16, security: SmtpSecurity, username: Option, password: Option, connection_timeout: Option, } impl EmailTransportBuilder { pub fn new( host: impl Into, port: u16, security: SmtpSecurity, username: Option, password: Option, ) -> Self { Self { host: host.into(), port, security, username, password, connection_timeout: None, } } pub fn connection_timeout(mut self, dur: Duration) -> Self { self.connection_timeout = Some(dur); self } pub fn build(self) -> Result { if self.host.is_empty() { return Err(EmailError::Config("smtp host is empty".into())); } let mut builder = match self.security { SmtpSecurity::Tls => AsyncSmtpTransport::::relay(&self.host)?, SmtpSecurity::StartTls => { AsyncSmtpTransport::::starttls_relay(&self.host)? } SmtpSecurity::Plain => { AsyncSmtpTransport::::builder_dangerous(self.host.clone()) } }; builder = builder.port(self.port); if let Some(ct) = self.connection_timeout { builder = builder.timeout(Some(ct)); } if let (Some(u), Some(p)) = (&self.username, &self.password) { builder = builder .credentials(Credentials::new(u.clone(), p.clone())) .authentication(vec![Mechanism::Plain]); } debug!( host = %self.host, port = self.port, has_credentials = self.username.is_some() && self.password.is_some(), timeout_ms = self.connection_timeout.map(|d| d.as_millis() as u64).unwrap_or(0), "smtp transport builder configured" ); Ok(EmailTransport { inner: builder.build(), }) } } fn to_mailbox(addr: &EmailAddress) -> Result { let parsed: Address = addr.address.parse()?; let mb = match &addr.name { Some(name) if !name.is_empty() => Mailbox::new(Some(name.clone()), parsed), _ => Mailbox::new(None, parsed), }; Ok(mb) } pub fn build_message( req: &SendEmailRequest, from: Mailbox, max_attachment_bytes: usize, ) -> Result { let has_text = req.text.as_deref().is_some_and(|s| !s.is_empty()); let has_html = req.html.as_deref().is_some_and(|s| !s.is_empty()); let recipient_count = req.to.len() + req.cc.len() + req.bcc.len(); debug!( subject_len = req.subject.len(), recipients = recipient_count, attachments = req.attachments.len(), has_text, has_html, "building email message" ); if !has_text && !has_html { return Err(EmailError::Missing( "email body requires text or html".into(), )); } let mut builder = MessageBuilder::new() .from(from) .subject(req.subject.clone()); for addr in &req.to { let mb = to_mailbox(addr)?; builder = builder.to(mb); } for addr in &req.cc { let mb = to_mailbox(addr)?; builder = builder.cc(mb); } for addr in &req.bcc { let mb = to_mailbox(addr)?; builder = builder.bcc(mb); } if let Some(reply) = &req.reply_to { let mb = to_mailbox(reply)?; builder = builder.reply_to(mb); } let body_part: MultiPart = match (has_text, has_html) { (true, true) => { let text = SinglePart::plain(req.text.as_deref().unwrap_or_default().to_string()); let html = SinglePart::html(req.html.as_deref().unwrap_or_default().to_string()); MultiPart::alternative().singlepart(text).singlepart(html) } (true, false) => MultiPart::alternative().singlepart(SinglePart::plain( req.text.as_deref().unwrap_or_default().to_string(), )), (false, true) => MultiPart::alternative().singlepart(SinglePart::html( req.html.as_deref().unwrap_or_default().to_string(), )), (false, false) => unreachable!(), }; let message = if req.attachments.is_empty() { builder.multipart(body_part)? } else { let mut mixed = MultiPart::mixed().multipart(body_part); for att in &req.attachments { // base64 解码前先估算解压后大小,避免超大请求先吃满内存才被拒。 let estimated = att.data_b64.len() / 4 * 3; if estimated > max_attachment_bytes { return Err(EmailError::AttachmentTooLarge { actual: estimated, max: max_attachment_bytes, }); } let bytes = base64::engine::general_purpose::STANDARD.decode(att.data_b64.as_bytes())?; if bytes.len() > max_attachment_bytes { return Err(EmailError::AttachmentTooLarge { actual: bytes.len(), max: max_attachment_bytes, }); } let ct_str = if att.content_type.is_empty() { "application/octet-stream".to_string() } else { att.content_type.clone() }; let content_type: ContentType = ct_str .parse() .map_err(|e: lettre::message::header::ContentTypeErr| { EmailError::Other(format!("invalid content_type {ct_str:?}: {e}")) })?; if let Some(cid) = &att.content_id { mixed = mixed.singlepart( LettreAttachment::new_inline_with_name(cid.clone(), att.filename.clone()) .body(bytes, content_type), ); } else { mixed = mixed.singlepart( LettreAttachment::new(att.filename.clone()).body(bytes, content_type), ); } } builder.multipart(mixed)? }; Ok(message) }