use std::sync::Arc;
use async_trait::async_trait;
use lettre::message::{header::ContentType, Mailbox, Message, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::Tls;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use super::{Email, MailError, Mailer};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TlsMode {
None,
#[default]
StartTls,
Implicit,
}
impl TlsMode {
#[must_use]
pub fn from_str_loose(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"none" | "plain" | "off" => Self::None,
"implicit" | "smtps" | "tls" => Self::Implicit,
"starttls" | "" => Self::StartTls,
_ => {
tracing::warn!(
target: "rustango::email::smtp",
given = %s,
"unknown smtp.tls value; falling back to starttls"
);
Self::StartTls
}
}
}
#[must_use]
pub fn default_port(self) -> u16 {
match self {
Self::None => 25,
Self::StartTls => 587,
Self::Implicit => 465,
}
}
}
pub struct SmtpMailer {
transport: AsyncSmtpTransport<Tokio1Executor>,
default_from: Option<Mailbox>,
}
impl SmtpMailer {
#[must_use]
pub fn builder(host: impl Into<String>) -> SmtpMailerBuilder {
SmtpMailerBuilder {
host: host.into(),
port: None,
credentials: None,
tls: TlsMode::default(),
default_from: None,
}
}
#[must_use]
pub fn with_transport(transport: AsyncSmtpTransport<Tokio1Executor>) -> Self {
Self {
transport,
default_from: None,
}
}
pub fn default_from(mut self, from: &str) -> Result<Self, MailError> {
self.default_from = Some(parse_mailbox(from)?);
Ok(self)
}
}
#[must_use = "call .build() to construct the SmtpMailer"]
pub struct SmtpMailerBuilder {
host: String,
port: Option<u16>,
credentials: Option<(String, String)>,
tls: TlsMode,
default_from: Option<String>,
}
impl SmtpMailerBuilder {
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.credentials = Some((username.into(), password.into()));
self
}
pub fn tls(mut self, mode: TlsMode) -> Self {
self.tls = mode;
self
}
pub fn default_from(mut self, from: impl Into<String>) -> Self {
self.default_from = Some(from.into());
self
}
pub fn build(self) -> Result<SmtpMailer, MailError> {
let port = self.port.unwrap_or_else(|| self.tls.default_port());
let mut builder = match self.tls {
TlsMode::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.host),
TlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.host)
.map_err(|e| {
MailError::Transport(format!("starttls_relay({}): {e}", self.host))
})?,
TlsMode::Implicit => AsyncSmtpTransport::<Tokio1Executor>::relay(&self.host)
.map_err(|e| MailError::Transport(format!("relay({}): {e}", self.host)))?,
}
.port(port);
if let Some((user, pass)) = self.credentials {
builder = builder.credentials(Credentials::new(user, pass));
}
if matches!(self.tls, TlsMode::None) {
builder = builder.tls(Tls::None);
}
let transport = builder.build();
let default_from = match self.default_from {
Some(addr) => Some(parse_mailbox(&addr)?),
None => None,
};
Ok(SmtpMailer {
transport,
default_from,
})
}
}
#[async_trait]
impl Mailer for SmtpMailer {
async fn send(&self, email: &Email) -> Result<(), MailError> {
email.validate()?;
let from = match email.from.as_deref() {
Some(addr) => parse_mailbox(addr)?,
None => self.default_from.clone().ok_or_else(|| {
MailError::InvalidMessage(
"Email has no `from`, and SmtpMailer has no default_from".into(),
)
})?,
};
let mut builder = Message::builder().from(from);
for to in &email.to {
builder = builder.to(parse_mailbox(to)?);
}
for cc in &email.cc {
builder = builder.cc(parse_mailbox(cc)?);
}
for bcc in &email.bcc {
builder = builder.bcc(parse_mailbox(bcc)?);
}
if let Some(rt) = &email.reply_to {
builder = builder.reply_to(parse_mailbox(rt)?);
}
builder = builder.subject(&email.subject);
let body_part = if let Some(html) = &email.html_body {
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(email.body.clone()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html.clone()),
)
} else {
MultiPart::mixed().singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(email.body.clone()),
)
};
if !email.headers.is_empty() {
tracing::warn!(
target: "rustango::email::smtp",
count = email.headers.len(),
"Email.headers ignored — SmtpMailer v1 only forwards the standard envelope; \
custom headers will land in a follow-up slice."
);
}
let message = builder
.multipart(body_part)
.map_err(|e| MailError::InvalidMessage(format!("message build: {e}")))?;
self.transport
.send(message)
.await
.map_err(|e| MailError::Transport(format!("smtp send: {e}")))?;
Ok(())
}
}
fn parse_mailbox(addr: &str) -> Result<Mailbox, MailError> {
addr.parse::<Mailbox>()
.map_err(|e| MailError::InvalidMessage(format!("bad address `{addr}`: {e}")))
}
#[cfg(feature = "config")]
pub fn from_settings(
s: &crate::config::MailSettings,
) -> Result<Option<super::BoxedMailer>, MailError> {
let Some(host) = s.smtp_host.as_deref() else {
return Ok(None);
};
let tls = s
.smtp_tls
.as_deref()
.map_or(TlsMode::default(), TlsMode::from_str_loose);
let mut b = SmtpMailer::builder(host).tls(tls);
if let Some(port) = s.smtp_port {
b = b.port(port);
}
if let (Some(u), Some(p)) = (s.smtp_username.as_deref(), s.smtp_password.as_deref()) {
b = b.credentials(u, p);
}
if let Some(addr) = s.from_address.as_deref() {
b = b.default_from(addr);
}
Ok(Some(Arc::new(b.build()?)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tls_mode_from_str_handles_synonyms() {
assert_eq!(TlsMode::from_str_loose("starttls"), TlsMode::StartTls);
assert_eq!(TlsMode::from_str_loose("STARTTLS"), TlsMode::StartTls);
assert_eq!(TlsMode::from_str_loose("none"), TlsMode::None);
assert_eq!(TlsMode::from_str_loose("off"), TlsMode::None);
assert_eq!(TlsMode::from_str_loose("plain"), TlsMode::None);
assert_eq!(TlsMode::from_str_loose("implicit"), TlsMode::Implicit);
assert_eq!(TlsMode::from_str_loose("smtps"), TlsMode::Implicit);
assert_eq!(TlsMode::from_str_loose(""), TlsMode::StartTls);
assert_eq!(TlsMode::from_str_loose("nope"), TlsMode::StartTls);
}
#[test]
fn tls_mode_default_port_matches_conventions() {
assert_eq!(TlsMode::None.default_port(), 25);
assert_eq!(TlsMode::StartTls.default_port(), 587);
assert_eq!(TlsMode::Implicit.default_port(), 465);
}
#[test]
fn builder_sets_default_from() {
let mailer = SmtpMailer::builder("localhost")
.port(2525)
.tls(TlsMode::None)
.default_from("noreply@example.com")
.build()
.expect("build ok");
assert!(mailer.default_from.is_some());
}
#[test]
fn builder_rejects_unparseable_default_from() {
let r = SmtpMailer::builder("localhost")
.tls(TlsMode::None)
.default_from("not a real address")
.build();
assert!(matches!(r, Err(MailError::InvalidMessage(_))));
}
#[cfg(feature = "config")]
#[tokio::test]
async fn from_settings_returns_none_without_host() {
let s = crate::config::MailSettings::default();
let r = from_settings(&s).expect("ok");
assert!(r.is_none(), "no smtp_host → None, caller falls back");
}
#[cfg(feature = "config")]
#[tokio::test]
async fn from_settings_builds_with_host_and_creds() {
let mut s = crate::config::MailSettings::default();
s.smtp_host = Some("localhost".into());
s.smtp_port = Some(2525);
s.smtp_tls = Some("none".into());
s.smtp_username = Some("user".into());
s.smtp_password = Some("pass".into());
s.from_address = Some("noreply@example.com".into());
let m = from_settings(&s).expect("ok").expect("some");
drop(m);
}
}