use async_trait::async_trait;
use lettre::{
AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
transport::smtp::authentication::Credentials,
};
use std::time::Duration;
use tracing::{debug, info};
use crate::{Email, MailError, Result};
#[async_trait]
pub trait Transport: Send + Sync {
async fn send(&self, email: &Email) -> Result<()>;
async fn is_healthy(&self) -> bool {
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SmtpSecurity {
None,
#[default]
StartTls,
Tls,
}
#[derive(Debug, Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub security: SmtpSecurity,
pub username: Option<String>,
pub password: Option<String>,
pub timeout: Duration,
pub pool_size: u32,
}
impl SmtpConfig {
pub fn new(host: impl Into<String>) -> Self {
Self {
host: host.into(),
port: 587,
security: SmtpSecurity::StartTls,
username: None,
password: None,
timeout: Duration::from_secs(30),
pool_size: 4,
}
}
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 port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn starttls(mut self) -> Self {
self.security = SmtpSecurity::StartTls;
self.port = 587;
self
}
pub fn tls(mut self) -> Self {
self.security = SmtpSecurity::Tls;
self.port = 465;
self
}
pub fn insecure(mut self) -> Self {
self.security = SmtpSecurity::None;
self.port = 25;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn pool_size(mut self, size: u32) -> Self {
self.pool_size = size;
self
}
pub fn gmail(username: impl Into<String>, password: impl Into<String>) -> Self {
Self::new("smtp.gmail.com")
.port(587)
.starttls()
.credentials(username, password)
}
pub fn outlook(username: impl Into<String>, password: impl Into<String>) -> Self {
Self::new("smtp.office365.com")
.port(587)
.starttls()
.credentials(username, password)
}
pub fn amazon_ses(
region: &str,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self::new(format!("email-smtp.{}.amazonaws.com", region))
.port(587)
.starttls()
.credentials(username, password)
}
pub fn mailgun(domain: &str, api_key: impl Into<String>) -> Self {
Self::new("smtp.mailgun.org")
.port(587)
.starttls()
.credentials(format!("postmaster@{}", domain), api_key)
}
pub fn sendgrid(api_key: impl Into<String>) -> Self {
Self::new("smtp.sendgrid.net")
.port(587)
.starttls()
.credentials("apikey", api_key)
}
pub fn postmark(api_key: impl Into<String>) -> Self {
let key = api_key.into();
Self::new("smtp.postmarkapp.com")
.port(587)
.starttls()
.credentials(key.clone(), key)
}
}
pub struct SmtpTransport {
transport: AsyncSmtpTransport<Tokio1Executor>,
config: SmtpConfig,
}
impl SmtpTransport {
pub async fn new(config: SmtpConfig) -> Result<Self> {
let mut builder = match config.security {
SmtpSecurity::None => {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host)
}
SmtpSecurity::StartTls => {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)?
}
SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)?,
};
builder = builder.port(config.port).timeout(Some(config.timeout));
if let (Some(username), Some(password)) = (&config.username, &config.password) {
builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
}
let transport = builder.build();
info!(
host = %config.host,
port = config.port,
security = ?config.security,
"SMTP transport initialized"
);
Ok(Self { transport, config })
}
pub fn config(&self) -> &SmtpConfig {
&self.config
}
pub async fn test_connection(&self) -> Result<bool> {
self.transport
.test_connection()
.await
.map_err(MailError::from)
}
}
#[async_trait]
impl Transport for SmtpTransport {
async fn send(&self, email: &Email) -> Result<()> {
let message = email.to_lettre()?;
debug!(
to = ?email.to.iter().map(|a| &a.email).collect::<Vec<_>>(),
subject = ?email.subject,
"Sending email via SMTP"
);
self.transport.send(message).await?;
debug!("Email sent successfully");
Ok(())
}
async fn is_healthy(&self) -> bool {
self.test_connection().await.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_smtp_config_builder() {
let config = SmtpConfig::new("smtp.example.com")
.port(587)
.starttls()
.credentials("user", "pass");
assert_eq!(config.host, "smtp.example.com");
assert_eq!(config.port, 587);
assert_eq!(config.security, SmtpSecurity::StartTls);
assert_eq!(config.username.as_deref(), Some("user"));
}
#[test]
fn test_provider_configs() {
let gmail = SmtpConfig::gmail("user@gmail.com", "pass");
assert_eq!(gmail.host, "smtp.gmail.com");
let sendgrid = SmtpConfig::sendgrid("api-key");
assert_eq!(sendgrid.host, "smtp.sendgrid.net");
assert_eq!(sendgrid.username.as_deref(), Some("apikey"));
}
}