1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum TransportKind {
28 Smtp,
30 #[default]
32 Log,
33 File,
35}
36
37impl TransportKind {
38 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#[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 pub path: PathBuf,
76 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 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 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 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 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
179fn 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 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}