use crate::{Message, Result, MailError};
use async_trait::async_trait;
use lettre::{
AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
message::{header::ContentType, Attachment as LettreAttachment, Mailbox, MultiPart, SinglePart},
};
use lettre::transport::smtp::authentication::Credentials;
#[async_trait]
pub trait Transport: Send + Sync {
async fn send(&self, message: Message) -> Result<()>;
async fn verify(&self) -> Result<()>;
}
#[derive(Debug, Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub use_tls: bool,
}
impl SmtpConfig {
pub fn new(host: impl Into<String>, port: u16) -> Self {
Self {
host: host.into(),
port,
username: None,
password: None,
use_tls: true,
}
}
pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.username = Some(username.into());
self.password = Some(password.into());
self
}
pub fn use_tls(mut self, use_tls: bool) -> Self {
self.use_tls = use_tls;
self
}
}
pub struct SmtpTransport {
config: SmtpConfig,
transport: AsyncSmtpTransport<Tokio1Executor>,
}
impl SmtpTransport {
pub fn new(host: impl Into<String>, port: u16) -> Result<Self> {
let config = SmtpConfig::new(host, port);
let transport = Self::build_transport(&config)?;
Ok(Self { config, transport })
}
pub fn from_config(config: SmtpConfig) -> Result<Self> {
let transport = Self::build_transport(&config)?;
Ok(Self { config, transport })
}
pub fn config(&self) -> &SmtpConfig {
&self.config
}
fn build_transport(config: &SmtpConfig) -> Result<AsyncSmtpTransport<Tokio1Executor>> {
let mut builder = if config.use_tls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
.map_err(|e| MailError::Smtp(e.to_string()))?
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host)
};
builder = builder.port(config.port);
if let (Some(username), Some(password)) = (&config.username, &config.password) {
builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
}
Ok(builder.build())
}
fn build_email(&self, message: Message) -> Result<lettre::Message> {
message.validate()?;
let from: Mailbox = message.from.as_ref().unwrap().parse()?;
let to: Vec<Mailbox> = message.to.iter()
.map(|addr| addr.parse())
.collect::<std::result::Result<Vec<_>, _>>()?;
let mut email_builder = lettre::Message::builder()
.from(from)
.subject(message.subject.as_ref().unwrap());
for recipient in to {
email_builder = email_builder.to(recipient);
}
for cc in &message.cc {
email_builder = email_builder.cc(cc.parse()?);
}
for bcc in &message.bcc {
email_builder = email_builder.bcc(bcc.parse()?);
}
if let Some(reply_to) = &message.reply_to {
email_builder = email_builder.reply_to(reply_to.parse()?);
}
let mut body = if let (Some(text), Some(html)) = (&message.text, &message.html) {
MultiPart::alternative_plain_html(text.clone(), html.clone())
} else if let Some(html) = &message.html {
MultiPart::alternative()
.singlepart(SinglePart::html(html.clone()))
} else if let Some(text) = &message.text {
MultiPart::alternative()
.singlepart(SinglePart::plain(text.clone()))
} else {
return Err(MailError::MissingField("text or html".to_string()));
};
if !message.attachments.is_empty() {
let mut multipart = MultiPart::mixed().multipart(body);
for attachment in &message.attachments {
let content_type = if let Some(ct) = &attachment.content_type {
ContentType::parse(ct)
.map_err(|_| MailError::Attachment(format!("Invalid content type: {ct}")))?
} else {
ContentType::TEXT_PLAIN
};
let part = if attachment.inline {
if let Some(cid) = &attachment.content_id {
LettreAttachment::new_inline_with_name(cid.clone(), attachment.filename.clone())
.body(attachment.content.clone(), content_type)
} else {
LettreAttachment::new_inline_with_name(
attachment.filename.clone(),
attachment.filename.clone(),
)
.body(attachment.content.clone(), content_type)
}
} else {
LettreAttachment::new(attachment.filename.clone())
.body(attachment.content.clone(), content_type)
};
multipart = multipart.singlepart(part);
}
body = multipart;
}
let email = email_builder.multipart(body)?;
Ok(email)
}
pub fn try_build(&self, message: Message) -> Result<lettre::Message> {
self.build_email(message)
}
}
#[async_trait]
impl Transport for SmtpTransport {
async fn send(&self, message: Message) -> Result<()> {
let email = self.build_email(message)?;
self.transport.send(email).await?;
Ok(())
}
async fn verify(&self) -> Result<()> {
self.transport.test_connection().await
.map_err(|e| MailError::Smtp(e.to_string()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::SmtpTransport;
use crate::{Attachment, Message};
#[tokio::test]
async fn build_email_rejects_invalid_attachment_content_type() {
let transport = SmtpTransport::new("localhost", 1025).expect("transport init");
let message = Message::new()
.from("sender@example.com")
.to("recipient@example.com")
.subject("subject")
.text("body")
.attach(
Attachment::new("file.bin")
.content(vec![1, 2, 3])
.content_type("not/a valid mime"),
);
let result = transport.build_email(message);
assert!(result.is_err());
}
#[tokio::test]
async fn try_build_valid_message() {
let transport = SmtpTransport::new("localhost", 1025).expect("transport init");
let message = Message::new()
.from("sender@example.com")
.to("recipient@example.com")
.subject("subject")
.text("body");
assert!(transport.try_build(message).is_ok());
}
}