vomit_config/
lib.rs

1//! This crate provides a unified config file for all tools that are part of the [Vomit project](https://sr.ht/~bitfehler/vomit).
2//!
3//! It aims to provide configuration options for various aspects of email accounts. Most tools will only need a subset of the
4//! available options, but once an account is fully configured, all tools will be able to work with it.
5//!
6//! While written for the Vomit project, this is essentially a generic email account configuration library. If you think it
7//! might be useful for you feel free to use outside of Vomit.
8//!
9//! The standard location is `$XDG_CONFIG_DIR`/vomit/config.toml, which usually means `~/.config/vomit/config.toml`.
10//!
11//! Projects using this will have their own documentation about which values they require. Here is a sample with
12//! all available options (though some commented out), including comments on their usage:
13//!
14//! ```toml
15//! ## This section defines one account named "example". Most tools
16//! ## support multiple accounts. If not specified, tools should default
17//! ## to the first account in the config file.
18//! [example]
19//! ## `local` defaults to "~/.maildir", but can be set explicitly:
20//! local = '/home/test/.maildir'
21//! ## The mail server. Must support IMAPS
22//! remote = 'imap.example.com'
23//! ## Login name for the mail server
24//! user = 'johndoe'
25//! ## Password for the mail server. Can be set explicitly:
26//! #password = 'hunter1'
27//! ## but that's not great for security. Instead use a command,
28//! ## e.g. to interact with your password manager
29//! pass-cmd = 'pass show mail/example.com'
30//! ## If sending mail is handled by a different server (i.e. a dedicated
31//! ## SMTP server), override it like this:
32//! send.remote = 'smtp.example.com'
33//! ## Some mail setups even have different credentials for sending mail,
34//! ## So those can be overridden, too:
35//! send.user = 'johndoe@example.com'
36//! ## See password and pass-cmd above:
37//! #send.password = 's3cr3t'
38//! send.pass-cmd = 'pass show mail/smtp.example.com'
39//! ```
40
41use std::fs;
42use std::io;
43use std::path::{Path, PathBuf};
44use std::process::Command;
45
46use dirs::{config_dir, home_dir};
47use shellexpand::tilde;
48use thiserror::Error;
49
50const DEFAULT_MAILDIR: &str = "~/.maildir";
51
52#[derive(Error, Debug)]
53pub enum ConfigError {
54    #[error("error getting default config file location")]
55    DirError,
56    #[error("error reading config file: {0}")]
57    IOError(#[from] io::Error),
58    #[error("error executing pass-cmd: {0}")]
59    PassExecError(String),
60    #[error("UTF-8 error: {0}")]
61    UTF8Error(#[from] std::string::FromUtf8Error),
62    #[error("error parsing config file: {0}")]
63    TOMLError(#[from] toml::de::Error),
64    #[error("config error: {0}")]
65    Error(&'static str),
66}
67
68/// Represents the configuration of a specific account
69pub struct Account<'a> {
70    name: String,
71    settings: &'a toml::value::Table,
72    // Keep a copy of this, as it is potentially computed (tilde expansion)
73    local: String,
74}
75
76/// Represents a complete config file, which can hold multiple [Accounts](Account)
77pub struct Config {
78    cfg: toml::value::Table,
79}
80
81impl Config {
82    /// Returns a specific account's configuration
83    ///
84    /// # Arguments
85    ///
86    /// * `name` - The name of the account. If `None`, returns the configuration of the first account in the config file.
87    pub fn for_account(&self, name: Option<&str>) -> Option<Account<'_>> {
88        let name = match name {
89            Some(n) => n,
90            // Leaving this a .expect() as load() already checks that there is an account
91            None => self.cfg.keys().next().expect("no account found"),
92        };
93
94        if let Some(a) = self.cfg.get(name) {
95            if let Some(t) = a.as_table() {
96                // Compute the local maildir
97                let mut path = DEFAULT_MAILDIR;
98                if let Some(l) = t.get("local") {
99                    if let Some(p) = l.as_str() {
100                        path = p
101                    }
102                };
103
104                return Some(Account {
105                    name: String::from(name),
106                    settings: t,
107                    local: tilde(path).into_owned(),
108                });
109            }
110        }
111        None
112    }
113}
114
115impl Account<'_> {
116    /// The name of the account
117    pub fn name(&self) -> &String {
118        &self.name
119    }
120
121    /// The directory where mail is stored locally
122    pub fn local(&self) -> &str {
123        &self.local
124    }
125
126    /// The remote IMAP server
127    pub fn remote(&self) -> Option<&str> {
128        self.settings.get("remote").and_then(|v| v.as_str())
129    }
130
131    /// The user name to log in to the remote server
132    pub fn user(&self) -> Option<&str> {
133        self.settings.get("user").and_then(|v| v.as_str())
134    }
135
136    /// The "From" address to use when sending mail
137    ///
138    /// Can be just an email address or "Some Name <name@example.com>".
139    pub fn send_from(&self) -> Option<&str> {
140        let v = self
141            .settings
142            .get("send")
143            .and_then(|v| v.as_table())
144            .and_then(|v| v.get("from"))
145            .and_then(|v| v.as_str());
146        v.or_else(|| self.user())
147    }
148
149    /// The user name to log in when sending mail
150    ///
151    /// If not explicitly configured, this returns the value of [user()](Account::user).
152    pub fn send_user(&self) -> Option<&str> {
153        let v = self
154            .settings
155            .get("send")
156            .and_then(|v| v.as_table())
157            .and_then(|v| v.get("user"))
158            .and_then(|v| v.as_str());
159        v.or_else(|| self.user())
160    }
161
162    /// The remote SMTP server
163    ///
164    /// If not explicitly configured, this returns the value of [remote()](Account::remote).
165    pub fn send_remote(&self) -> Option<&str> {
166        let v = self
167            .settings
168            .get("send")
169            .and_then(|v| v.as_table())
170            .and_then(|v| v.get("remote"))
171            .and_then(|v| v.as_str());
172        v.or_else(|| self.remote())
173    }
174
175    fn pass_cmd(cmd: &str) -> Result<Option<String>, ConfigError> {
176        let out = Command::new("sh").arg("-c").arg(cmd).output();
177        match out {
178            Ok(mut output) => {
179                let newline: u8 = 10;
180                if Some(&newline) == output.stdout.last() {
181                    _ = output.stdout.pop(); // remove trailing newline
182                }
183                Ok(Some(String::from_utf8(output.stdout)?))
184            }
185            Err(e) => Err(ConfigError::PassExecError(e.to_string())),
186        }
187    }
188
189    /// The password to log in to the remote server
190    ///
191    /// Potentially executes a configured command to retrieve the password.
192    /// Returns an error if the password command has to be executed and fails. Can return `Ok(None)` if neither
193    /// a password nor a password command are configured. Otherwise, returns the password.
194    pub fn password(&self) -> Result<Option<String>, ConfigError> {
195        if let Some(p) = self.settings.get("password").and_then(|v| v.as_str()) {
196            return Ok(Some(String::from(p)));
197        } else if let Some(cmd) = self.settings.get("pass-cmd").and_then(|v| v.as_str()) {
198            return Account::pass_cmd(cmd);
199        }
200        Ok(None)
201    }
202
203    /// The password to log in to the remote server for sending mail
204    ///
205    /// Potentially executes a configured command to retrieve the password.
206    /// Returns an error if the password command has to be executed and fails. Can return `Ok(None)` if neither
207    /// a password nor a password command are configured. Otherwise, returns the password.
208    ///
209    /// Unless explicitly configured by the user, this returns the value of [password()](Account::password).
210    pub fn send_password(&self) -> Result<Option<String>, ConfigError> {
211        let pass = self
212            .settings
213            .get("send")
214            .and_then(|v| v.as_table())
215            .and_then(|v| v.get("password"))
216            .and_then(|v| v.as_str());
217        if let Some(p) = pass {
218            return Ok(Some(String::from(p)));
219        }
220
221        let pcmd = self
222            .settings
223            .get("send")
224            .and_then(|v| v.as_table())
225            .and_then(|v| v.get("pass-cmd"))
226            .and_then(|v| v.as_str());
227        if let Some(cmd) = pcmd {
228            return Account::pass_cmd(cmd);
229        }
230        self.password()
231    }
232}
233
234/// Returns the (system dependent) default path to the configuration file
235///
236/// Can fail if the user's home directory cannot be determined.
237pub fn default_path() -> Result<PathBuf, ConfigError> {
238    if let Some(mut path) = config_dir() {
239        // let mut cfg = PathBuf::from(path);
240        path.push("vomit");
241        path.push("config.toml");
242        return Ok(path);
243    };
244    if let Some(mut path) = home_dir() {
245        path.push(".vomitrc");
246        return Ok(path);
247    }
248    Err(ConfigError::DirError)
249}
250
251/// Load a configuration file
252///
253/// # Arguments
254///
255/// * `path` - the path to load the file from. If `None`, load it from the default location.
256///
257/// See [default_path] for the default location.
258pub fn load<P: AsRef<Path>>(path: Option<P>) -> Result<Config, ConfigError> {
259    let contents = match path {
260        Some(p) => fs::read_to_string(p)?,
261        None => fs::read_to_string(default_path()?)?,
262    };
263
264    let table = contents.parse::<toml::Table>()?;
265    if table.is_empty() {
266        Err(ConfigError::Error("no accounts found"))
267    } else {
268        Ok(Config { cfg: table })
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_simple() {
278        let table: toml::value::Table = toml::from_str(
279            r#"
280            [example]
281            local = '/home/test/.maildir'
282            remote = 'mx.example.com'
283            user = 'johndoe'
284            password = 'hunter1'
285        "#,
286        )
287        .unwrap();
288
289        let config = Config { cfg: table };
290
291        let acc = config.for_account(None).expect("no account found");
292
293        assert_eq!(acc.user(), Some("johndoe"));
294        assert_eq!(acc.remote(), Some("mx.example.com"));
295        assert_eq!(
296            acc.password().expect("failed to get password"),
297            Some(String::from("hunter1"))
298        );
299        assert_eq!(acc.send_user(), Some("johndoe"));
300        assert_eq!(acc.send_remote(), Some("mx.example.com"));
301        assert_eq!(
302            acc.send_password().expect("failed to get password"),
303            Some(String::from("hunter1"))
304        );
305    }
306
307    #[test]
308    fn test_minimal() {
309        let table: toml::value::Table = toml::from_str(
310            r#"
311            [example]
312        "#,
313        )
314        .unwrap();
315
316        let config = Config { cfg: table };
317
318        let _ = config.for_account(None).expect("no account found");
319    }
320
321    #[test]
322    fn test_send() {
323        let table: toml::value::Table = toml::from_str(
324            r#"
325            [example]
326            local = '/home/test/.maildir'
327            remote = 'imap.example.com'
328            user = 'johndoe'
329            password = 'hunter1'
330            send.remote = 'smtp.example.com'
331            send.user = 'johndoe@example.com'
332            send.password = 's3cr3t'
333        "#,
334        )
335        .unwrap();
336
337        let config = Config { cfg: table };
338
339        let acc = config.for_account(None).expect("no account found");
340
341        assert_eq!(acc.user(), Some("johndoe"));
342        assert_eq!(acc.remote(), Some("imap.example.com"));
343        assert_eq!(
344            acc.password().expect("failed to get password"),
345            Some(String::from("hunter1"))
346        );
347        assert_eq!(acc.send_user(), Some("johndoe@example.com"));
348        assert_eq!(acc.send_remote(), Some("smtp.example.com"));
349        assert_eq!(
350            acc.send_password().expect("failed to get password"),
351            Some(String::from("s3cr3t"))
352        );
353    }
354
355    #[test]
356    fn test_simple_cmd() {
357        let table: toml::value::Table = toml::from_str(
358            r#"
359            [example]
360            local = '/home/test/.maildir'
361            remote = 'mx.example.com'
362            user = 'johndoe'
363            pass-cmd = 'echo hunter1'
364        "#,
365        )
366        .unwrap();
367
368        let config = Config { cfg: table };
369
370        let acc = config.for_account(None).expect("no account found");
371
372        assert_eq!(acc.user(), Some("johndoe"));
373        assert_eq!(acc.remote(), Some("mx.example.com"));
374        assert_eq!(
375            acc.password().expect("failed to get password"),
376            Some(String::from("hunter1"))
377        );
378        assert_eq!(acc.send_user(), Some("johndoe"));
379        assert_eq!(acc.send_remote(), Some("mx.example.com"));
380        assert_eq!(
381            acc.send_password().expect("failed to get password"),
382            Some(String::from("hunter1"))
383        );
384    }
385
386    #[test]
387    fn test_send_cmd() {
388        let table: toml::value::Table = toml::from_str(
389            r#"
390            [example]
391            local = '/home/test/.maildir'
392            remote = 'imap.example.com'
393            user = 'johndoe'
394            pass-cmd = 'echo hunter1'
395            send.remote = 'smtp.example.com'
396            send.user = 'johndoe@example.com'
397            send.pass-cmd = 'echo s3cr3t'
398        "#,
399        )
400        .unwrap();
401
402        let config = Config { cfg: table };
403
404        let acc = config.for_account(None).expect("no account found");
405
406        assert_eq!(acc.user(), Some("johndoe"));
407        assert_eq!(acc.remote(), Some("imap.example.com"));
408        assert_eq!(
409            acc.password().expect("failed to get password"),
410            Some(String::from("hunter1"))
411        );
412        assert_eq!(acc.send_user(), Some("johndoe@example.com"));
413        assert_eq!(acc.send_remote(), Some("smtp.example.com"));
414        assert_eq!(
415            acc.send_password().expect("failed to get password"),
416            Some(String::from("s3cr3t"))
417        );
418    }
419}