use crate::error::{Result, TidewayError};
use crate::traits::mailer::{Email, Mailer};
use async_trait::async_trait;
use lettre::{
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
message::{Mailbox, MultiPart, SinglePart, header::ContentType},
transport::smtp::authentication::Credentials,
};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub default_from: Option<String>,
pub starttls: bool,
}
impl SmtpConfig {
pub fn new(host: impl Into<String>) -> Self {
Self {
host: host.into(),
port: 587,
username: None,
password: None,
default_from: None,
starttls: true,
}
}
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
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 from(mut self, address: impl Into<String>) -> Self {
self.default_from = Some(address.into());
self
}
pub fn no_starttls(mut self) -> Self {
self.starttls = false;
self
}
pub fn from_env() -> Result<Self> {
let host = std::env::var("SMTP_HOST")
.map_err(|_| TidewayError::internal("SMTP_HOST environment variable not set"))?;
let port = std::env::var("SMTP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(587);
let username = std::env::var("SMTP_USERNAME").ok();
let password = std::env::var("SMTP_PASSWORD").ok();
let default_from = std::env::var("SMTP_FROM").ok();
let starttls = std::env::var("SMTP_STARTTLS")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
Ok(Self {
host,
port,
username,
password,
default_from,
starttls,
})
}
}
pub struct SmtpMailer {
transport: Arc<RwLock<AsyncSmtpTransport<Tokio1Executor>>>,
config: SmtpConfig,
}
impl SmtpMailer {
pub fn new(config: SmtpConfig) -> Result<Self> {
let mut builder = if config.starttls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host).map_err(|e| {
TidewayError::internal(format!("Failed to create SMTP transport: {}", e))
})?
} else {
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host).map_err(|e| {
TidewayError::internal(format!("Failed to create SMTP transport: {}", e))
})?
};
builder = builder.port(config.port);
if let (Some(username), Some(password)) = (&config.username, &config.password) {
let credentials = Credentials::new(username.clone(), password.clone());
builder = builder.credentials(credentials);
}
let transport = builder.build();
Ok(Self {
transport: Arc::new(RwLock::new(transport)),
config,
})
}
pub fn from_env() -> Result<Self> {
let config = SmtpConfig::from_env()?;
Self::new(config)
}
fn build_message(&self, email: &Email) -> Result<Message> {
let from_str = if email.from.is_empty() {
self.config.default_from.as_ref().ok_or_else(|| {
TidewayError::bad_request("No 'from' address specified and no default configured")
})?
} else {
&email.from
};
let from: Mailbox = from_str
.parse()
.map_err(|e| TidewayError::bad_request(format!("Invalid 'from' address: {}", e)))?;
let mut builder = Message::builder().from(from).subject(&email.subject);
for to in &email.to {
let mailbox: Mailbox = to.parse().map_err(|e| {
TidewayError::bad_request(format!("Invalid 'to' address '{}': {}", to, e))
})?;
builder = builder.to(mailbox);
}
for cc in &email.cc {
let mailbox: Mailbox = cc.parse().map_err(|e| {
TidewayError::bad_request(format!("Invalid 'cc' address '{}': {}", cc, e))
})?;
builder = builder.cc(mailbox);
}
for bcc in &email.bcc {
let mailbox: Mailbox = bcc.parse().map_err(|e| {
TidewayError::bad_request(format!("Invalid 'bcc' address '{}': {}", bcc, e))
})?;
builder = builder.bcc(mailbox);
}
if let Some(ref reply_to) = email.reply_to {
let mailbox: Mailbox = reply_to.parse().map_err(|e| {
TidewayError::bad_request(format!("Invalid 'reply_to' address: {}", e))
})?;
builder = builder.reply_to(mailbox);
}
let message = match (&email.text, &email.html) {
(Some(text), Some(html)) => {
builder
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(text.clone()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html.clone()),
),
)
.map_err(|e| TidewayError::internal(format!("Failed to build email: {}", e)))?
}
(Some(text), None) => {
builder
.header(ContentType::TEXT_PLAIN)
.body(text.clone())
.map_err(|e| TidewayError::internal(format!("Failed to build email: {}", e)))?
}
(None, Some(html)) => {
builder
.header(ContentType::TEXT_HTML)
.body(html.clone())
.map_err(|e| TidewayError::internal(format!("Failed to build email: {}", e)))?
}
(None, None) => {
return Err(TidewayError::bad_request(
"Email must have either text or HTML body",
));
}
};
Ok(message)
}
}
#[async_trait]
impl Mailer for SmtpMailer {
async fn send(&self, email: &Email) -> Result<()> {
email.validate()?;
let message = self.build_message(email)?;
let transport = self.transport.read().await;
transport
.send(message)
.await
.map_err(|e| TidewayError::internal(format!("Failed to send email: {}", e)))?;
Ok(())
}
fn is_healthy(&self) -> bool {
true
}
}
impl std::fmt::Debug for SmtpMailer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SmtpMailer")
.field("host", &self.config.host)
.field("port", &self.config.port)
.finish()
}
}