use async_trait::async_trait;
use lettre::{
message::{header, Mailbox, MultiPart, SinglePart},
transport::smtp::{
authentication::Credentials,
client::{Tls, TlsParameters},
},
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
use crate::email::{Email, EmailError, EmailSender};
#[derive(Debug, Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub use_tls: bool,
}
impl SmtpConfig {
pub fn from_env() -> Result<Self, EmailError> {
let host = std::env::var("SMTP_HOST")
.map_err(|_| EmailError::config("SMTP_HOST environment variable not set"))?;
let port = std::env::var("SMTP_PORT")
.unwrap_or_else(|_| "587".to_string())
.parse()
.map_err(|_| EmailError::config("SMTP_PORT must be a valid port number"))?;
let username = std::env::var("SMTP_USERNAME")
.map_err(|_| EmailError::config("SMTP_USERNAME environment variable not set"))?;
let password = std::env::var("SMTP_PASSWORD")
.map_err(|_| EmailError::config("SMTP_PASSWORD environment variable not set"))?;
let use_tls = std::env::var("SMTP_USE_TLS")
.unwrap_or_else(|_| "true".to_string())
.parse()
.unwrap_or(true);
Ok(Self {
host,
port,
username,
password,
use_tls,
})
}
}
pub struct SmtpBackend {
config: SmtpConfig,
}
impl SmtpBackend {
#[must_use]
pub const fn new(config: SmtpConfig) -> Self {
Self { config }
}
pub fn from_env() -> Result<Self, EmailError> {
let config = SmtpConfig::from_env()?;
Ok(Self::new(config))
}
fn build_message(email: &Email) -> Result<Message, EmailError> {
email.validate()?;
let from_addr = email.from.as_ref().ok_or(EmailError::NoSender)?;
let from: Mailbox = from_addr
.parse()
.map_err(|_| EmailError::InvalidAddress(from_addr.clone()))?;
let mut builder = Message::builder().from(from);
for to_addr in &email.to {
let to: Mailbox = to_addr
.parse()
.map_err(|_| EmailError::InvalidAddress(to_addr.clone()))?;
builder = builder.to(to);
}
for cc_addr in &email.cc {
let cc: Mailbox = cc_addr
.parse()
.map_err(|_| EmailError::InvalidAddress(cc_addr.clone()))?;
builder = builder.cc(cc);
}
for bcc_addr in &email.bcc {
let bcc: Mailbox = bcc_addr
.parse()
.map_err(|_| EmailError::InvalidAddress(bcc_addr.clone()))?;
builder = builder.bcc(bcc);
}
if let Some(reply_to_addr) = &email.reply_to {
let reply_to: Mailbox = reply_to_addr
.parse()
.map_err(|_| EmailError::InvalidAddress(reply_to_addr.clone()))?;
builder = builder.reply_to(reply_to);
}
let subject = email.subject.as_ref().ok_or(EmailError::NoSubject)?;
builder = builder.subject(subject);
let message = if let (Some(html), Some(text)) = (&email.html, &email.text) {
builder
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_PLAIN)
.body(text.clone()),
)
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_HTML)
.body(html.clone()),
),
)
.map_err(|e| EmailError::smtp(e.to_string()))?
} else if let Some(html) = &email.html {
builder
.header(header::ContentType::TEXT_HTML)
.body(html.clone())
.map_err(|e| EmailError::smtp(e.to_string()))?
} else if let Some(text) = &email.text {
builder
.header(header::ContentType::TEXT_PLAIN)
.body(text.clone())
.map_err(|e| EmailError::smtp(e.to_string()))?
} else {
return Err(EmailError::NoContent);
};
Ok(message)
}
fn create_transport(&self) -> Result<AsyncSmtpTransport<Tokio1Executor>, EmailError> {
let credentials = Credentials::new(
self.config.username.clone(),
self.config.password.clone(),
);
let mut transport = if self.config.use_tls {
let tls_parameters = TlsParameters::new(self.config.host.clone())
.map_err(|e| EmailError::smtp(format!("TLS parameters error: {e}")))?;
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.config.host)
.map_err(|e| EmailError::smtp(e.to_string()))?
.credentials(credentials)
.tls(Tls::Required(tls_parameters))
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.config.host)
.credentials(credentials)
};
transport = transport.port(self.config.port);
Ok(transport.build())
}
}
#[async_trait]
impl EmailSender for SmtpBackend {
async fn send(&self, email: Email) -> Result<(), EmailError> {
let message = Self::build_message(&email)?;
let transport = self.create_transport()?;
transport
.send(message)
.await
.map_err(|e| EmailError::smtp(e.to_string()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_smtp_config_from_env() {
std::env::set_var("SMTP_HOST", "smtp.example.com");
std::env::set_var("SMTP_PORT", "587");
std::env::set_var("SMTP_USERNAME", "user@example.com");
std::env::set_var("SMTP_PASSWORD", "password123");
std::env::set_var("SMTP_USE_TLS", "true");
let config = SmtpConfig::from_env().unwrap();
assert_eq!(config.host, "smtp.example.com");
assert_eq!(config.port, 587);
assert_eq!(config.username, "user@example.com");
assert_eq!(config.password, "password123");
assert!(config.use_tls);
}
#[test]
fn test_smtp_config_defaults() {
std::env::remove_var("SMTP_PORT");
std::env::remove_var("SMTP_USE_TLS");
std::env::set_var("SMTP_HOST", "smtp.example.com");
std::env::set_var("SMTP_USERNAME", "user@example.com");
std::env::set_var("SMTP_PASSWORD", "password123");
let config = SmtpConfig::from_env().unwrap();
assert_eq!(config.port, 587); assert!(config.use_tls); }
#[test]
fn test_build_message_simple() {
let email = Email::new()
.to("recipient@example.com")
.from("sender@example.com")
.subject("Test Email")
.text("This is a test email");
let message = SmtpBackend::build_message(&email);
assert!(message.is_ok());
}
#[test]
fn test_build_message_with_html_and_text() {
let email = Email::new()
.to("recipient@example.com")
.from("sender@example.com")
.subject("Test Email")
.text("This is plain text")
.html("<h1>This is HTML</h1>");
let message = SmtpBackend::build_message(&email);
assert!(message.is_ok());
}
#[test]
fn test_build_message_with_cc_and_bcc() {
let email = Email::new()
.to("recipient@example.com")
.cc("cc@example.com")
.bcc("bcc@example.com")
.from("sender@example.com")
.subject("Test Email")
.text("Test content");
let message = SmtpBackend::build_message(&email);
assert!(message.is_ok());
}
}