// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Outbound mail: SMTP delivery plus the activation/reset templates. //! //! Three backends (`config::MailBackend`): `smtp` delivers over SMTP with //! native-TLS (`starttls`/`tls`/plaintext), `stdout` prints the rendered message //! (development), and `none` rejects any flow that needs mail. Sending is //! best-effort — the caller always has the activation URL to deliver by hand — so //! `send_invite` returns a `Result` the caller logs rather than fails on. mod templates; use std::time::Duration; use config::{Mail, MailBackend, MailEncryption}; use lettre::message::{Mailbox, MultiPart}; use lettre::transport::smtp::authentication::Credentials; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; pub use crate::templates::{Purpose, Rendered, render}; /// An error from the mail layer. #[derive(Debug, thiserror::Error)] pub enum MailError { /// The `none` backend was asked to send. #[error("mail is disabled (mail.backend = \"none\")")] Disabled, /// An address failed to parse. #[error("invalid email address: {0}")] Address(String), /// Building the SMTP transport failed. #[error("smtp transport setup failed: {0}")] Transport(String), /// Building the message failed. #[error("message construction failed: {0}")] Message(String), /// Delivery failed. #[error("mail delivery failed: {0}")] Send(String), } /// A configured mailer. Cheap to build once and reuse. pub struct Mailer { from: Mailbox, backend: Delivery, } /// The delivery mechanism behind a [`Mailer`]. enum Delivery { /// Deliver over SMTP. Smtp(Box>), /// Print the rendered message to stdout. Stdout, /// Reject sends. None, } impl Mailer { /// Build a mailer from configuration. /// /// # Errors /// /// Returns [`MailError::Address`] if `mail.from` is not a valid address, or /// [`MailError::Transport`] if the SMTP transport cannot be constructed. pub fn build(config: &Mail) -> Result { let from: Mailbox = config .from .parse() .map_err(|e| MailError::Address(format!("{}: {e}", config.from)))?; let backend = match config.backend { MailBackend::Stdout => Delivery::Stdout, MailBackend::None => Delivery::None, MailBackend::Smtp => Delivery::Smtp(Box::new(build_transport(config)?)), }; Ok(Self { from, backend }) } /// Send an invite email to `to_email` (optionally named `to_name`) with the /// activation `link`. /// /// # Errors /// /// Returns [`MailError`] on address/message/transport failure, or /// [`MailError::Disabled`] under the `none` backend. Callers treat this as /// best-effort and still surface the link out of band. pub async fn send_invite( &self, instance_name: &str, to_email: &str, to_name: Option<&str>, link: &str, purpose: Purpose, ) -> Result<(), MailError> { let rendered = render(instance_name, link, purpose); let to = mailbox(to_email, to_name)?; match &self.backend { Delivery::None => Err(MailError::Disabled), Delivery::Stdout => { println!("--- mail ({}) ---", purpose.as_str()); println!("From: {}", self.from); println!("To: {to}"); println!("Subject: {}", rendered.subject); println!(); println!("{}", rendered.plain); println!("--- end mail ---"); Ok(()) } Delivery::Smtp(transport) => { let email = Message::builder() .from(self.from.clone()) .to(to) .subject(rendered.subject) .multipart(MultiPart::alternative_plain_html( rendered.plain, rendered.html, )) .map_err(|e| MailError::Message(e.to_string()))?; transport .send(email) .await .map_err(|e| MailError::Send(e.to_string()))?; Ok(()) } } } } /// Build a `to` mailbox from an address and optional display name. fn mailbox(email: &str, name: Option<&str>) -> Result { let address = email .parse() .map_err(|e| MailError::Address(format!("{email}: {e}")))?; Ok(Mailbox::new(name.map(str::to_string), address)) } /// Construct the async SMTP transport for the `smtp` backend. fn build_transport(config: &Mail) -> Result, MailError> { let builder = match config.encryption { MailEncryption::Tls => AsyncSmtpTransport::::relay(&config.host) .map_err(|e| MailError::Transport(e.to_string()))?, MailEncryption::Starttls => { AsyncSmtpTransport::::starttls_relay(&config.host) .map_err(|e| MailError::Transport(e.to_string()))? } MailEncryption::None => { AsyncSmtpTransport::::builder_dangerous(&config.host) } }; let mut builder = builder .port(config.port) .timeout(Some(Duration::from_secs(config.timeout_secs))); if !config.username.is_empty() { let password = config .password .as_ref() .map(|s| s.expose().to_string()) .unwrap_or_default(); builder = builder.credentials(Credentials::new(config.username.clone(), password)); } Ok(builder.build()) } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use config::Mail; use super::*; fn stdout_config() -> Mail { Mail { backend: MailBackend::Stdout, from: "Fabrica ".to_string(), ..Mail::default() } } #[tokio::test] async fn stdout_backend_sends_without_error() { let mailer = Mailer::build(&stdout_config()).unwrap(); mailer .send_invite( "Forge", "ada@example.com", Some("Ada"), "https://git.example.com/invite/tok", Purpose::Activate, ) .await .unwrap(); } #[tokio::test] async fn none_backend_rejects() { let config = Mail { backend: MailBackend::None, ..stdout_config() }; let mailer = Mailer::build(&config).unwrap(); let err = mailer .send_invite( "Forge", "a@b.com", None, "https://x/invite/t", Purpose::Activate, ) .await .unwrap_err(); assert!(matches!(err, MailError::Disabled)); } #[test] fn invalid_from_is_rejected() { let config = Mail { from: "not an address".to_string(), ..stdout_config() }; assert!(matches!(Mailer::build(&config), Err(MailError::Address(_)))); } }