//! Minimal plain-text SMTP delivery. #[cfg(test)] use std::sync::Arc; use anyhow::Context as _; use lettre::message::{Mailbox, header::ContentType}; use lettre::transport::smtp::authentication::Credentials; use lettre::{AsyncSmtpTransport, AsyncTransport as _, Tokio1Executor}; use crate::config::MailConfig; /// A plain-text outbound message. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { /// Recipient mailbox, either an address or `Name
`. pub to: String, /// Message subject. pub subject: String, /// Plain-text message body. pub body: String, } #[derive(Clone)] enum Transport { Smtp(AsyncSmtpTransport), #[cfg(test)] Recording(Arc>>), #[cfg(test)] Failing, } /// A reusable SMTP sender built from the startup configuration. #[derive(Clone)] pub struct Mailer { from: Mailbox, transport: Transport, } impl Mailer { /// Build an SMTP sender, or return `None` when mail is not fully active. pub fn from_config(config: &MailConfig) -> anyhow::Result> { if !config.is_active() { return Ok(None); } let from = parse_mailbox(&config.from, "mail.from")?; let user = config .smtp_user .as_deref() .expect("active mail config has an SMTP username"); let pass = config .smtp_pass .as_deref() .expect("active mail config has an SMTP password"); let builder = if config.smtp_starttls { AsyncSmtpTransport::::starttls_relay(&config.smtp_host) } else { AsyncSmtpTransport::::relay(&config.smtp_host) } .with_context(|| format!("invalid SMTP host {:?}", config.smtp_host))?; let transport = builder .port(config.smtp_port) .credentials(Credentials::new(user.to_string(), pass.to_string())) .build(); Ok(Some(Self { from, transport: Transport::Smtp(transport), })) } /// Send one plain-text message. pub async fn send(&self, message: Message) -> anyhow::Result<()> { #[cfg(test)] if let Transport::Recording(messages) = &self.transport { messages .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .push(message); return Ok(()); } #[cfg(test)] if let Transport::Failing = &self.transport { anyhow::bail!("test mail delivery failed"); } let to = parse_mailbox(&message.to, "message recipient")?; let email = lettre::Message::builder() .from(self.from.clone()) .to(to) .subject(message.subject) .header(ContentType::TEXT_PLAIN) .body(message.body) .context("building plain-text email")?; match &self.transport { Transport::Smtp(transport) => { transport .send(email) .await .context("sending email through SMTP")?; } #[cfg(test)] Transport::Recording(_) => unreachable!("recording transport returned above"), #[cfg(test)] Transport::Failing => unreachable!("failing transport returned above"), } Ok(()) } #[cfg(test)] pub(crate) fn recording() -> (Self, Arc>>) { let messages = Arc::new(std::sync::Mutex::new(Vec::new())); ( Self { from: "daily@example.com".parse().expect("valid test mailbox"), transport: Transport::Recording(Arc::clone(&messages)), }, messages, ) } #[cfg(test)] pub(crate) fn failing() -> Self { Self { from: "daily@example.com".parse().expect("valid test mailbox"), transport: Transport::Failing, } } } fn parse_mailbox(value: &str, field: &str) -> anyhow::Result { value .parse::() .with_context(|| format!("{field} is not a valid email mailbox")) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_named_and_bare_mailboxes() { assert!(parse_mailbox("The Daily EPUB ", "from").is_ok()); assert!(parse_mailbox("operator@example.com", "to").is_ok()); assert!(parse_mailbox("not an address", "to").is_err()); } #[test] fn inactive_config_builds_no_mailer() { assert!( Mailer::from_config(&MailConfig::default()) .unwrap() .is_none() ); let mut config = MailConfig { enabled: true, smtp_host: "smtp.example.com".into(), from: "daily@example.com".into(), smtp_user: Some("user".into()), smtp_pass: Some(" ".into()), ..MailConfig::default() }; assert!(Mailer::from_config(&config).unwrap().is_none()); config.smtp_pass = Some("secret".into()); config.from = "not an address".into(); assert!(Mailer::from_config(&config).is_err()); } }