1use sofie_core::{Error, Result};
2use sofie_values::Secret;
3
4use crate::Address;
5
6const TRANSPORT: &str = "SOFIE_MAIL_TRANSPORT";
7const FROM: &str = "SOFIE_MAIL_FROM";
8const BASE_URL: &str = "SOFIE_MAIL_BASE_URL";
9const SMTP_HOST: &str = "SOFIE_MAIL_SMTP_HOST";
10const SMTP_PORT: &str = "SOFIE_MAIL_SMTP_PORT";
11const SMTP_SECURITY: &str = "SOFIE_MAIL_SMTP_SECURITY";
12const SMTP_USERNAME: &str = "SOFIE_MAIL_SMTP_USERNAME";
13const SMTP_PASSWORD: &str = "SOFIE_MAIL_SMTP_PASSWORD";
14const PREVIEW_DIR: &str = "SOFIE_MAIL_PREVIEW_DIR";
15const DEFAULT_PREVIEW_DIR: &str = "target/mail-preview";
17
18pub(crate) struct Settings {
19 pub(crate) from: Address,
20 pub(crate) base_url: Option<String>,
21 pub(crate) transport: Chosen,
22}
23
24pub(crate) enum Chosen {
25 Smtp(SmtpSettings),
26 Log {
28 preview: Option<std::path::PathBuf>,
29 },
30}
31
32#[derive(Clone)]
33pub struct SmtpSettings {
34 pub(crate) host: String,
35 pub(crate) port: u16,
36 pub(crate) security: Security,
37 pub(crate) credentials: Option<Credentials>,
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Security {
42 StartTls,
43 Tls,
44 None,
46}
47
48#[derive(Clone)]
49pub(crate) struct Credentials {
50 pub(crate) username: String,
51 pub(crate) password: Secret<String>,
52}
53
54#[cfg(feature = "testing")]
55impl SmtpSettings {
56 #[must_use]
57 pub fn new(host: &str, port: u16, security: Security) -> Self {
58 Self {
59 host: host.to_owned(),
60 port,
61 security,
62 credentials: None,
63 }
64 }
65
66 #[must_use]
67 pub fn with_credentials(self, username: &str, password: &str) -> Self {
68 Self {
69 credentials: Some(Credentials {
70 username: username.to_owned(),
71 password: Secret::new(password.to_owned()),
72 }),
73 ..self
74 }
75 }
76}
77
78impl Settings {
79 pub(crate) fn read(text: impl Fn(&str) -> Result<Option<String>>) -> Result<Self> {
80 let required = |name: &str| {
81 text(name)?.ok_or_else(|| Error::internal_message(format!("{name} is not set")))
82 };
83 let from = Address::parse(&required(FROM)?)
84 .map_err(|error| Error::internal(format!("{FROM} is not a mail address"), error))?;
85 let base_url = sofie_core::base_url(BASE_URL, &text)?
86 .map(|variable| base_url(variable.name, &variable.value))
87 .transpose()?;
88 let transport = match required(TRANSPORT)?.trim() {
89 "smtp" => Chosen::Smtp(smtp(&text)?),
90 "log" if cfg!(debug_assertions) => Chosen::Log {
91 preview: match text(PREVIEW_DIR)?.as_deref().map(str::trim) {
92 Some("off") => None,
93 Some(folder) => Some(folder.into()),
94 None => Some(DEFAULT_PREVIEW_DIR.into()),
95 },
96 },
97 "log" => {
98 return Err(Error::internal_message(format!(
99 "{TRANSPORT} `log` writes mails, links included, to the log; \
100 it is for debug builds only"
101 )));
102 }
103 other => {
104 return Err(Error::internal_message(format!(
105 "{TRANSPORT} `{other}` is neither `smtp` nor `log`"
106 )));
107 }
108 };
109 Ok(Self {
110 from,
111 base_url,
112 transport,
113 })
114 }
115}
116
117pub(crate) fn missing_base_url(purpose: &str) -> Error {
118 sofie_core::base_url_missing(BASE_URL, purpose)
119}
120
121fn base_url(name: &str, url: &str) -> Result<String> {
122 let url = url.trim().trim_end_matches('/');
123 let host = url
124 .strip_prefix("https://")
125 .or_else(|| url.strip_prefix("http://"));
126 match host {
127 Some(host)
128 if !host.is_empty()
129 && !host.contains(|character: char| {
130 character.is_whitespace()
131 || character.is_control()
132 || matches!(character, '/' | '?' | '#' | '@' | '"' | '<' | '>' | '\\')
133 }) =>
134 {
135 Ok(url.to_owned())
136 }
137 _ => Err(Error::internal_message(format!(
138 "{name} must be an http or https origin such as https://shop.example.com"
139 ))),
140 }
141}
142
143fn smtp(text: impl Fn(&str) -> Result<Option<String>>) -> Result<SmtpSettings> {
144 let host = text(SMTP_HOST)?
145 .filter(|host| !host.trim().is_empty())
146 .ok_or_else(|| Error::internal_message(format!("{SMTP_HOST} is not set")))?;
147 let security = match text(SMTP_SECURITY)?.as_deref().map(str::trim) {
148 None | Some("starttls") => Security::StartTls,
149 Some("tls") => Security::Tls,
150 Some("none") => Security::None,
151 Some(other) => {
152 return Err(Error::internal_message(format!(
153 "{SMTP_SECURITY} `{other}` is none of `starttls`, `tls` and `none`"
154 )));
155 }
156 };
157 let port = match text(SMTP_PORT)? {
158 Some(port) => port.trim().parse().map_err(|_| {
159 Error::internal_message(format!("{SMTP_PORT} `{port}` is not a port number"))
160 })?,
161 None => default_port(security),
162 };
163 let credentials = match (text(SMTP_USERNAME)?, text(SMTP_PASSWORD)?) {
164 (None, None) => None,
165 (Some(_), Some(_)) if security == Security::None => {
166 return Err(Error::internal_message(format!(
167 "{SMTP_USERNAME} and {SMTP_PASSWORD} would cross the network in clear text; \
168 set {SMTP_SECURITY} to `starttls` or `tls`"
169 )));
170 }
171 (Some(username), Some(password)) => Some(Credentials {
172 username,
173 password: Secret::new(password),
174 }),
175 _ => {
176 return Err(Error::internal_message(format!(
177 "{SMTP_USERNAME} and {SMTP_PASSWORD} are set together or not at all"
178 )));
179 }
180 };
181 Ok(SmtpSettings {
182 host: host.trim().to_owned(),
183 port,
184 security,
185 credentials,
186 })
187}
188
189fn default_port(security: Security) -> u16 {
190 match security {
191 Security::StartTls => 587,
192 Security::Tls => 465,
193 Security::None => 25,
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 fn read(variables: &[(&str, &str)]) -> Result<Settings> {
202 Settings::read(|name| {
203 Ok(variables
204 .iter()
205 .find(|(variable, _)| *variable == name)
206 .map(|(_, value)| (*value).to_owned()))
207 })
208 }
209
210 const FROM_SHOP: (&str, &str) = (FROM, "Shop <shop@example.com>");
211
212 #[test]
213 fn smtp_defaults_to_starttls_on_587() {
214 let settings = read(&[
215 FROM_SHOP,
216 (TRANSPORT, "smtp"),
217 (SMTP_HOST, "smtp.example.com"),
218 (BASE_URL, "https://shop.example.com/"),
219 ])
220 .unwrap();
221
222 let Chosen::Smtp(smtp) = settings.transport else {
223 panic!("smtp was chosen");
224 };
225 assert_eq!((smtp.host.as_str(), smtp.port), ("smtp.example.com", 587));
226 assert_eq!(smtp.security, Security::StartTls);
227 assert!(smtp.credentials.is_none());
228 assert_eq!(settings.from.name(), Some("Shop"));
229 assert_eq!(
230 settings.base_url.as_deref(),
231 Some("https://shop.example.com")
232 );
233 }
234
235 #[test]
236 fn missing_and_malformed_keys_are_named() {
237 let cases: [(&[(&str, &str)], &str); 7] = [
238 (&[(TRANSPORT, "smtp")], "SOFIE_MAIL_FROM is not set"),
239 (&[FROM_SHOP], "SOFIE_MAIL_TRANSPORT is not set"),
240 (&[FROM_SHOP, (TRANSPORT, "pigeon")], "`pigeon` is neither"),
241 (
242 &[FROM_SHOP, (TRANSPORT, "smtp")],
243 "SOFIE_MAIL_SMTP_HOST is not set",
244 ),
245 (
246 &[
247 FROM_SHOP,
248 (TRANSPORT, "log"),
249 (BASE_URL, "shop.example.com"),
250 ],
251 "SOFIE_MAIL_BASE_URL must be",
252 ),
253 (
254 &[
255 FROM_SHOP,
256 (TRANSPORT, "smtp"),
257 (SMTP_HOST, "h"),
258 (SMTP_USERNAME, "u"),
259 ],
260 "set together or not at all",
261 ),
262 (
263 &[
264 FROM_SHOP,
265 (TRANSPORT, "smtp"),
266 (SMTP_HOST, "h"),
267 (SMTP_SECURITY, "none"),
268 (SMTP_USERNAME, "u"),
269 (SMTP_PASSWORD, "p"),
270 ],
271 "in clear text",
272 ),
273 ];
274 for (variables, expected) in cases {
275 let error = read(variables).err().expect(expected).to_string();
276 assert!(error.contains(expected), "{error}");
277 }
278 }
279
280 #[test]
281 fn the_shared_base_url_serves_unless_mail_has_its_own() {
282 let log = [FROM_SHOP, (TRANSPORT, "log")];
283 let shared = read(&[log[0], log[1], (sofie_core::BASE_URL, "https://shop.test/")]);
284 assert_eq!(
285 shared.unwrap().base_url.as_deref(),
286 Some("https://shop.test")
287 );
288
289 let own = (BASE_URL, "https://mail.shop.test");
290 let both = read(&[
291 log[0],
292 log[1],
293 (sofie_core::BASE_URL, "https://shop.test"),
294 own,
295 ]);
296 assert_eq!(
297 both.unwrap().base_url.as_deref(),
298 Some("https://mail.shop.test")
299 );
300
301 let malformed = read(&[log[0], log[1], (sofie_core::BASE_URL, "shop.test")]);
302 let error = malformed.err().unwrap().to_string();
303 assert!(error.starts_with("SOFIE_BASE_URL must be"), "{error}");
304 }
305
306 #[test]
307 fn a_base_url_holding_markup_or_spaces_is_refused() {
308 for url in [
309 "https://shop.test\"x",
310 "https://shop .test",
311 "https://shop.test<",
312 "https://a\\b",
313 ] {
314 assert!(base_url(BASE_URL, url).is_err(), "{url}");
315 }
316 assert_eq!(
317 base_url(BASE_URL, "https://shop.test/").unwrap(),
318 "https://shop.test"
319 );
320 }
321
322 #[test]
323 fn implicit_tls_defaults_to_465_and_a_port_can_be_set() {
324 let base = [FROM_SHOP, (TRANSPORT, "smtp"), (SMTP_HOST, "h")];
325 let tls = read(&[base[0], base[1], base[2], (SMTP_SECURITY, "tls")]).unwrap();
326 let custom = read(&[base[0], base[1], base[2], (SMTP_PORT, "2525")]).unwrap();
327
328 let (Chosen::Smtp(tls), Chosen::Smtp(custom)) = (tls.transport, custom.transport) else {
329 panic!("smtp was chosen");
330 };
331 assert_eq!((tls.security, tls.port), (Security::Tls, 465));
332 assert_eq!(custom.port, 2525);
333 }
334}