use lettre::{ Address, AsyncSmtpTransport, Message, Tokio1Executor, message::{ Attachment as LettreAttachment, Body, Mailbox, MultiPart, SinglePart, header::{ContentType, HeaderName, HeaderValue}, }, transport::smtp::{self, authentication::Credentials, extension::ClientId}, }; use crate::{ config::{SmtpConfig, SmtpTls}, error::EmailError, pb::email::v1::{Attachment, EmailAddress, EmailPriority, SendEmailRequest}, }; pub(crate) fn build_mailer( config: &SmtpConfig, ) -> Result, EmailError> { let mut builder = match config.tls { SmtpTls::None => { smtp::AsyncSmtpTransport::::builder_dangerous(&config.host) } SmtpTls::StartTls => { smtp::AsyncSmtpTransport::::starttls_relay(&config.host) .map_err(|e| EmailError::BuildTransport(e.to_string()))? } SmtpTls::Tls => smtp::AsyncSmtpTransport::::relay(&config.host) .map_err(|e| EmailError::BuildTransport(e.to_string()))?, } .port(config.port) .timeout(Some(config.timeout)); match (&config.username, &config.password) { (Some(u), Some(p)) => { builder = builder.credentials(Credentials::new(u.clone(), p.clone())); } (None, None) => {} _ => return Err(EmailError::IncompleteCredentials), } if let Some(name) = config.helo_name.as_deref().and_then(non_empty) { builder = builder.hello_name(ClientId::Domain(name.to_owned())); } Ok(builder.build()) } pub(crate) fn build_body( builder: lettre::message::MessageBuilder, request: &SendEmailRequest, ) -> Result { match select_body_multipart(request) { BodyKind::Both(mp) => builder .multipart(mp) .map_err(|e| EmailError::BuildMessage(e.to_string())), BodyKind::Html(sp) => builder .singlepart(sp) .map_err(|e| EmailError::BuildMessage(e.to_string())), BodyKind::Plain(sp) => builder .singlepart(sp) .map_err(|e| EmailError::BuildMessage(e.to_string())), } } pub(crate) fn build_mixed_body(request: &SendEmailRequest) -> MultiPart { let mixed = MultiPart::mixed(); match select_body_multipart(request) { BodyKind::Both(mp) => mixed.multipart(mp), BodyKind::Html(sp) => mixed.singlepart(sp), BodyKind::Plain(sp) => mixed.singlepart(sp), } } enum BodyKind { Both(MultiPart), Html(SinglePart), Plain(SinglePart), } fn select_body_multipart(request: &SendEmailRequest) -> BodyKind { if !request.text_body.is_empty() && !request.html_body.is_empty() { BodyKind::Both(MultiPart::alternative_plain_html( request.text_body.clone(), request.html_body.clone(), )) } else if !request.html_body.is_empty() { BodyKind::Html(SinglePart::html(request.html_body.clone())) } else { BodyKind::Plain(SinglePart::plain(request.text_body.clone())) } } pub(crate) fn build_single_attachment( index: usize, att: &Attachment, ) -> Result { let filename = if att.filename.trim().is_empty() { format!("attachment-{index}") } else { att.filename.clone() }; if att.data.is_empty() && !att.url.trim().is_empty() { return Err(EmailError::UnsupportedAttachmentUrl { filename, url: att.url.clone(), }); } let ct: ContentType = if att.content_type.trim().is_empty() { "application/octet-stream" .parse() .expect("valid static mime") } else { att.content_type .parse() .map_err(|e: lettre::message::header::ContentTypeErr| { EmailError::InvalidContentType { filename: filename.clone(), value: att.content_type.clone(), reason: e.to_string(), } })? }; Ok(LettreAttachment::new(filename).body(Body::new(att.data.clone()), ct)) } pub(crate) fn mailbox_from_email_address( field: &'static str, value: &EmailAddress, ) -> Result { mailbox_from_parts(field, &value.email, non_empty(&value.name)) } pub(crate) fn mailbox_from_parts( field: &'static str, email: &str, name: Option<&str>, ) -> Result { let address = email .trim() .parse::
() .map_err(|e| EmailError::InvalidAddress { field, value: email.to_owned(), reason: e.to_string(), })?; Ok(Mailbox::new(name.map(ToOwned::to_owned), address)) } pub(crate) fn apply_custom_headers( mut builder: lettre::message::MessageBuilder, request: &SendEmailRequest, ) -> Result { for (name, value) in &request.headers { if non_empty(name).is_none() { continue; } if is_managed_header(name) { return Err(EmailError::ForbiddenHeader { name: name.clone() }); } if value.contains(['\r', '\n']) { return Err(EmailError::InvalidHeader { name: name.clone(), reason: "header value must not contain CR or LF".to_owned(), }); } let hn = HeaderName::new_from_ascii(name.clone()).map_err(|e| EmailError::InvalidHeader { name: name.clone(), reason: e.to_string(), })?; builder = builder.raw_header(HeaderValue::new(hn, value.clone())); } Ok(builder) } pub(crate) fn apply_priority_headers( builder: lettre::message::MessageBuilder, priority: i32, ) -> lettre::message::MessageBuilder { match EmailPriority::try_from(priority).unwrap_or(EmailPriority::Unspecified) { EmailPriority::High => builder .raw_header(hv("X-Priority", "1")) .raw_header(hv("Importance", "high")), EmailPriority::Low => builder .raw_header(hv("X-Priority", "5")) .raw_header(hv("Importance", "low")), EmailPriority::Normal | EmailPriority::Unspecified => builder, } } fn hv(name: &'static str, value: &str) -> HeaderValue { HeaderValue::new(HeaderName::new_from_ascii_str(name), value.to_owned()) } fn is_managed_header(name: &str) -> bool { matches!( name.trim().to_ascii_lowercase().as_str(), "from" | "to" | "cc" | "bcc" | "subject" | "reply-to" | "date" | "mime-version" | "content-type" | "content-transfer-encoding" | "content-disposition" ) } pub(crate) fn non_empty(value: &str) -> Option<&str> { let v = value.trim(); (!v.is_empty()).then_some(v) } pub fn build_message_from_parts( config: &SmtpConfig, request: &SendEmailRequest, ) -> Result { if request.to.is_empty() { return Err(EmailError::MissingRecipients); } if request.text_body.is_empty() && request.html_body.is_empty() { return Err(EmailError::BuildMessage( "email must have at least a text or HTML body".to_owned(), )); } let from = resolve_sender(config, request)?; let mut builder = Message::builder() .from(from) .subject(request.subject.clone()); for r in &request.to { builder = builder.to(mailbox_from_email_address("to", r)?); } for r in &request.cc { builder = builder.cc(mailbox_from_email_address("cc", r)?); } for r in &request.bcc { builder = builder.bcc(mailbox_from_email_address("bcc", r)?); } if let Some(reply_to) = resolve_reply_to(config, request)? { builder = builder.reply_to(reply_to); } builder = apply_custom_headers(builder, request)?; builder = apply_priority_headers(builder, request.priority); if request.attachments.is_empty() { return build_body(builder, request); } let mut mixed = build_mixed_body(request); for (i, att) in request.attachments.iter().enumerate() { mixed = mixed.singlepart(build_single_attachment(i, att)?); } builder .multipart(mixed) .map_err(|e| EmailError::BuildMessage(e.to_string())) } fn resolve_sender(config: &SmtpConfig, request: &SendEmailRequest) -> Result { if let Some(from) = &request.from && !from.email.trim().is_empty() { if !config.allow_request_from { return Err(EmailError::RequestSenderDisabled); } return mailbox_from_email_address("from", from); } let email = config .from_email .as_deref() .filter(|v| !v.trim().is_empty()) .ok_or(EmailError::MissingSender)?; mailbox_from_parts("from", email, config.from_name.as_deref()) } fn resolve_reply_to( config: &SmtpConfig, request: &SendEmailRequest, ) -> Result, EmailError> { let email = non_empty(&request.reply_to).or(config.reply_to.as_deref()); email .map(|v| mailbox_from_parts("reply_to", v, None)) .transpose() }