Skip to main content

vomit_config/
lib.rs

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