use std::sync::{Arc, Mutex};
use async_trait::async_trait;
#[cfg(feature = "email-smtp")]
pub mod smtp;
#[cfg(feature = "email-smtp")]
pub use smtp::{SmtpMailer, SmtpMailerBuilder, TlsMode};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct Email {
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub from: Option<String>,
pub reply_to: Option<String>,
pub subject: String,
pub body: String,
pub html_body: Option<String>,
pub headers: Vec<(String, String)>,
}
impl Email {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn to(mut self, addr: impl Into<String>) -> Self {
self.to.push(addr.into());
self
}
#[must_use]
pub fn cc(mut self, addr: impl Into<String>) -> Self {
self.cc.push(addr.into());
self
}
#[must_use]
pub fn bcc(mut self, addr: impl Into<String>) -> Self {
self.bcc.push(addr.into());
self
}
#[must_use]
pub fn from(mut self, addr: impl Into<String>) -> Self {
self.from = Some(addr.into());
self
}
#[must_use]
pub fn reply_to(mut self, addr: impl Into<String>) -> Self {
self.reply_to = Some(addr.into());
self
}
#[must_use]
pub fn subject(mut self, s: impl Into<String>) -> Self {
self.subject = s.into();
self
}
#[must_use]
pub fn body(mut self, b: impl Into<String>) -> Self {
self.body = b.into();
self
}
#[must_use]
pub fn html_body(mut self, b: impl Into<String>) -> Self {
self.html_body = Some(b.into());
self
}
#[must_use]
pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((key.into(), value.into()));
self
}
pub fn validate(&self) -> Result<(), MailError> {
if self.to.is_empty() && self.cc.is_empty() && self.bcc.is_empty() {
return Err(MailError::InvalidMessage("no recipients".into()));
}
if self.subject.is_empty() {
return Err(MailError::InvalidMessage("subject is empty".into()));
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum MailError {
#[error("invalid message: {0}")]
InvalidMessage(String),
#[error("transport error: {0}")]
Transport(String),
}
#[async_trait]
pub trait Mailer: Send + Sync + 'static {
async fn send(&self, email: &Email) -> Result<(), MailError>;
}
pub type BoxedMailer = Arc<dyn Mailer>;
#[derive(Default)]
pub struct ConsoleMailer;
#[async_trait]
impl Mailer for ConsoleMailer {
async fn send(&self, email: &Email) -> Result<(), MailError> {
email.validate()?;
println!("============= [ConsoleMailer] outgoing =============");
if let Some(f) = &email.from {
println!("From: {f}");
}
if !email.to.is_empty() {
println!("To: {}", email.to.join(", "));
}
if !email.cc.is_empty() {
println!("Cc: {}", email.cc.join(", "));
}
if !email.bcc.is_empty() {
println!("Bcc: {}", email.bcc.join(", "));
}
if let Some(rt) = &email.reply_to {
println!("Reply-To: {rt}");
}
println!("Subject: {}", email.subject);
for (k, v) in &email.headers {
println!("{k}: {v}");
}
println!();
println!("{}", email.body);
if let Some(html) = &email.html_body {
println!("\n--- HTML alternative ---\n{html}");
}
println!("====================================================");
Ok(())
}
}
#[derive(Default)]
pub struct InMemoryMailer {
sent: Mutex<Vec<Email>>,
}
impl InMemoryMailer {
#[must_use]
pub fn new() -> Self {
Self {
sent: Mutex::new(Vec::new()),
}
}
#[must_use]
pub fn sent(&self) -> Vec<Email> {
self.sent.lock().expect("sent mutex poisoned").clone()
}
#[must_use]
pub fn count(&self) -> usize {
self.sent.lock().expect("sent mutex poisoned").len()
}
pub fn clear(&self) {
self.sent.lock().expect("sent mutex poisoned").clear();
}
}
#[async_trait]
impl Mailer for InMemoryMailer {
async fn send(&self, email: &Email) -> Result<(), MailError> {
email.validate()?;
self.sent
.lock()
.expect("sent mutex poisoned")
.push(email.clone());
Ok(())
}
}
pub struct FileMailer {
dir: std::path::PathBuf,
seq: std::sync::atomic::AtomicU64,
}
impl FileMailer {
#[must_use]
pub fn new(dir: impl Into<std::path::PathBuf>) -> Self {
Self {
dir: dir.into(),
seq: std::sync::atomic::AtomicU64::new(0),
}
}
#[must_use]
pub fn dir(&self) -> &std::path::Path {
&self.dir
}
}
fn serialize_eml(email: &Email) -> String {
let mut out = String::with_capacity(256 + email.body.len());
if let Some(f) = &email.from {
out.push_str("From: ");
out.push_str(f);
out.push('\n');
}
if !email.to.is_empty() {
out.push_str("To: ");
out.push_str(&email.to.join(", "));
out.push('\n');
}
if !email.cc.is_empty() {
out.push_str("Cc: ");
out.push_str(&email.cc.join(", "));
out.push('\n');
}
if !email.bcc.is_empty() {
out.push_str("Bcc: ");
out.push_str(&email.bcc.join(", "));
out.push('\n');
}
if let Some(rt) = &email.reply_to {
out.push_str("Reply-To: ");
out.push_str(rt);
out.push('\n');
}
out.push_str("Subject: ");
out.push_str(&email.subject);
out.push('\n');
for (k, v) in &email.headers {
out.push_str(k);
out.push_str(": ");
out.push_str(v);
out.push('\n');
}
out.push('\n');
out.push_str(&email.body);
if let Some(html) = &email.html_body {
out.push_str("\n\n--- HTML alternative ---\n");
out.push_str(html);
}
out
}
#[async_trait]
impl Mailer for FileMailer {
async fn send(&self, email: &Email) -> Result<(), MailError> {
email.validate()?;
std::fs::create_dir_all(&self.dir)
.map_err(|e| MailError::Transport(format!("create_dir_all: {e}")))?;
let seq = self.seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
let name = format!("{stamp}-{seq:04}.eml");
let path = self.dir.join(name);
std::fs::write(&path, serialize_eml(email))
.map_err(|e| MailError::Transport(format!("write {}: {e}", path.display())))?;
Ok(())
}
}
#[derive(Default)]
pub struct NullMailer;
#[async_trait]
impl Mailer for NullMailer {
async fn send(&self, email: &Email) -> Result<(), MailError> {
email.validate()?;
Ok(())
}
}
#[cfg(feature = "config")]
pub async fn mail_admins(
mailer: &dyn Mailer,
s: &crate::config::MailSettings,
subject: impl Into<String>,
body: impl Into<String>,
) -> Result<usize, MailError> {
send_to_list(
mailer,
s,
&s.admins,
"[admin] ",
subject.into(),
body.into(),
)
.await
}
#[cfg(feature = "config")]
pub async fn mail_managers(
mailer: &dyn Mailer,
s: &crate::config::MailSettings,
subject: impl Into<String>,
body: impl Into<String>,
) -> Result<usize, MailError> {
send_to_list(
mailer,
s,
&s.managers,
"[manager] ",
subject.into(),
body.into(),
)
.await
}
#[cfg(feature = "config")]
async fn send_to_list(
mailer: &dyn Mailer,
s: &crate::config::MailSettings,
list: &[String],
prefix: &str,
subject: String,
body: String,
) -> Result<usize, MailError> {
if list.is_empty() {
return Ok(0);
}
let mut email = Email::new()
.subject(format!("{prefix}{subject}"))
.body(body);
if let Some(from) = s.from_address.as_deref() {
email = email.from(from.to_owned());
}
for addr in list {
email = email.to(addr.clone());
}
mailer.send(&email).await?;
Ok(list.len())
}
#[cfg(feature = "config")]
#[must_use]
pub fn from_settings(s: &crate::config::MailSettings) -> BoxedMailer {
match s.backend.as_deref() {
Some("smtp") => smtp_from_settings_or_warn(s),
Some("memory") => Arc::new(InMemoryMailer::new()),
Some("null" | "none") => Arc::new(NullMailer),
Some("file") => file_from_settings_or_warn(s),
Some("console") | None => Arc::new(ConsoleMailer),
Some(other) => {
tracing::warn!(
target: "rustango::email",
backend = %other,
"unknown mail.backend value; falling back to ConsoleMailer",
);
Arc::new(ConsoleMailer)
}
}
}
#[cfg(feature = "config")]
fn file_from_settings_or_warn(s: &crate::config::MailSettings) -> BoxedMailer {
match s.file_email_dir.as_deref() {
Some(dir) => Arc::new(FileMailer::new(dir)),
None => {
tracing::warn!(
target: "rustango::email",
"mail.backend = \"file\" but [mail].file_email_dir is unset; \
falling back to ConsoleMailer.",
);
Arc::new(ConsoleMailer)
}
}
}
#[cfg(all(feature = "config", feature = "email-smtp"))]
fn smtp_from_settings_or_warn(s: &crate::config::MailSettings) -> BoxedMailer {
match smtp::from_settings(s) {
Ok(Some(m)) => m,
Ok(None) => {
tracing::warn!(
target: "rustango::email",
"mail.backend = \"smtp\" but [mail].smtp_host is unset; falling back to ConsoleMailer."
);
Arc::new(ConsoleMailer)
}
Err(e) => {
tracing::warn!(
target: "rustango::email",
error = %e,
"mail.backend = \"smtp\" but SmtpMailer build failed; falling back to ConsoleMailer."
);
Arc::new(ConsoleMailer)
}
}
}
#[cfg(all(feature = "config", not(feature = "email-smtp")))]
fn smtp_from_settings_or_warn(_s: &crate::config::MailSettings) -> BoxedMailer {
tracing::warn!(
target: "rustango::email",
"mail.backend = \"smtp\" but the `email-smtp` feature isn't enabled in this build; \
falling back to ConsoleMailer. Enable `email-smtp` to ship a real SMTP transport.",
);
Arc::new(ConsoleMailer)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn email_builder_chains() {
let e = Email::new()
.to("a@x.com")
.to("b@x.com")
.from("noreply@my.app")
.subject("hi")
.body("hello");
assert_eq!(e.to, vec!["a@x.com", "b@x.com"]);
assert_eq!(e.from.as_deref(), Some("noreply@my.app"));
assert_eq!(e.subject, "hi");
}
#[tokio::test]
async fn validate_rejects_no_recipients() {
let e = Email::new().subject("x").body("y");
assert!(matches!(e.validate(), Err(MailError::InvalidMessage(_))));
}
#[tokio::test]
async fn validate_rejects_empty_subject() {
let e = Email::new().to("x@y.com").body("z");
assert!(matches!(e.validate(), Err(MailError::InvalidMessage(_))));
}
#[tokio::test]
async fn in_memory_mailer_captures_sent() {
let m = InMemoryMailer::new();
m.send(&Email::new().to("a@x").subject("s").body("b"))
.await
.unwrap();
m.send(&Email::new().to("b@x").subject("s2").body("b2"))
.await
.unwrap();
assert_eq!(m.count(), 2);
assert_eq!(m.sent()[0].to, vec!["a@x"]);
m.clear();
assert_eq!(m.count(), 0);
}
#[tokio::test]
async fn null_mailer_succeeds_silently() {
let m = NullMailer;
m.send(&Email::new().to("x@y").subject("s").body("b"))
.await
.unwrap();
}
#[tokio::test]
async fn null_mailer_still_validates() {
let m = NullMailer;
let result = m.send(&Email::new().subject("s")).await;
assert!(result.is_err());
}
#[cfg(feature = "config")]
#[tokio::test]
async fn from_settings_memory_backend_captures_send() {
let mut s = crate::config::MailSettings::default();
s.backend = Some("memory".into());
let m = from_settings(&s);
let email = Email::new()
.to("a@x.com")
.from("noreply@x.com")
.subject("hi")
.body("body");
m.send(&email).await.expect("send ok");
}
#[cfg(feature = "config")]
#[tokio::test]
async fn from_settings_null_backend_drops_send() {
let mut s = crate::config::MailSettings::default();
s.backend = Some("null".into());
let m = from_settings(&s);
let email = Email::new()
.to("a@x.com")
.from("noreply@x.com")
.subject("hi")
.body("body");
m.send(&email)
.await
.expect("null backend never errors on valid email");
}
#[cfg(feature = "config")]
#[tokio::test]
async fn from_settings_unset_falls_back_to_console() {
let s = crate::config::MailSettings::default();
let m = from_settings(&s);
let email = Email::new()
.to("a@x.com")
.from("noreply@x.com")
.subject("hi")
.body("body");
m.send(&email).await.expect("console mailer ok");
}
#[cfg(feature = "config")]
#[tokio::test]
async fn from_settings_smtp_builds_mailer_when_host_given() {
let mut s = crate::config::MailSettings::default();
s.backend = Some("smtp".into());
s.smtp_host = Some("mail.example.com".into());
s.smtp_tls = Some("starttls".into());
let m = from_settings(&s);
drop(m);
}
}