//! The SMTP transport (lettre, rustls). It reads the server, sender and password from the //! database on every send, so a change on `/admin/mail` applies to the next message. use std::time::Duration; use forge_core::Store; use forge_core::mail::{MailError, MailSettings, OutgoingMail, Security}; use lettre::message::Mailbox; use lettre::message::header::{ContentType, InReplyTo, MessageId, References}; use lettre::transport::smtp::authentication::Credentials; use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use super::{Mailer, SendFuture}; /// Longest a connection, handshake or reply may take. A silent server is cut off here. const TIMEOUT: Duration = Duration::from_secs(30); /// Sends through the SMTP server stored in `mail_settings`. pub struct SmtpMailer { store: Store, } impl SmtpMailer { pub fn new(store: Store) -> SmtpMailer { SmtpMailer { store } } async fn deliver(&self, mail: &OutgoingMail) -> Result<(), MailError> { let settings = self .store .mail_settings() .await .map_err(|e| MailError::Transient(format!("cannot read the mail settings: {e}")))?; let password = self.store.mail_password().await.map_err(|e| { MailError::Permanent(format!("cannot open the stored SMTP password: {e}")) })?; let message = build_message(&settings, mail)?; let transport = build_transport(&settings, password.as_ref().map(|p| p.expose()))?; transport.send(message).await.map(|_| ()).map_err(|e| { if e.is_permanent() { MailError::Permanent(e.to_string()) } else { MailError::Transient(e.to_string()) } }) } } impl Mailer for SmtpMailer { fn send<'a>(&'a self, mail: &'a OutgoingMail) -> SendFuture<'a> { Box::pin(self.deliver(mail)) } } /// Builds a plain-text message from the stored sender. A bad address is permanent. fn build_message(settings: &MailSettings, mail: &OutgoingMail) -> Result { let bad = |what: &str, e: &dyn std::fmt::Display| { MailError::Permanent(format!("the {what} is not a valid address: {e}")) }; let address: Address = settings.from_addr.parse().map_err(|e| bad("sender", &e))?; let from = Mailbox::new( (!settings.from_name.is_empty()).then(|| settings.from_name.clone()), address, ); let to: Mailbox = mail.to.parse().map_err(|e| bad("recipient", &e))?; let mut builder = Message::builder() .from(from) .to(to) .subject(mail.subject.clone()); if let Some(id) = &mail.message_id { builder = builder.header(MessageId::from(id.clone())); } if let Some(parent) = &mail.in_reply_to { builder = builder .header(InReplyTo::from(parent.clone())) .header(References::from(parent.clone())); } builder .header(ContentType::TEXT_PLAIN) .body(mail.body.clone()) .map_err(|e| MailError::Permanent(format!("cannot build the message: {e}"))) } /// Builds a connection for one send. The transport is cheap, so there is no cache to invalidate. fn build_transport( settings: &MailSettings, password: Option<&str>, ) -> Result, MailError> { let builder = match settings.security { Security::StartTls => AsyncSmtpTransport::::starttls_relay(&settings.host), Security::Tls => AsyncSmtpTransport::::relay(&settings.host), Security::Plain => Ok(AsyncSmtpTransport::::builder_dangerous( &settings.host, )), } .map_err(|e| MailError::Permanent(format!("cannot set up the SMTP connection: {e}")))?; let mut builder = builder.port(settings.port).timeout(Some(TIMEOUT)); if !settings.username.is_empty() { builder = builder.credentials(Credentials::new( settings.username.clone(), password.unwrap_or_default().to_owned(), )); } Ok(builder.build()) } #[cfg(test)] mod tests { use super::*; fn settings() -> MailSettings { MailSettings { host: "smtp.test.example".into(), port: 587, security: Security::StartTls, username: String::new(), from_addr: "forge@test.example".into(), from_name: "forge".into(), enabled: true, has_password: false, updated_at: None, } } fn rendered(mail: &OutgoingMail) -> String { let message = build_message(&settings(), mail).expect("message"); String::from_utf8(message.formatted()).expect("utf-8 headers") } #[test] fn threading_headers_are_written_when_set() { let mail = OutgoingMail { to: "bob@test.example".into(), subject: "[bob/widgets] T (#1)".into(), body: "hello".into(), message_id: Some("".into()), in_reply_to: Some("".into()), }; let text = rendered(&mail); assert!(text.contains("Message-ID: "), "{text}"); assert!( text.contains("In-Reply-To: "), "{text}" ); assert!(text.contains("References: "), "{text}"); } #[test] fn no_reply_headers_without_a_thread() { let mail = OutgoingMail { to: "bob@test.example".into(), subject: "s".into(), body: "b".into(), message_id: None, in_reply_to: None, }; let text = rendered(&mail); assert!(!text.contains("In-Reply-To"), "{text}"); assert!(!text.contains("References"), "{text}"); } }