Skip to main content

postvak_core/
config.rs

1use std::io;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::paths::{read_or_empty, write_atomic};
7
8#[derive(Debug, thiserror::Error)]
9pub enum ConfigError {
10    #[error("config.toml: {0}")]
11    Parse(String),
12    #[error("config.toml: {0}")]
13    Invalid(String),
14    #[error(transparent)]
15    Io(#[from] io::Error),
16}
17
18#[derive(Debug, Default, Clone, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Config {
21    #[serde(default)]
22    pub accounts: Vec<AccountConfig>,
23    #[serde(default, skip_serializing_if = "UiConfig::is_default")]
24    pub ui: UiConfig,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct AccountConfig {
30    pub name: String,
31    pub host: String,
32    #[serde(default = "default_port")]
33    pub port: u16,
34    pub username: String,
35    pub password: PasswordSource,
36    #[serde(default)]
37    pub address: Option<String>,
38    #[serde(default)]
39    pub aliases: Vec<String>,
40    #[serde(default = "default_sync_interval")]
41    pub sync_interval_secs: u64,
42    #[serde(default = "default_retention")]
43    pub trash_retention_days: u64,
44    #[serde(default = "default_true")]
45    pub notify: bool,
46    /// Extra trusted root certificate (PEM), for servers with a private CA.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub ca_file: Option<PathBuf>,
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(untagged)]
53pub enum PasswordSource {
54    Keyring { keyring: bool },
55    Command { command: String },
56}
57
58#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct UiConfig {
61    /// Ask GitHub once a day whether a newer release is out.
62    #[serde(default)]
63    pub check_updates: bool,
64    /// Show the logo a sender's domain publishes (BIMI): a DNS lookup and an HTTPS fetch to that domain.
65    #[serde(default)]
66    pub sender_images: bool,
67    #[serde(default)]
68    pub theme: Theme,
69}
70
71impl UiConfig {
72    fn is_default(&self) -> bool {
73        *self == UiConfig::default()
74    }
75}
76
77#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "lowercase")]
79pub enum Theme {
80    Dark,
81    Light,
82    #[default]
83    System,
84}
85
86impl Theme {
87    pub fn as_str(self) -> &'static str {
88        match self {
89            Theme::Dark => "dark",
90            Theme::Light => "light",
91            Theme::System => "system",
92        }
93    }
94}
95
96fn default_port() -> u16 {
97    993
98}
99fn default_sync_interval() -> u64 {
100    120
101}
102fn default_retention() -> u64 {
103    30
104}
105fn default_true() -> bool {
106    true
107}
108
109impl Config {
110    pub fn parse(text: &str) -> Result<Config, ConfigError> {
111        let cfg: Config = toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))?;
112        cfg.validate()?;
113        Ok(cfg)
114    }
115
116    pub fn load(path: &Path) -> Result<Config, ConfigError> {
117        Config::parse(&read_or_empty(path)?)
118    }
119
120    /// Writes the accounts and `[ui]` into the file in place: comments, untouched keys and their order stay, and a
121    /// setting the file leaves out is only written once it differs from its default.
122    pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
123        let mut doc = read_document(path)?;
124        let old_accounts = doc
125            .remove("accounts")
126            .and_then(|item| item.into_array_of_tables().ok())
127            .unwrap_or_default();
128        let account_defaults = account_defaults()?;
129        let mut accounts = toml_edit::ArrayOfTables::new();
130        for account in &self.accounts {
131            let mut table = old_accounts
132                .iter()
133                .find(|old| old.get("name").and_then(|n| n.as_str()) == Some(account.name.as_str()))
134                .cloned()
135                .unwrap_or_default();
136            merge_table(&mut table, &to_table(account)?, &account_defaults);
137            accounts.push(table);
138        }
139        if !accounts.is_empty() {
140            doc.insert("accounts", toml_edit::Item::ArrayOfTables(accounts));
141        }
142        let mut ui = doc
143            .remove("ui")
144            .and_then(|item| item.into_table().ok())
145            .unwrap_or_default();
146        merge_table(
147            &mut ui,
148            &to_table(&self.ui)?,
149            &to_table(&UiConfig::default())?,
150        );
151        if !ui.is_empty() {
152            doc.insert("ui", toml_edit::Item::Table(ui));
153        }
154        write_atomic(path, doc.to_string().as_bytes())?;
155        Ok(())
156    }
157
158    pub fn account(&self, name: &str) -> Option<&AccountConfig> {
159        self.accounts.iter().find(|a| a.name == name)
160    }
161
162    pub fn validate(&self) -> Result<(), ConfigError> {
163        let mut seen = std::collections::HashSet::new();
164        for account in &self.accounts {
165            let name = &account.name;
166            let safe = !name.is_empty()
167                && name
168                    .chars()
169                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
170            if !safe {
171                return Err(ConfigError::Invalid(format!(
172                    "account name '{name}' may only contain letters, digits, '-' and '_'"
173                )));
174            }
175            if !seen.insert(name) {
176                return Err(ConfigError::Invalid(format!(
177                    "duplicate account name '{name}'"
178                )));
179            }
180            if !account.address().contains('@') {
181                return Err(ConfigError::Invalid(format!(
182                    "account '{name}': `address` must be an email address; set it when the username is not one"
183                )));
184            }
185            if account.password == (PasswordSource::Keyring { keyring: false }) {
186                return Err(ConfigError::Invalid(format!(
187                    "account '{name}': `password = {{ keyring = false }}` has no meaning; use `{{ keyring = true }}` or a `command`"
188                )));
189            }
190            if account.trash_retention_days == 0 {
191                return Err(ConfigError::Invalid(format!(
192                    "account '{name}': trash_retention_days must be at least 1, or a rule delete keeps no backup"
193                )));
194            }
195            if let Some(ca_file) = &account.ca_file
196                && !ca_file.is_absolute()
197            {
198                return Err(ConfigError::Invalid(format!(
199                    "account '{name}': ca_file must be an absolute path"
200                )));
201            }
202            account.identity()?;
203        }
204        Ok(())
205    }
206}
207
208/// Sets `[ui] theme`, keeping the rest of the file's text and comments.
209pub fn save_theme(path: &Path, theme: Theme) -> Result<(), ConfigError> {
210    let mut doc = read_document(path)?;
211    let ui = doc
212        .entry("ui")
213        .or_insert_with(toml_edit::table)
214        .as_table_like_mut()
215        .ok_or_else(|| ConfigError::Invalid("`ui` must be a table".into()))?;
216    ui.insert("theme", toml_edit::value(theme.as_str()));
217    write_atomic(path, doc.to_string().as_bytes())?;
218    Ok(())
219}
220
221fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
222    read_or_empty(path)?
223        .parse()
224        .map_err(|e: toml_edit::TomlError| ConfigError::Parse(e.to_string()))
225}
226
227fn to_table(value: &impl Serialize) -> Result<toml_edit::Table, ConfigError> {
228    let text = toml::to_string(value).map_err(|e| ConfigError::Invalid(e.to_string()))?;
229    let doc: toml_edit::DocumentMut = text
230        .parse()
231        .map_err(|e: toml_edit::TomlError| ConfigError::Parse(e.to_string()))?;
232    Ok(doc.as_table().clone())
233}
234
235/// The values an account gets for the keys it leaves out; the required keys are never defaults.
236fn account_defaults() -> Result<toml_edit::Table, ConfigError> {
237    let mut defaults = to_table(&AccountConfig::new(
238        "",
239        "",
240        "",
241        PasswordSource::Keyring { keyring: true },
242    ))?;
243    for required in ["name", "host", "username", "password"] {
244        defaults.remove(required);
245    }
246    Ok(defaults)
247}
248
249/// Makes `old` hold `new`'s values: keys `new` lacks go, changed values are replaced keeping their comments, and a
250/// key `old` lacks is only added when it differs from `defaults`. Nested tables are written inline.
251fn merge_table(old: &mut toml_edit::Table, new: &toml_edit::Table, defaults: &toml_edit::Table) {
252    let gone: Vec<String> = old
253        .iter()
254        .map(|(key, _)| key.to_string())
255        .filter(|key| !new.contains_key(key))
256        .collect();
257    for key in gone {
258        old.remove(&key);
259    }
260    for (key, item) in new.iter() {
261        let Ok(mut value) = item.clone().into_value() else {
262            continue;
263        };
264        value.decor_mut().clear();
265        match old.get_mut(key) {
266            Some(existing) if same_value(existing, item) => {}
267            Some(existing) => {
268                if let Some(decor) = existing.as_value().map(|v| v.decor().clone()) {
269                    *value.decor_mut() = decor;
270                }
271                *existing = toml_edit::Item::Value(value);
272            }
273            None if defaults.get(key).is_some_and(|d| same_value(d, item)) => {}
274            None => {
275                old.insert(key, toml_edit::Item::Value(value));
276            }
277        }
278    }
279}
280
281/// Whether two items hold the same data, whatever their layout: an inline table equals a `[section]`.
282fn same_value(a: &toml_edit::Item, b: &toml_edit::Item) -> bool {
283    let plain = |item: &toml_edit::Item| {
284        let mut doc = toml_edit::DocumentMut::new();
285        doc.insert("v", item.clone());
286        toml::from_str::<toml::Table>(&doc.to_string())
287            .ok()
288            .and_then(|mut table| table.remove("v"))
289    };
290    plain(a).is_some_and(|value| Some(value) == plain(b))
291}
292
293impl AccountConfig {
294    /// An account with the defaults config.toml gives the settings it leaves out.
295    pub fn new(name: &str, host: &str, username: &str, password: PasswordSource) -> AccountConfig {
296        AccountConfig {
297            name: name.into(),
298            host: host.into(),
299            port: default_port(),
300            username: username.into(),
301            password,
302            address: None,
303            aliases: Vec::new(),
304            sync_interval_secs: default_sync_interval(),
305            trash_retention_days: default_retention(),
306            notify: default_true(),
307            ca_file: None,
308        }
309    }
310
311    pub fn address(&self) -> &str {
312        self.address.as_deref().unwrap_or(&self.username)
313    }
314
315    pub fn identity(&self) -> Result<Identity, ConfigError> {
316        let aliases = regex::RegexSet::new(self.aliases.iter().map(|pattern| alias_regex(pattern)))
317            .map_err(|e| ConfigError::Invalid(format!("account '{}': aliases: {e}", self.name)))?;
318        Ok(Identity {
319            address: self.address().to_ascii_lowercase(),
320            aliases,
321        })
322    }
323}
324
325/// An alias pattern as a regex over the whole address, ignoring case; `*` matches anything.
326pub(crate) fn alias_regex(pattern: &str) -> String {
327    format!("(?i)^{}$", regex::escape(pattern).replace(r"\*", ".*"))
328}
329
330#[derive(Debug, Clone)]
331pub struct Identity {
332    address: String,
333    aliases: regex::RegexSet,
334}
335
336impl Identity {
337    pub fn is_me(&self, addr: &str) -> bool {
338        let addr = addr.trim().to_ascii_lowercase();
339        addr == self.address || self.aliases.is_match(&addr)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn alias_patterns_take_star_as_the_only_wildcard() {
349        let alias = |pattern: &str| regex::Regex::new(&alias_regex(pattern)).unwrap();
350        assert!(alias("*@shop.example.com").is_match("Orders@SHOP.example.com"));
351        assert!(!alias("*@shop.example.com").is_match("orders@shop.example.com.evil"));
352        assert!(alias("a+b@x.org").is_match("a+b@x.org"));
353        assert!(!alias("a+b@x.org").is_match("aab@xxorg"));
354        assert!(!alias("me?@x.org").is_match("mex@x.org"));
355    }
356
357    const SAMPLE: &str = r#"
358[[accounts]]
359name = "work"
360host = "imap.example.com"
361username = "pieter@example.com"
362password = { keyring = true }
363aliases = ["p@example.org", "*@shop.example.com"]
364
365[[accounts]]
366name = "home"
367host = "mail.home.test"
368port = 143
369username = "pieter"
370address = "pieter@home.test"
371password = { command = "pass show mail/home" }
372notify = false
373"#;
374
375    #[test]
376    fn parses_accounts_with_defaults() {
377        let cfg = Config::parse(SAMPLE).unwrap();
378        let work = cfg.account("work").unwrap();
379        assert_eq!(work.port, 993);
380        assert_eq!(work.sync_interval_secs, 120);
381        assert_eq!(work.trash_retention_days, 30);
382        assert!(work.notify);
383        assert!(matches!(
384            work.password,
385            PasswordSource::Keyring { keyring: true }
386        ));
387        let home = cfg.account("home").unwrap();
388        assert_eq!(home.port, 143);
389        assert!(!home.notify);
390        assert!(
391            matches!(&home.password, PasswordSource::Command { command } if command == "pass show mail/home")
392        );
393    }
394
395    #[test]
396    fn new_gives_the_defaults_config_toml_gives() {
397        let cfg = Config::parse(SAMPLE).unwrap();
398        let mut work = cfg.account("work").unwrap().clone();
399        work.aliases.clear();
400        let new = AccountConfig::new(
401            "work",
402            "imap.example.com",
403            "pieter@example.com",
404            PasswordSource::Keyring { keyring: true },
405        );
406        assert_eq!(new, work);
407    }
408
409    #[test]
410    fn address_defaults_to_username_when_it_is_an_email() {
411        let cfg = Config::parse(SAMPLE).unwrap();
412        assert_eq!(cfg.account("work").unwrap().address(), "pieter@example.com");
413        assert_eq!(cfg.account("home").unwrap().address(), "pieter@home.test");
414    }
415
416    #[test]
417    fn identity_matches_address_and_aliases_case_insensitively() {
418        let cfg = Config::parse(SAMPLE).unwrap();
419        let id = cfg.account("work").unwrap().identity().unwrap();
420        assert!(id.is_me("Pieter@Example.com"));
421        assert!(id.is_me("p@example.org"));
422        assert!(id.is_me("orders@shop.example.com"));
423        assert!(!id.is_me("someone@example.com"));
424    }
425
426    #[test]
427    fn rejects_duplicate_and_unsafe_names() {
428        let dup = SAMPLE.replace("name = \"home\"", "name = \"work\"");
429        assert!(matches!(Config::parse(&dup), Err(ConfigError::Invalid(_))));
430        let bad = SAMPLE.replace("name = \"home\"", "name = \"ho/me\"");
431        assert!(matches!(Config::parse(&bad), Err(ConfigError::Invalid(_))));
432    }
433
434    #[test]
435    fn rejects_a_trash_retention_of_zero_days() {
436        let bad = SAMPLE.replace(
437            "name = \"home\"",
438            "name = \"home\"\ntrash_retention_days = 0",
439        );
440        assert!(
441            matches!(Config::parse(&bad), Err(ConfigError::Invalid(e)) if e.contains("trash_retention_days")),
442        );
443    }
444
445    #[test]
446    fn rejects_keyring_false() {
447        let bad = SAMPLE.replace("{ keyring = true }", "{ keyring = false }");
448        assert!(
449            matches!(Config::parse(&bad), Err(ConfigError::Invalid(e)) if e.contains("keyring = false")),
450            "keyring = false must not quietly use the keyring"
451        );
452    }
453
454    #[test]
455    fn rejects_unknown_account_keys() {
456        let typo = SAMPLE.replace("aliases = ", "alias = ");
457        assert!(
458            matches!(Config::parse(&typo), Err(ConfigError::Parse(e)) if e.contains("alias")),
459            "a misspelled key must not be ignored"
460        );
461    }
462
463    #[test]
464    fn rejects_unknown_top_level_keys() {
465        let typo = SAMPLE.replace("[[accounts]]", "[[account]]");
466        assert!(
467            matches!(Config::parse(&typo), Err(ConfigError::Parse(e)) if e.contains("account")),
468            "a misspelled table must not leave the user without accounts"
469        );
470    }
471
472    #[test]
473    fn rejects_username_without_at_and_no_address() {
474        let bad = SAMPLE.replace("address = \"pieter@home.test\"\n", "");
475        assert!(matches!(Config::parse(&bad), Err(ConfigError::Invalid(_))));
476    }
477
478    #[test]
479    fn rejects_an_address_that_is_no_email_address() {
480        let bad = SAMPLE.replace("pieter@home.test", "pieter");
481        assert!(matches!(Config::parse(&bad), Err(ConfigError::Invalid(_))));
482    }
483
484    #[test]
485    fn relative_ca_file_is_rejected() {
486        let text = "[[accounts]]\nname = \"self\"\nhost = \"localhost\"\nusername = \"me@example.com\"\npassword = { keyring = true }\nca_file = \"certs/ca.pem\"\n";
487        let err = Config::parse(text).unwrap_err().to_string();
488        assert!(err.contains("ca_file must be an absolute path"), "{err}");
489        let absolute = text.replace("certs/ca.pem", "/etc/ssl/self/ca.pem");
490        assert!(
491            Config::parse(&absolute).unwrap().accounts[0]
492                .ca_file
493                .is_some()
494        );
495    }
496
497    #[test]
498    fn load_missing_file_is_empty_and_save_round_trips() {
499        let dir = tempfile::tempdir().unwrap();
500        let path = dir.path().join("config.toml");
501        let cfg = Config::load(&path).unwrap();
502        assert!(cfg.accounts.is_empty());
503        let cfg = Config::parse(SAMPLE).unwrap();
504        cfg.save(&path).unwrap();
505        let again = Config::load(&path).unwrap();
506        assert_eq!(again.accounts.len(), 2);
507    }
508
509    #[test]
510    fn ui_theme_defaults_to_system() {
511        assert_eq!(Config::parse(SAMPLE).unwrap().ui.theme, Theme::System);
512    }
513
514    #[test]
515    fn ui_theme_parses_and_rejects_unknown_values() {
516        let dark = format!("{SAMPLE}\n[ui]\ntheme = \"dark\"\n");
517        assert_eq!(Config::parse(&dark).unwrap().ui.theme, Theme::Dark);
518        let blue = format!("{SAMPLE}\n[ui]\ntheme = \"blue\"\n");
519        assert!(matches!(Config::parse(&blue), Err(ConfigError::Parse(_))));
520        let typo = format!("{SAMPLE}\n[ui]\ntheme_ = \"dark\"\n");
521        assert!(matches!(Config::parse(&typo), Err(ConfigError::Parse(_))));
522    }
523
524    #[test]
525    fn save_theme_keeps_the_rest_of_the_file() {
526        let dir = tempfile::tempdir().unwrap();
527        let path = dir.path().join("config.toml");
528        let text = format!("# my accounts\n{SAMPLE}");
529        std::fs::write(&path, &text).unwrap();
530        save_theme(&path, Theme::Dark).unwrap();
531        let saved = std::fs::read_to_string(&path).unwrap();
532        assert!(saved.starts_with(&text), "{saved}");
533        assert!(saved.contains("[ui]\ntheme = \"dark\""), "{saved}");
534        save_theme(&path, Theme::Light).unwrap();
535        let saved = std::fs::read_to_string(&path).unwrap();
536        assert!(
537            saved.contains("theme = \"light\"") && !saved.contains("dark"),
538            "{saved}"
539        );
540        let cfg = Config::load(&path).unwrap();
541        assert_eq!((cfg.ui.theme, cfg.accounts.len()), (Theme::Light, 2));
542    }
543
544    #[test]
545    fn save_keeps_comments_and_writes_only_what_changed() {
546        let dir = tempfile::tempdir().unwrap();
547        let path = dir.path().join("config.toml");
548        let text = "# my accounts\n\n# the day job\n[[accounts]]\nname = \"work\"\nhost = \"imap.example.com\" # via the VPN\nport = 993\nusername = \"pieter@example.com\"\npassword = { keyring = true }\n\n[[accounts]]\nname = \"home\"\nhost = \"mail.home.test\"\nusername = \"pieter@home.test\"\npassword = { keyring = true }\n";
549        std::fs::write(&path, text).unwrap();
550        let mut cfg = Config::load(&path).unwrap();
551        cfg.accounts[0].port = 994;
552        cfg.accounts[0].aliases = vec!["p@example.org".into()];
553        cfg.accounts.retain(|a| a.name != "home");
554        let password = PasswordSource::Command {
555            command: "pass show mail/extra".into(),
556        };
557        cfg.accounts.push(AccountConfig::new(
558            "extra",
559            "imap.extra.test",
560            "me@extra.test",
561            password,
562        ));
563        cfg.save(&path).unwrap();
564
565        let saved = std::fs::read_to_string(&path).unwrap();
566        for kept in [
567            "# my accounts",
568            "# the day job",
569            "host = \"imap.example.com\" # via the VPN",
570        ] {
571            assert!(saved.contains(kept), "lost {kept:?}:\n{saved}");
572        }
573        assert!(
574            saved.contains("port = 994") && !saved.contains("home"),
575            "{saved}"
576        );
577        assert!(
578            !saved.contains("sync_interval_secs"),
579            "defaults are left out:\n{saved}"
580        );
581        assert!(
582            saved.contains("password = { command = \"pass show mail/extra\" }"),
583            "{saved}"
584        );
585        let again = Config::load(&path).unwrap();
586        assert_eq!(again.accounts, cfg.accounts);
587    }
588
589    #[test]
590    fn save_round_trip_leaves_out_a_default_ui_table() {
591        let dir = tempfile::tempdir().unwrap();
592        let path = dir.path().join("config.toml");
593        Config::parse(SAMPLE).unwrap().save(&path).unwrap();
594        assert!(!std::fs::read_to_string(&path).unwrap().contains("[ui]"));
595    }
596
597    #[test]
598    fn check_updates_is_off_unless_turned_on() {
599        assert!(!Config::default().ui.check_updates);
600        assert!(!Config::parse(SAMPLE).unwrap().ui.check_updates);
601        let theme_only = format!("{SAMPLE}\n[ui]\ntheme = \"dark\"\n");
602        assert!(!Config::parse(&theme_only).unwrap().ui.check_updates);
603        let on = format!("{SAMPLE}\n[ui]\ncheck_updates = true\n");
604        assert!(Config::parse(&on).unwrap().ui.check_updates);
605    }
606
607    #[test]
608    fn sender_images_are_off_unless_turned_on() {
609        assert!(!Config::default().ui.sender_images);
610        assert!(!Config::parse(SAMPLE).unwrap().ui.sender_images);
611        let on = format!("{SAMPLE}\n[ui]\nsender_images = true\n");
612        assert!(Config::parse(&on).unwrap().ui.sender_images);
613    }
614}