Skip to main content

rustlavel_mail/
config.rs

1//! Mail configuration, read from `config/mail.json` or `.env`.
2//!
3//! ```json
4//! {
5//!   "transport": "${MAIL_TRANSPORT:log}",
6//!   "host":      "${MAIL_HOST:127.0.0.1}",
7//!   "port":      "${MAIL_PORT:587}",
8//!   "username":  "${MAIL_USERNAME}",
9//!   "password":  "${MAIL_PASSWORD}",
10//!   "path":      "storage/mail",
11//!   "from": { "address": "${MAIL_FROM_ADDRESS}", "name": "${MAIL_FROM_NAME}" }
12//! }
13//! ```
14//!
15//! The default transport is `log`, deliberately: a new application should not
16//! be able to send real mail to real people before anyone has configured it.
17
18use crate::address::Address;
19use crate::smtp::{Encryption, SmtpConfig};
20use rustlavel_core::{Config, Error, Result};
21use std::fmt;
22use std::path::PathBuf;
23use std::time::Duration;
24
25/// Which transport an application uses.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum TransportKind {
28    /// Talk to a real mail server.
29    Smtp,
30    /// Write the rendered message to the log. The local default.
31    #[default]
32    Log,
33    /// Write one `.eml` per message under `mail.path`.
34    File,
35}
36
37impl TransportKind {
38    /// Parse a transport name, listing the alternatives when it is not one.
39    ///
40    /// Refuses rather than falling back: silently logging mail that was meant
41    /// to be sent is the kind of bug that is noticed a week later.
42    pub fn parse(name: &str) -> Result<TransportKind> {
43        match name.trim().to_ascii_lowercase().as_str() {
44            "smtp" => Ok(TransportKind::Smtp),
45            "" | "log" => Ok(TransportKind::Log),
46            "file" | "eml" => Ok(TransportKind::File),
47            other => Err(Error::msg(format!(
48                "`{other}` is not a mail transport. Set mail.transport to one of: smtp, log, file."
49            ))),
50        }
51    }
52
53    pub fn name(self) -> &'static str {
54        match self {
55            TransportKind::Smtp => "smtp",
56            TransportKind::Log => "log",
57            TransportKind::File => "file",
58        }
59    }
60}
61
62/// Everything `config/mail.json` holds.
63///
64/// `Debug` is hand-written so a password cannot reach a log line by being
65/// printed alongside the rest of the settings.
66#[derive(Clone)]
67pub struct MailConfig {
68    pub transport: TransportKind,
69    pub host: String,
70    pub port: u16,
71    pub username: String,
72    pub password: String,
73    pub encryption: Encryption,
74    /// Where the file transport writes its `.eml` files.
75    pub path: PathBuf,
76    /// The default sender, used by any message that does not set its own.
77    pub from_address: String,
78    pub from_name: String,
79    pub timeout: Duration,
80}
81
82impl Default for MailConfig {
83    fn default() -> Self {
84        MailConfig {
85            transport: TransportKind::Log,
86            host: "127.0.0.1".into(),
87            port: 587,
88            username: String::new(),
89            password: String::new(),
90            encryption: Encryption::Auto,
91            path: PathBuf::from("storage/mail"),
92            from_address: String::new(),
93            from_name: String::new(),
94            timeout: Duration::from_secs(30),
95        }
96    }
97}
98
99impl MailConfig {
100    pub fn from_app_config(config: &Config) -> Result<MailConfig> {
101        let defaults = MailConfig::default();
102
103        Ok(MailConfig {
104            transport: TransportKind::parse(&config.string("mail.transport", "log"))?,
105            host: config.string("mail.host", &defaults.host),
106            port: port_of(config)?,
107            username: config.string("mail.username", ""),
108            password: config.string("mail.password", ""),
109            encryption: Encryption::parse(&config.string("mail.encryption", ""))?,
110            path: PathBuf::from(config.string("mail.path", "storage/mail")),
111            from_address: config.string("mail.from.address", ""),
112            from_name: config.string("mail.from.name", ""),
113            ..defaults
114        })
115    }
116
117    /// The configured sender, if there is one and it is valid.
118    pub fn from(&self) -> Result<Option<Address>> {
119        if self.from_address.is_empty() {
120            return Ok(None);
121        }
122        let address = if self.from_name.is_empty() {
123            Address::new(&self.from_address)
124        } else {
125            Address::named(&self.from_name, &self.from_address)
126        }
127        .map_err(|error| {
128            Error::msg(format!("mail.from.address is not a valid address: {error}"))
129        })?;
130
131        Ok(Some(address))
132    }
133
134    /// The settings the SMTP transport needs.
135    pub fn smtp(&self) -> SmtpConfig {
136        SmtpConfig {
137            host: self.host.clone(),
138            port: self.port,
139            username: self.username.clone(),
140            password: self.password.clone(),
141            encryption: self.encryption,
142            timeout: self.timeout,
143            // The EHLO name identifies this client to the server. The sender's
144            // domain is the honest answer, and some servers check it.
145            hello: self
146                .from_address
147                .split_once('@')
148                .map(|(_, domain)| domain.to_string())
149                .unwrap_or_else(|| "localhost".to_string()),
150        }
151    }
152
153    /// A one-line summary that is safe to log.
154    pub fn describe(&self) -> String {
155        match self.transport {
156            TransportKind::Smtp => format!("smtp {}", self.smtp().describe()),
157            TransportKind::Log => "log".to_string(),
158            TransportKind::File => format!("file {}", self.path.display()),
159        }
160    }
161}
162
163impl fmt::Debug for MailConfig {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.debug_struct("MailConfig")
166            .field("transport", &self.transport)
167            .field("host", &self.host)
168            .field("port", &self.port)
169            .field("username", &self.username)
170            .field("password", &"<redacted>")
171            .field("encryption", &self.encryption)
172            .field("path", &self.path)
173            .field("from_address", &self.from_address)
174            .field("from_name", &self.from_name)
175            .finish()
176    }
177}
178
179/// `mail.port` may arrive as a number or as a string out of `.env`.
180fn port_of(config: &Config) -> Result<u16> {
181    let port = config.int("mail.port", 587);
182    u16::try_from(port).map_err(|_| {
183        Error::msg(format!("mail.port is {port}, which is not a TCP port number"))
184    })
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn an_unconfigured_application_gets_the_log_transport() {
193        let settings = MailConfig::from_app_config(&Config::new()).unwrap();
194
195        assert_eq!(settings.transport, TransportKind::Log);
196        assert_eq!(settings.from().unwrap(), None);
197    }
198
199    #[test]
200    fn a_transport_typo_names_the_valid_choices() {
201        let config = Config::new();
202        config.set("mail.transport", "smpt");
203
204        let error = MailConfig::from_app_config(&config).unwrap_err().to_string();
205        assert!(error.contains("smtp, log, file"), "{error}");
206    }
207
208    #[test]
209    fn every_documented_key_is_read() {
210        let config = Config::new();
211        config.set("mail.transport", "smtp");
212        config.set("mail.host", "smtp.example.com");
213        config.set("mail.port", "2525");
214        config.set("mail.username", "ada");
215        config.set("mail.password", "hunter2");
216        config.set("mail.from.address", "no-reply@example.com");
217        config.set("mail.from.name", "Rustlavel");
218        config.set("mail.path", "/tmp/some-mail");
219
220        let settings = MailConfig::from_app_config(&config).unwrap();
221
222        assert_eq!(settings.transport, TransportKind::Smtp);
223        assert_eq!(settings.host, "smtp.example.com");
224        assert_eq!(settings.port, 2525);
225        assert_eq!(settings.username, "ada");
226        assert_eq!(settings.password, "hunter2");
227        assert_eq!(settings.path, PathBuf::from("/tmp/some-mail"));
228        assert_eq!(
229            settings.from().unwrap().unwrap().to_header(),
230            "Rustlavel <no-reply@example.com>"
231        );
232        // The EHLO name comes from the sender's domain.
233        assert_eq!(settings.smtp().hello, "example.com");
234    }
235
236    #[test]
237    fn the_password_never_reaches_debug_output_or_a_description() {
238        let config = Config::new();
239        config.set("mail.transport", "smtp");
240        config.set("mail.username", "ada");
241        config.set("mail.password", "hunter2");
242        config.set("mail.host", "smtp.example.com");
243
244        let settings = MailConfig::from_app_config(&config).unwrap();
245
246        for rendered in [format!("{settings:?}"), settings.describe(), format!("{:?}", settings.smtp())] {
247            assert!(!rendered.contains("hunter2"), "a password leaked: {rendered}");
248        }
249        assert!(format!("{settings:?}").contains("<redacted>"));
250    }
251
252    #[test]
253    fn a_from_address_that_cannot_be_parsed_is_refused_at_boot() {
254        let config = Config::new();
255        config.set("mail.from.address", "not an address");
256
257        let error = MailConfig::from_app_config(&config).unwrap().from().unwrap_err().to_string();
258        assert!(error.contains("mail.from.address"), "{error}");
259    }
260
261    #[test]
262    fn an_impossible_port_is_refused_rather_than_truncated() {
263        let config = Config::new();
264        config.set("mail.port", 99_999);
265
266        let error = MailConfig::from_app_config(&config).unwrap_err().to_string();
267        assert!(error.contains("not a TCP port"), "{error}");
268    }
269}