use async_trait::async_trait;
use lettre::message::{Mailbox, MultiPart};
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use crate::error::AdminError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Email {
pub to: String,
pub subject: String,
pub text: String,
pub html: Option<String>,
}
#[async_trait]
pub trait Mailer: Send + Sync {
async fn send(&self, email: Email) -> Result<(), AdminError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LogMailer;
#[async_trait]
impl Mailer for LogMailer {
async fn send(&self, email: Email) -> Result<(), AdminError> {
tracing::info!(
to = %email.to,
subject = %email.subject,
"e-mail not sent (no mail.url set); its text:\n{}",
email.text
);
Ok(())
}
}
#[derive(Clone)]
pub struct SmtpMailer {
transport: AsyncSmtpTransport<Tokio1Executor>,
from: Mailbox,
}
impl std::fmt::Debug for SmtpMailer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SmtpMailer")
.field("from", &self.from.to_string())
.finish_non_exhaustive()
}
}
impl SmtpMailer {
pub fn new(url: &str, from: &str) -> Result<Self, AdminError> {
let parsed =
url::Url::parse(url).map_err(|_| AdminError::Config("mail.url is not a URL".into()))?;
let local = matches!(
parsed.host_str(),
Some("localhost" | "127.0.0.1" | "[::1]" | "::1")
);
let tls = parsed
.query_pairs()
.find(|(key, _)| key == "tls")
.map(|(_, value)| value.into_owned());
let encrypted = match parsed.scheme() {
"smtps" => true,
"smtp" => tls.as_deref() == Some("required"),
other => {
return Err(AdminError::Config(format!(
"mail.url must start with smtps:// or smtp://, not {other}://"
)));
}
};
if !encrypted && !local {
return Err(AdminError::Config(
"mail.url would send unencrypted: use smtps://… or smtp://…?tls=required".into(),
));
}
let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
.map_err(|e| AdminError::Config(format!("mail.url: {e}")))?
.build();
let from = from
.parse::<Mailbox>()
.map_err(|e| AdminError::Config(format!("mail.from `{from}`: {e}")))?;
Ok(Self { transport, from })
}
}
#[async_trait]
impl Mailer for SmtpMailer {
async fn send(&self, email: Email) -> Result<(), AdminError> {
let to = email
.to
.parse::<Mailbox>()
.map_err(|e| AdminError::Custom {
status: axum::http::StatusCode::BAD_GATEWAY,
code: "mail_failed".into(),
message: format!("cannot send to `{}`: {e}", email.to),
})?;
let builder = Message::builder()
.from(self.from.clone())
.to(to)
.subject(email.subject);
let message = match email.html {
Some(html) => builder.multipart(MultiPart::alternative_plain_html(email.text, html)),
None => builder.body(email.text),
}
.map_err(|e| mail_failed(&e))?;
self.transport
.send(message)
.await
.map_err(|e| mail_failed(&e))?;
Ok(())
}
}
fn mail_failed(error: &dyn std::fmt::Display) -> AdminError {
AdminError::Custom {
status: axum::http::StatusCode::BAD_GATEWAY,
code: "mail_failed".into(),
message: format!("the e-mail could not be sent: {error}"),
}
}
pub(crate) fn send_later(mailer: std::sync::Arc<dyn Mailer>, email: Email) {
tokio::spawn(async move {
let to = email.to.clone();
if let Err(error) = mailer.send(email).await {
tracing::error!(%to, %error, "e-mail could not be sent");
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn unencrypted_smtp_is_only_allowed_locally() {
assert!(SmtpMailer::new("smtps://u:p@smtp.example.com", "Shop <a@shop.com>").is_ok());
assert!(SmtpMailer::new("smtp://u:p@smtp.example.com?tls=required", "a@shop.com").is_ok());
assert!(SmtpMailer::new("smtp://localhost:1025", "a@shop.com").is_ok());
for url in [
"smtp://u:p@smtp.example.com",
"smtp://u:p@smtp.example.com?tls=opportunistic",
] {
match SmtpMailer::new(url, "a@shop.com") {
Err(AdminError::Config(message)) => assert!(message.contains("unencrypted")),
_ => panic!("{url} must be refused"),
}
}
assert!(SmtpMailer::new("http://smtp.example.com", "a@shop.com").is_err());
assert!(SmtpMailer::new("smtps://smtp.example.com", "not an address").is_err());
}
}