use crate::email::Email;
use crate::fake::FakeMail;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::{AsyncFileTransport, AsyncTransport, Tokio1Executor};
use sova_core::{Error, Result};
use std::path::PathBuf;
use std::sync::Arc;
#[cfg(feature = "templates")]
type TemplatesSlot =
Arc<std::sync::RwLock<Option<Arc<sova_templates::MiniJinjaTemplates>>>>;
enum Backend {
Smtp(AsyncSmtpTransport<Tokio1Executor>),
File(AsyncFileTransport<Tokio1Executor>),
Fake(FakeMail),
}
pub struct Mail {
backend: Backend,
default_from: Option<String>,
from_explicit: bool,
fake: Option<FakeMail>,
#[cfg(feature = "templates")]
templates_slot: TemplatesSlot,
}
impl Mail {
fn bare(backend: Backend, default_from: Option<String>, fake: Option<FakeMail>) -> Self {
Self {
backend,
default_from,
from_explicit: false,
fake,
#[cfg(feature = "templates")]
templates_slot: Arc::new(std::sync::RwLock::new(None)),
}
}
pub(crate) fn is_from_explicit(&self) -> bool {
self.from_explicit
}
pub fn smtp(host: impl Into<String>) -> SmtpBuilder {
SmtpBuilder {
host: host.into(),
port: None,
user: None,
pass: None,
from: None,
}
}
pub fn fake() -> Self {
let fake = FakeMail::new();
Self::bare(
Backend::Fake(fake.clone()),
Some("Sova <noreply@localhost>".into()),
Some(fake),
)
}
pub fn file(dir: impl Into<PathBuf>) -> Self {
let dir = dir.into();
let transport = AsyncFileTransport::<Tokio1Executor>::new(dir);
Self::bare(
Backend::File(transport),
Some("Sova <noreply@localhost>".into()),
None,
)
}
pub fn from_env() -> Self {
Self::try_from_env().unwrap_or_else(|e| {
panic!("mail: from_env failed: {e}");
})
}
pub fn try_from_env() -> Result<Self> {
Self::try_from_vars(|k| std::env::var(k).ok().filter(|s| !s.is_empty()))
}
pub fn try_from_vars<F>(mut get: F) -> Result<Self>
where
F: FnMut(&str) -> Option<String>,
{
let from = get("SOVA_MAIL_FROM");
let mailer = get("SOVA_MAIL")
.or_else(|| get("SOVA_MAIL_MAILER"))
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let url = get("SOVA_MAIL_URL").or_else(|| get("SMTP_URL"));
let kind = match mailer.as_deref() {
Some("fake") => "fake",
Some("file") => "file",
Some("smtp") => "smtp",
Some(other) => {
return Err(Error::Internal(format!(
"unknown SOVA_MAIL={other} (use fake|smtp|file)"
)));
}
None if url.is_some() => "smtp",
None => "fake",
};
match kind {
"fake" => {
if mailer.is_none() {
tracing::info!("mail: no SOVA_MAIL / SOVA_MAIL_URL — using fake transport");
}
let mut m = Self::fake();
if let Some(f) = from {
m.default_from = Some(f);
m.from_explicit = true;
}
Ok(m)
}
"file" => {
let path = get("SOVA_MAIL_PATH").unwrap_or_else(|| "./mail".into());
let mut m = Self::file(path);
if let Some(f) = from {
m.default_from = Some(f);
m.from_explicit = true;
}
Ok(m)
}
"smtp" => {
let url = url.ok_or_else(|| {
Error::Internal("SOVA_MAIL=smtp requires SOVA_MAIL_URL or SMTP_URL".into())
})?;
let transport = build_smtp_from_url(&url)?;
let mut m = Self::bare(
Backend::Smtp(transport),
from.clone().or_else(|| Some("Sova <noreply@localhost>".into())),
None,
);
if from.is_some() {
m.from_explicit = true;
}
Ok(m)
}
_ => unreachable!(),
}
}
pub fn from(mut self, addr: impl Into<String>) -> Self {
self.default_from = Some(addr.into());
self.from_explicit = true;
self
}
pub fn client(&self) -> MailClient {
MailClient {
backend: match &self.backend {
Backend::Smtp(t) => Arc::new(ClientBackend::Smtp(t.clone())),
Backend::File(t) => Arc::new(ClientBackend::File(t.clone())),
Backend::Fake(f) => Arc::new(ClientBackend::Fake(f.clone())),
},
default_from: self.default_from.clone(),
fake: self.fake.clone(),
events: None,
#[cfg(feature = "templates")]
templates: Arc::clone(&self.templates_slot),
}
}
pub(crate) fn into_client(self) -> MailClient {
self.client()
}
pub fn recorder(&self) -> Option<&FakeMail> {
self.fake.as_ref()
}
pub(crate) fn from_smtp(b: SmtpBuilder) -> Self {
b.build().unwrap_or_else(|e| {
panic!("mail smtp build failed (refusing silent fake): {e}");
})
}
}
pub struct SmtpBuilder {
host: String,
port: Option<u16>,
user: Option<String>,
pass: Option<String>,
from: Option<String>,
}
impl SmtpBuilder {
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
pub fn credentials(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
self.user = Some(user.into());
self.pass = Some(pass.into());
self
}
pub fn from(mut self, addr: impl Into<String>) -> Self {
self.from = Some(addr.into());
self
}
pub fn build(self) -> Result<Mail> {
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&self.host)
.map_err(|e| Error::Internal(format!("mail smtp relay: {e}")))?;
if let Some(port) = self.port {
builder = builder.port(port);
}
if let (Some(user), Some(pass)) = (self.user.clone(), self.pass.clone()) {
builder = builder.credentials(Credentials::new(user, pass));
}
let transport = builder.build();
let explicit = self.from.is_some();
let mut mail = Mail::bare(
Backend::Smtp(transport),
self.from
.or_else(|| Some("Sova <noreply@localhost>".into())),
None,
);
mail.from_explicit = explicit;
Ok(mail)
}
}
impl From<SmtpBuilder> for Mail {
fn from(b: SmtpBuilder) -> Self {
Mail::from_smtp(b)
}
}
enum ClientBackend {
Smtp(AsyncSmtpTransport<Tokio1Executor>),
File(AsyncFileTransport<Tokio1Executor>),
Fake(FakeMail),
}
#[derive(Clone)]
pub struct MailClient {
backend: Arc<ClientBackend>,
pub(crate) default_from: Option<String>,
fake: Option<FakeMail>,
events: Option<sova_core::EventBus>,
#[cfg(feature = "templates")]
templates: TemplatesSlot,
}
impl MailClient {
pub fn compose(&self) -> Email {
Email::with_client(self.clone())
}
pub fn set_events(&mut self, bus: sova_core::EventBus) {
self.events = Some(bus);
}
#[cfg(feature = "templates")]
pub(crate) fn templates(&self) -> Option<Arc<sova_templates::MiniJinjaTemplates>> {
self.templates.read().unwrap().clone()
}
#[cfg(feature = "templates")]
pub(crate) fn set_templates(&self, templates: sova_templates::MiniJinjaTemplates) {
*self.templates.write().unwrap() = Some(Arc::new(templates));
}
#[allow(unused_mut)] pub async fn send(&self, mut email: Email) -> Result<()> {
#[cfg(any(feature = "templates", feature = "markdown"))]
email.resolve_body(self)?;
let (snap, message) = email.into_message(self.default_from.as_deref())?;
let to = snap.to.clone();
let subject = snap.subject.clone();
let result = match self.backend.as_ref() {
ClientBackend::Fake(fake) => {
fake.record(snap);
Ok(())
}
ClientBackend::Smtp(t) => t
.send(message)
.await
.map(|_| ())
.map_err(|e| Error::Internal(format!("mail smtp: {e}"))),
ClientBackend::File(t) => t
.send(message)
.await
.map(|_| ())
.map_err(|e| Error::Internal(format!("mail file: {e}"))),
};
if result.is_ok() {
if let Some(bus) = &self.events {
bus.dispatch(crate::MailSent { to, subject });
}
}
result
}
pub fn fake(&self) -> Option<&FakeMail> {
self.fake.as_ref()
}
}
fn build_smtp_from_url(url: &str) -> Result<AsyncSmtpTransport<Tokio1Executor>> {
Ok(AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
.map_err(|e| Error::Internal(format!("mail url: {e}")))?
.build())
}