use lettre::{
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, message::header::ContentType,
transport::smtp::authentication::Credentials,
};
use std::{env::var, sync::OnceLock};
#[derive(Debug, Clone, Default)]
pub enum MailerBackend {
#[default]
Smtp,
Console,
}
pub static MAILER_CONFIG: OnceLock<MailerConfig> = OnceLock::new();
#[derive(Clone)]
pub struct MailerConfig {
pub backend: MailerBackend,
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub from: String,
pub starttls: bool,
}
impl std::fmt::Debug for MailerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MailerConfig")
.field("backend", &self.backend)
.field("host", &self.host)
.field("port", &self.port)
.field("username", &self.username)
.field("password", &"***")
.field("from", &self.from)
.field("starttls", &self.starttls)
.finish()
}
}
impl MailerConfig {
pub fn from_env() -> Option<Self> {
let backend = match crate::utils::config::env::env_keyword("EMAIL_BACKEND")
.as_deref()
.unwrap_or("smtp")
{
"console" => MailerBackend::Console,
_ => MailerBackend::Smtp,
};
match backend {
MailerBackend::Console => Some(Self {
backend: MailerBackend::Console,
host: String::new(),
port: 0,
username: String::new(),
password: String::new(),
from: var("SMTP_FROM").unwrap_or_else(|_| "noreply@localhost".to_string()),
starttls: false,
}),
MailerBackend::Smtp => {
let host = var("SMTP_HOST").ok()?;
let username = var("SMTP_USER").ok()?;
let password = var("SMTP_PASS").ok()?;
let from = var("SMTP_FROM").unwrap_or_else(|_| username.clone());
let port = var("SMTP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(587);
let starttls = var("SMTP_STARTTLS").map(|v| v == "true").unwrap_or(true);
Some(Self {
backend: MailerBackend::Smtp,
host,
port,
username,
password,
from,
starttls,
})
}
}
}
}
pub fn mailer_init(config: MailerConfig) {
MAILER_CONFIG.set(config).ok();
}
pub fn mailer_init_from_env() {
if let Some(config) = MailerConfig::from_env() {
mailer_init(config);
}
}
pub fn mailer_configured() -> bool {
MAILER_CONFIG.get().is_some()
}
pub struct Email {
to: String,
subject: String,
html: Option<String>,
text: Option<String>,
reply_to: Option<String>,
}
impl Email {
pub fn new() -> Self {
Self {
to: String::new(),
subject: String::new(),
html: None,
text: None,
reply_to: None,
}
}
pub fn to(mut self, address: impl Into<String>) -> Self {
self.to = address.into();
self
}
pub fn subject(mut self, subject: impl Into<String>) -> Self {
self.subject = subject.into();
self
}
pub fn html(mut self, body: impl Into<String>) -> Self {
self.html = Some(body.into());
self
}
pub fn text(mut self, body: impl Into<String>) -> Self {
self.text = Some(body.into());
self
}
pub fn reply_to(mut self, address: impl Into<String>) -> Self {
self.reply_to = Some(address.into());
self
}
pub fn template(
mut self,
tera: &tera::Tera,
template_name: &str,
ctx: tera::Context,
) -> Result<Self, String> {
let rendered = tera
.render(template_name, &ctx)
.map_err(|e| format!("Template error ({template_name}): {e}"))?;
self.html = Some(rendered);
Ok(self)
}
pub async fn send(self) -> Result<(), String> {
let config = MAILER_CONFIG.get().ok_or_else(|| {
if let Some(level) = crate::utils::runique_log::get_log()
.mailer
.as_ref()
.and_then(|m| m.send)
{
crate::runique_log!(level, to = %self.to, subject = %self.subject, "send — mailer not configured");
}
"Mailer not configured — call .with_mailer_from_env() in the builder or set EMAIL_BACKEND/SMTP_* in .env"
.to_string()
})?;
if self.html.is_none() && self.text.is_none() {
return Err("Email without content".to_string());
}
let backend = match config.backend {
MailerBackend::Console => "console",
MailerBackend::Smtp => "smtp",
};
if let Some(level) = crate::utils::runique_log::get_log()
.mailer
.as_ref()
.and_then(|m| m.send)
{
crate::runique_log!(level, to = %self.to, subject = %self.subject, backend, "send");
}
let result = match config.backend {
MailerBackend::Console => self.send_console(config),
MailerBackend::Smtp => self.send_smtp(config).await,
};
if let Err(ref e) = result
&& let Some(level) = crate::utils::runique_log::get_log()
.mailer
.as_ref()
.and_then(|m| m.send)
{
crate::runique_log!(level, error = %e, backend, "send error");
}
result
}
fn send_console(self, config: &MailerConfig) -> Result<(), String> {
let body = self
.html
.as_deref()
.or(self.text.as_deref())
.unwrap_or("(no content)");
println!(
"\n{}\n From: {}\n To: {}\n Subject: {}{}\n\n{}\n{}",
"─".repeat(60),
config.from,
self.to,
self.subject,
self.reply_to
.as_deref()
.map(|r| format!("\n Reply-To: {r}"))
.unwrap_or_default(),
body,
"─".repeat(60),
);
Ok(())
}
async fn send_smtp(self, config: &MailerConfig) -> Result<(), String> {
let from = config
.from
.parse::<lettre::message::Mailbox>()
.map_err(|e| format!("Invalid sender address: {e}"))?;
let to = self
.to
.parse::<lettre::message::Mailbox>()
.map_err(|e| format!("Invalid recipient address: {e}"))?;
let mut builder = Message::builder().from(from).to(to).subject(self.subject);
if let Some(reply_to) = &self.reply_to {
let rt = reply_to
.parse::<lettre::message::Mailbox>()
.map_err(|e| format!("Invalid Reply-To: {e}"))?;
builder = builder.reply_to(rt);
}
let message = if let Some(html) = self.html {
builder
.header(ContentType::TEXT_HTML)
.body(html)
.map_err(|e| format!("Error constructing email: {e}"))?
} else if let Some(text) = self.text {
builder
.header(ContentType::TEXT_PLAIN)
.body(text)
.map_err(|e| format!("Error constructing email: {e}"))?
} else {
return Err("Email without content".to_string());
};
let creds = Credentials::new(config.username.clone(), config.password.clone());
let transport = if config.starttls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
.map_err(|e| format!("SMTP connection failed: {e}"))?
.port(config.port)
.credentials(creds)
.build()
} else {
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
.map_err(|e| format!("SMTP connection failed: {e}"))?
.port(config.port)
.credentials(creds)
.build()
};
transport
.send(message)
.await
.map_err(|e| format!("Error sending email: {e}"))?;
Ok(())
}
}
impl Default for Email {
fn default() -> Self {
Self::new()
}
}
pub async fn dispatch_email(to: &str, subject: &str, body: &str) -> Result<(), String> {
Email::new().to(to).subject(subject).text(body).send().await
}
#[cfg(test)]
mod guarantees {
use super::*;
static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn set(vars: &[(&str, Option<&str>)]) {
for (k, v) in vars {
match v {
Some(v) => unsafe { std::env::set_var(k, v) },
None => unsafe { std::env::remove_var(k) },
}
}
}
const SMTP_VARS: [&str; 7] = [
"EMAIL_BACKEND",
"SMTP_HOST",
"SMTP_USER",
"SMTP_PASS",
"SMTP_FROM",
"SMTP_PORT",
"SMTP_STARTTLS",
];
fn clear() {
set(&SMTP_VARS.map(|k| (k, None)));
}
fn smtp_config() -> MailerConfig {
MailerConfig {
backend: MailerBackend::Smtp,
host: "smtp.example".into(),
port: 587,
username: "user".into(),
password: "hunter2-secret".into(),
from: "noreply@example.com".into(),
starttls: true,
}
}
#[test]
fn debug_never_prints_the_smtp_password() {
let shown = format!("{:?}", smtp_config());
assert!(
shown.contains("smtp.example") && shown.contains("***"),
"{shown}"
);
assert!(!shown.contains("hunter2-secret"), "{shown}");
}
#[tokio::test]
async fn from_env_reads_each_backend() {
let _g = ENV_LOCK.lock().await;
clear();
set(&[("EMAIL_BACKEND", Some("console"))]);
let console = MailerConfig::from_env().expect("console needs no credentials");
assert!(matches!(console.backend, MailerBackend::Console));
assert_eq!(console.from, "noreply@localhost");
set(&[
("EMAIL_BACKEND", Some("smtp")),
("SMTP_HOST", Some("smtp.example")),
]);
assert!(
MailerConfig::from_env().is_none(),
"smtp without credentials"
);
set(&[
("SMTP_USER", Some("user@example.com")),
("SMTP_PASS", Some("pw")),
]);
let smtp = MailerConfig::from_env().expect("smtp");
assert!(matches!(smtp.backend, MailerBackend::Smtp));
assert_eq!(
smtp.from, "user@example.com",
"From defaults to the SMTP user"
);
assert_eq!(smtp.port, 587);
assert!(smtp.starttls);
set(&[("SMTP_PORT", Some("465")), ("SMTP_STARTTLS", Some("false"))]);
let smtps = MailerConfig::from_env().unwrap();
assert_eq!(smtps.port, 465);
assert!(!smtps.starttls);
clear();
}
#[test]
fn builders_set_what_they_name() {
let mut tera = tera::Tera::default();
tera.add_raw_template("mail.html", "Bonjour {{ name }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("name", "Ada");
let email = Email::new()
.to("ada@example.com")
.subject("Sujet")
.reply_to("support@example.com")
.template(&tera, "mail.html", ctx)
.unwrap();
assert_eq!(email.to, "ada@example.com");
assert_eq!(email.subject, "Sujet");
assert_eq!(email.reply_to.as_deref(), Some("support@example.com"));
assert_eq!(email.html.as_deref(), Some("Bonjour Ada"));
assert!(
Email::new()
.template(&tera, "missing.html", tera::Context::new())
.is_err()
);
}
#[tokio::test]
async fn smtp_refuses_invalid_addresses_before_connecting() {
let ok = || Email::new().to("ada@example.com").subject("s").text("b");
let err = |e: Email| async move { e.send_smtp(&smtp_config()).await.unwrap_err() };
assert!(err(ok().to("not an address")).await.contains("recipient"));
assert!(err(ok().reply_to("nope")).await.contains("Reply-To"));
let mut bad_from = smtp_config();
bad_from.from = "nope".into();
assert!(
ok().send_smtp(&bad_from)
.await
.unwrap_err()
.contains("sender")
);
let empty = Email::new().to("ada@example.com").subject("s");
assert!(
empty
.send_smtp(&smtp_config())
.await
.unwrap_err()
.contains("content")
);
}
#[tokio::test]
async fn sending_needs_a_configured_mailer() {
let _g = ENV_LOCK.lock().await;
if !mailer_configured() {
assert!(dispatch_email("ada@example.com", "s", "b").await.is_err());
clear();
set(&[("EMAIL_BACKEND", Some("console"))]);
mailer_init_from_env();
clear();
}
assert!(mailer_configured());
assert!(dispatch_email("ada@example.com", "s", "b").await.is_ok());
}
}