Skip to main content

sofie_mail/
settings.rs

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";
15/// Below the shared build directory, so a preview is never committed.
16const 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    /// Debug builds only: a logged mail's links would open accounts to whoever reads the log.
27    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    /// Never across a network: a relay on the same host, or a test's server.
45    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}