main
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<Tokio1Executor>,
}
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<String, EmailError> {
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<String>,
password: Option<String>,
connection_timeout: Option<Duration>,
}
impl EmailTransportBuilder {
pub fn new(
host: impl Into<String>,
port: u16,
security: SmtpSecurity,
username: Option<String>,
password: Option<String>,
) -> 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<EmailTransport, EmailError> {
if self.host.is_empty() {
return Err(EmailError::Config("smtp host is empty".into()));
}
let mut builder = match self.security {
SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&self.host)?,
SmtpSecurity::StartTls => {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.host)?
}
SmtpSecurity::Plain => {
AsyncSmtpTransport::<Tokio1Executor>::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<Mailbox, EmailError> {
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<Message, EmailError> {
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)
}