| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | mod templates; |
| 14 | |
| 15 | use std::time::Duration; |
| 16 | |
| 17 | use config::{Mail, MailBackend, MailEncryption}; |
| 18 | use lettre::message::{Mailbox, MultiPart}; |
| 19 | use lettre::transport::smtp::authentication::Credentials; |
| 20 | use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; |
| 21 | |
| 22 | pub use crate::templates::{Purpose, Rendered, render}; |
| 23 | |
| 24 | |
| 25 | #[derive(Debug, thiserror::Error)] |
| 26 | pub enum MailError { |
| 27 | |
| 28 | #[error("mail is disabled (mail.backend = \"none\")")] |
| 29 | Disabled, |
| 30 | |
| 31 | #[error("invalid email address: {0}")] |
| 32 | Address(String), |
| 33 | |
| 34 | #[error("smtp transport setup failed: {0}")] |
| 35 | Transport(String), |
| 36 | |
| 37 | #[error("message construction failed: {0}")] |
| 38 | Message(String), |
| 39 | |
| 40 | #[error("mail delivery failed: {0}")] |
| 41 | Send(String), |
| 42 | } |
| 43 | |
| 44 | |
| 45 | pub struct Mailer { |
| 46 | from: Mailbox, |
| 47 | backend: Delivery, |
| 48 | } |
| 49 | |
| 50 | |
| 51 | enum Delivery { |
| 52 | |
| 53 | Smtp(Box<AsyncSmtpTransport<Tokio1Executor>>), |
| 54 | |
| 55 | Stdout, |
| 56 | |
| 57 | None, |
| 58 | } |
| 59 | |
| 60 | impl Mailer { |
| 61 | |
| 62 | |
| 63 | |
| 64 | |
| 65 | |
| 66 | |
| 67 | pub fn build(config: &Mail) -> Result<Self, MailError> { |
| 68 | let from: Mailbox = config |
| 69 | .from |
| 70 | .parse() |
| 71 | .map_err(|e| MailError::Address(format!("{}: {e}", config.from)))?; |
| 72 | |
| 73 | let backend = match config.backend { |
| 74 | MailBackend::Stdout => Delivery::Stdout, |
| 75 | MailBackend::None => Delivery::None, |
| 76 | MailBackend::Smtp => Delivery::Smtp(Box::new(build_transport(config)?)), |
| 77 | }; |
| 78 | Ok(Self { from, backend }) |
| 79 | } |
| 80 | |
| 81 | |
| 82 | |
| 83 | |
| 84 | |
| 85 | |
| 86 | |
| 87 | |
| 88 | |
| 89 | pub async fn send_invite( |
| 90 | &self, |
| 91 | instance_name: &str, |
| 92 | to_email: &str, |
| 93 | to_name: Option<&str>, |
| 94 | link: &str, |
| 95 | purpose: Purpose, |
| 96 | ) -> Result<(), MailError> { |
| 97 | let rendered = render(instance_name, link, purpose); |
| 98 | let to = mailbox(to_email, to_name)?; |
| 99 | |
| 100 | match &self.backend { |
| 101 | Delivery::None => Err(MailError::Disabled), |
| 102 | Delivery::Stdout => { |
| 103 | println!("--- mail ({}) ---", purpose.as_str()); |
| 104 | println!("From: {}", self.from); |
| 105 | println!("To: {to}"); |
| 106 | println!("Subject: {}", rendered.subject); |
| 107 | println!(); |
| 108 | println!("{}", rendered.plain); |
| 109 | println!("--- end mail ---"); |
| 110 | Ok(()) |
| 111 | } |
| 112 | Delivery::Smtp(transport) => { |
| 113 | let email = Message::builder() |
| 114 | .from(self.from.clone()) |
| 115 | .to(to) |
| 116 | .subject(rendered.subject) |
| 117 | .multipart(MultiPart::alternative_plain_html( |
| 118 | rendered.plain, |
| 119 | rendered.html, |
| 120 | )) |
| 121 | .map_err(|e| MailError::Message(e.to_string()))?; |
| 122 | transport |
| 123 | .send(email) |
| 124 | .await |
| 125 | .map_err(|e| MailError::Send(e.to_string()))?; |
| 126 | Ok(()) |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | |
| 133 | fn mailbox(email: &str, name: Option<&str>) -> Result<Mailbox, MailError> { |
| 134 | let address = email |
| 135 | .parse() |
| 136 | .map_err(|e| MailError::Address(format!("{email}: {e}")))?; |
| 137 | Ok(Mailbox::new(name.map(str::to_string), address)) |
| 138 | } |
| 139 | |
| 140 | |
| 141 | fn build_transport(config: &Mail) -> Result<AsyncSmtpTransport<Tokio1Executor>, MailError> { |
| 142 | let builder = match config.encryption { |
| 143 | MailEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host) |
| 144 | .map_err(|e| MailError::Transport(e.to_string()))?, |
| 145 | MailEncryption::Starttls => { |
| 146 | AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host) |
| 147 | .map_err(|e| MailError::Transport(e.to_string()))? |
| 148 | } |
| 149 | MailEncryption::None => { |
| 150 | AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host) |
| 151 | } |
| 152 | }; |
| 153 | |
| 154 | let mut builder = builder |
| 155 | .port(config.port) |
| 156 | .timeout(Some(Duration::from_secs(config.timeout_secs))); |
| 157 | if !config.username.is_empty() { |
| 158 | let password = config |
| 159 | .password |
| 160 | .as_ref() |
| 161 | .map(|s| s.expose().to_string()) |
| 162 | .unwrap_or_default(); |
| 163 | builder = builder.credentials(Credentials::new(config.username.clone(), password)); |
| 164 | } |
| 165 | Ok(builder.build()) |
| 166 | } |
| 167 | |
| 168 | #[cfg(test)] |
| 169 | mod tests { |
| 170 | #![allow(clippy::unwrap_used)] |
| 171 | |
| 172 | use config::Mail; |
| 173 | |
| 174 | use super::*; |
| 175 | |
| 176 | fn stdout_config() -> Mail { |
| 177 | Mail { |
| 178 | backend: MailBackend::Stdout, |
| 179 | from: "Fabrica <fabrica@example.com>".to_string(), |
| 180 | ..Mail::default() |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | #[tokio::test] |
| 185 | async fn stdout_backend_sends_without_error() { |
| 186 | let mailer = Mailer::build(&stdout_config()).unwrap(); |
| 187 | mailer |
| 188 | .send_invite( |
| 189 | "Forge", |
| 190 | "ada@example.com", |
| 191 | Some("Ada"), |
| 192 | "https://git.example.com/invite/tok", |
| 193 | Purpose::Activate, |
| 194 | ) |
| 195 | .await |
| 196 | .unwrap(); |
| 197 | } |
| 198 | |
| 199 | #[tokio::test] |
| 200 | async fn none_backend_rejects() { |
| 201 | let config = Mail { |
| 202 | backend: MailBackend::None, |
| 203 | ..stdout_config() |
| 204 | }; |
| 205 | let mailer = Mailer::build(&config).unwrap(); |
| 206 | let err = mailer |
| 207 | .send_invite( |
| 208 | "Forge", |
| 209 | "a@b.com", |
| 210 | None, |
| 211 | "https://x/invite/t", |
| 212 | Purpose::Activate, |
| 213 | ) |
| 214 | .await |
| 215 | .unwrap_err(); |
| 216 | assert!(matches!(err, MailError::Disabled)); |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn invalid_from_is_rejected() { |
| 221 | let config = Mail { |
| 222 | from: "not an address".to_string(), |
| 223 | ..stdout_config() |
| 224 | }; |
| 225 | assert!(matches!(Mailer::build(&config), Err(MailError::Address(_)))); |
| 226 | } |
| 227 | } |