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}