use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::paths::{read_or_empty, write_atomic};
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config.toml: {0}")]
Parse(String),
#[error("config.toml: {0}")]
Invalid(String),
#[error(transparent)]
Io(#[from] io::Error),
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub accounts: Vec<AccountConfig>,
#[serde(default, skip_serializing_if = "UiConfig::is_default")]
pub ui: UiConfig,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AccountConfig {
pub name: String,
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
pub username: String,
pub password: PasswordSource,
#[serde(default)]
pub address: Option<String>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default = "default_sync_interval")]
pub sync_interval_secs: u64,
#[serde(default = "default_retention")]
pub trash_retention_days: u64,
#[serde(default = "default_true")]
pub notify: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_file: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PasswordSource {
Keyring { keyring: bool },
Command { command: String },
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiConfig {
#[serde(default)]
pub check_updates: bool,
#[serde(default)]
pub sender_images: bool,
#[serde(default)]
pub theme: Theme,
}
impl UiConfig {
fn is_default(&self) -> bool {
*self == UiConfig::default()
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
Dark,
Light,
#[default]
System,
}
impl Theme {
pub fn as_str(self) -> &'static str {
match self {
Theme::Dark => "dark",
Theme::Light => "light",
Theme::System => "system",
}
}
}
fn default_port() -> u16 {
993
}
fn default_sync_interval() -> u64 {
120
}
fn default_retention() -> u64 {
30
}
fn default_true() -> bool {
true
}
impl Config {
pub fn parse(text: &str) -> Result<Config, ConfigError> {
let cfg: Config = toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))?;
cfg.validate()?;
Ok(cfg)
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
Config::parse(&read_or_empty(path)?)
}
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
let mut doc = read_document(path)?;
let old_accounts = doc
.remove("accounts")
.and_then(|item| item.into_array_of_tables().ok())
.unwrap_or_default();
let account_defaults = account_defaults()?;
let mut accounts = toml_edit::ArrayOfTables::new();
for account in &self.accounts {
let mut table = old_accounts
.iter()
.find(|old| old.get("name").and_then(|n| n.as_str()) == Some(account.name.as_str()))
.cloned()
.unwrap_or_default();
merge_table(&mut table, &to_table(account)?, &account_defaults);
accounts.push(table);
}
if !accounts.is_empty() {
doc.insert("accounts", toml_edit::Item::ArrayOfTables(accounts));
}
let mut ui = doc
.remove("ui")
.and_then(|item| item.into_table().ok())
.unwrap_or_default();
merge_table(
&mut ui,
&to_table(&self.ui)?,
&to_table(&UiConfig::default())?,
);
if !ui.is_empty() {
doc.insert("ui", toml_edit::Item::Table(ui));
}
write_atomic(path, doc.to_string().as_bytes())?;
Ok(())
}
pub fn account(&self, name: &str) -> Option<&AccountConfig> {
self.accounts.iter().find(|a| a.name == name)
}
pub fn validate(&self) -> Result<(), ConfigError> {
let mut seen = std::collections::HashSet::new();
for account in &self.accounts {
let name = &account.name;
let safe = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !safe {
return Err(ConfigError::Invalid(format!(
"account name '{name}' may only contain letters, digits, '-' and '_'"
)));
}
if !seen.insert(name) {
return Err(ConfigError::Invalid(format!(
"duplicate account name '{name}'"
)));
}
if !account.address().contains('@') {
return Err(ConfigError::Invalid(format!(
"account '{name}': `address` must be an email address; set it when the username is not one"
)));
}
if account.password == (PasswordSource::Keyring { keyring: false }) {
return Err(ConfigError::Invalid(format!(
"account '{name}': `password = {{ keyring = false }}` has no meaning; use `{{ keyring = true }}` or a `command`"
)));
}
if account.trash_retention_days == 0 {
return Err(ConfigError::Invalid(format!(
"account '{name}': trash_retention_days must be at least 1, or a rule delete keeps no backup"
)));
}
if let Some(ca_file) = &account.ca_file
&& !ca_file.is_absolute()
{
return Err(ConfigError::Invalid(format!(
"account '{name}': ca_file must be an absolute path"
)));
}
account.identity()?;
}
Ok(())
}
}
pub fn save_theme(path: &Path, theme: Theme) -> Result<(), ConfigError> {
let mut doc = read_document(path)?;
let ui = doc
.entry("ui")
.or_insert_with(toml_edit::table)
.as_table_like_mut()
.ok_or_else(|| ConfigError::Invalid("`ui` must be a table".into()))?;
ui.insert("theme", toml_edit::value(theme.as_str()));
write_atomic(path, doc.to_string().as_bytes())?;
Ok(())
}
fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
read_or_empty(path)?
.parse()
.map_err(|e: toml_edit::TomlError| ConfigError::Parse(e.to_string()))
}
fn to_table(value: &impl Serialize) -> Result<toml_edit::Table, ConfigError> {
let text = toml::to_string(value).map_err(|e| ConfigError::Invalid(e.to_string()))?;
let doc: toml_edit::DocumentMut = text
.parse()
.map_err(|e: toml_edit::TomlError| ConfigError::Parse(e.to_string()))?;
Ok(doc.as_table().clone())
}
fn account_defaults() -> Result<toml_edit::Table, ConfigError> {
let mut defaults = to_table(&AccountConfig::new(
"",
"",
"",
PasswordSource::Keyring { keyring: true },
))?;
for required in ["name", "host", "username", "password"] {
defaults.remove(required);
}
Ok(defaults)
}
fn merge_table(old: &mut toml_edit::Table, new: &toml_edit::Table, defaults: &toml_edit::Table) {
let gone: Vec<String> = old
.iter()
.map(|(key, _)| key.to_string())
.filter(|key| !new.contains_key(key))
.collect();
for key in gone {
old.remove(&key);
}
for (key, item) in new.iter() {
let Ok(mut value) = item.clone().into_value() else {
continue;
};
value.decor_mut().clear();
match old.get_mut(key) {
Some(existing) if same_value(existing, item) => {}
Some(existing) => {
if let Some(decor) = existing.as_value().map(|v| v.decor().clone()) {
*value.decor_mut() = decor;
}
*existing = toml_edit::Item::Value(value);
}
None if defaults.get(key).is_some_and(|d| same_value(d, item)) => {}
None => {
old.insert(key, toml_edit::Item::Value(value));
}
}
}
}
fn same_value(a: &toml_edit::Item, b: &toml_edit::Item) -> bool {
let plain = |item: &toml_edit::Item| {
let mut doc = toml_edit::DocumentMut::new();
doc.insert("v", item.clone());
toml::from_str::<toml::Table>(&doc.to_string())
.ok()
.and_then(|mut table| table.remove("v"))
};
plain(a).is_some_and(|value| Some(value) == plain(b))
}
impl AccountConfig {
pub fn new(name: &str, host: &str, username: &str, password: PasswordSource) -> AccountConfig {
AccountConfig {
name: name.into(),
host: host.into(),
port: default_port(),
username: username.into(),
password,
address: None,
aliases: Vec::new(),
sync_interval_secs: default_sync_interval(),
trash_retention_days: default_retention(),
notify: default_true(),
ca_file: None,
}
}
pub fn address(&self) -> &str {
self.address.as_deref().unwrap_or(&self.username)
}
pub fn identity(&self) -> Result<Identity, ConfigError> {
let aliases = regex::RegexSet::new(self.aliases.iter().map(|pattern| alias_regex(pattern)))
.map_err(|e| ConfigError::Invalid(format!("account '{}': aliases: {e}", self.name)))?;
Ok(Identity {
address: self.address().to_ascii_lowercase(),
aliases,
})
}
}
pub(crate) fn alias_regex(pattern: &str) -> String {
format!("(?i)^{}$", regex::escape(pattern).replace(r"\*", ".*"))
}
#[derive(Debug, Clone)]
pub struct Identity {
address: String,
aliases: regex::RegexSet,
}
impl Identity {
pub fn is_me(&self, addr: &str) -> bool {
let addr = addr.trim().to_ascii_lowercase();
addr == self.address || self.aliases.is_match(&addr)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alias_patterns_take_star_as_the_only_wildcard() {
let alias = |pattern: &str| regex::Regex::new(&alias_regex(pattern)).unwrap();
assert!(alias("*@shop.example.com").is_match("Orders@SHOP.example.com"));
assert!(!alias("*@shop.example.com").is_match("orders@shop.example.com.evil"));
assert!(alias("a+b@x.org").is_match("a+b@x.org"));
assert!(!alias("a+b@x.org").is_match("aab@xxorg"));
assert!(!alias("me?@x.org").is_match("mex@x.org"));
}
const SAMPLE: &str = r#"
[[accounts]]
name = "work"
host = "imap.example.com"
username = "pieter@example.com"
password = { keyring = true }
aliases = ["p@example.org", "*@shop.example.com"]
[[accounts]]
name = "home"
host = "mail.home.test"
port = 143
username = "pieter"
address = "pieter@home.test"
password = { command = "pass show mail/home" }
notify = false
"#;
#[test]
fn parses_accounts_with_defaults() {
let cfg = Config::parse(SAMPLE).unwrap();
let work = cfg.account("work").unwrap();
assert_eq!(work.port, 993);
assert_eq!(work.sync_interval_secs, 120);
assert_eq!(work.trash_retention_days, 30);
assert!(work.notify);
assert!(matches!(
work.password,
PasswordSource::Keyring { keyring: true }
));
let home = cfg.account("home").unwrap();
assert_eq!(home.port, 143);
assert!(!home.notify);
assert!(
matches!(&home.password, PasswordSource::Command { command } if command == "pass show mail/home")
);
}
#[test]
fn new_gives_the_defaults_config_toml_gives() {
let cfg = Config::parse(SAMPLE).unwrap();
let mut work = cfg.account("work").unwrap().clone();
work.aliases.clear();
let new = AccountConfig::new(
"work",
"imap.example.com",
"pieter@example.com",
PasswordSource::Keyring { keyring: true },
);
assert_eq!(new, work);
}
#[test]
fn address_defaults_to_username_when_it_is_an_email() {
let cfg = Config::parse(SAMPLE).unwrap();
assert_eq!(cfg.account("work").unwrap().address(), "pieter@example.com");
assert_eq!(cfg.account("home").unwrap().address(), "pieter@home.test");
}
#[test]
fn identity_matches_address_and_aliases_case_insensitively() {
let cfg = Config::parse(SAMPLE).unwrap();
let id = cfg.account("work").unwrap().identity().unwrap();
assert!(id.is_me("Pieter@Example.com"));
assert!(id.is_me("p@example.org"));
assert!(id.is_me("orders@shop.example.com"));
assert!(!id.is_me("someone@example.com"));
}
#[test]
fn rejects_duplicate_and_unsafe_names() {
let dup = SAMPLE.replace("name = \"home\"", "name = \"work\"");
assert!(matches!(Config::parse(&dup), Err(ConfigError::Invalid(_))));
let bad = SAMPLE.replace("name = \"home\"", "name = \"ho/me\"");
assert!(matches!(Config::parse(&bad), Err(ConfigError::Invalid(_))));
}
#[test]
fn rejects_a_trash_retention_of_zero_days() {
let bad = SAMPLE.replace(
"name = \"home\"",
"name = \"home\"\ntrash_retention_days = 0",
);
assert!(
matches!(Config::parse(&bad), Err(ConfigError::Invalid(e)) if e.contains("trash_retention_days")),
);
}
#[test]
fn rejects_keyring_false() {
let bad = SAMPLE.replace("{ keyring = true }", "{ keyring = false }");
assert!(
matches!(Config::parse(&bad), Err(ConfigError::Invalid(e)) if e.contains("keyring = false")),
"keyring = false must not quietly use the keyring"
);
}
#[test]
fn rejects_unknown_account_keys() {
let typo = SAMPLE.replace("aliases = ", "alias = ");
assert!(
matches!(Config::parse(&typo), Err(ConfigError::Parse(e)) if e.contains("alias")),
"a misspelled key must not be ignored"
);
}
#[test]
fn rejects_unknown_top_level_keys() {
let typo = SAMPLE.replace("[[accounts]]", "[[account]]");
assert!(
matches!(Config::parse(&typo), Err(ConfigError::Parse(e)) if e.contains("account")),
"a misspelled table must not leave the user without accounts"
);
}
#[test]
fn rejects_username_without_at_and_no_address() {
let bad = SAMPLE.replace("address = \"pieter@home.test\"\n", "");
assert!(matches!(Config::parse(&bad), Err(ConfigError::Invalid(_))));
}
#[test]
fn rejects_an_address_that_is_no_email_address() {
let bad = SAMPLE.replace("pieter@home.test", "pieter");
assert!(matches!(Config::parse(&bad), Err(ConfigError::Invalid(_))));
}
#[test]
fn relative_ca_file_is_rejected() {
let text = "[[accounts]]\nname = \"self\"\nhost = \"localhost\"\nusername = \"me@example.com\"\npassword = { keyring = true }\nca_file = \"certs/ca.pem\"\n";
let err = Config::parse(text).unwrap_err().to_string();
assert!(err.contains("ca_file must be an absolute path"), "{err}");
let absolute = text.replace("certs/ca.pem", "/etc/ssl/self/ca.pem");
assert!(
Config::parse(&absolute).unwrap().accounts[0]
.ca_file
.is_some()
);
}
#[test]
fn load_missing_file_is_empty_and_save_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = Config::load(&path).unwrap();
assert!(cfg.accounts.is_empty());
let cfg = Config::parse(SAMPLE).unwrap();
cfg.save(&path).unwrap();
let again = Config::load(&path).unwrap();
assert_eq!(again.accounts.len(), 2);
}
#[test]
fn ui_theme_defaults_to_system() {
assert_eq!(Config::parse(SAMPLE).unwrap().ui.theme, Theme::System);
}
#[test]
fn ui_theme_parses_and_rejects_unknown_values() {
let dark = format!("{SAMPLE}\n[ui]\ntheme = \"dark\"\n");
assert_eq!(Config::parse(&dark).unwrap().ui.theme, Theme::Dark);
let blue = format!("{SAMPLE}\n[ui]\ntheme = \"blue\"\n");
assert!(matches!(Config::parse(&blue), Err(ConfigError::Parse(_))));
let typo = format!("{SAMPLE}\n[ui]\ntheme_ = \"dark\"\n");
assert!(matches!(Config::parse(&typo), Err(ConfigError::Parse(_))));
}
#[test]
fn save_theme_keeps_the_rest_of_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let text = format!("# my accounts\n{SAMPLE}");
std::fs::write(&path, &text).unwrap();
save_theme(&path, Theme::Dark).unwrap();
let saved = std::fs::read_to_string(&path).unwrap();
assert!(saved.starts_with(&text), "{saved}");
assert!(saved.contains("[ui]\ntheme = \"dark\""), "{saved}");
save_theme(&path, Theme::Light).unwrap();
let saved = std::fs::read_to_string(&path).unwrap();
assert!(
saved.contains("theme = \"light\"") && !saved.contains("dark"),
"{saved}"
);
let cfg = Config::load(&path).unwrap();
assert_eq!((cfg.ui.theme, cfg.accounts.len()), (Theme::Light, 2));
}
#[test]
fn save_keeps_comments_and_writes_only_what_changed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
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";
std::fs::write(&path, text).unwrap();
let mut cfg = Config::load(&path).unwrap();
cfg.accounts[0].port = 994;
cfg.accounts[0].aliases = vec!["p@example.org".into()];
cfg.accounts.retain(|a| a.name != "home");
let password = PasswordSource::Command {
command: "pass show mail/extra".into(),
};
cfg.accounts.push(AccountConfig::new(
"extra",
"imap.extra.test",
"me@extra.test",
password,
));
cfg.save(&path).unwrap();
let saved = std::fs::read_to_string(&path).unwrap();
for kept in [
"# my accounts",
"# the day job",
"host = \"imap.example.com\" # via the VPN",
] {
assert!(saved.contains(kept), "lost {kept:?}:\n{saved}");
}
assert!(
saved.contains("port = 994") && !saved.contains("home"),
"{saved}"
);
assert!(
!saved.contains("sync_interval_secs"),
"defaults are left out:\n{saved}"
);
assert!(
saved.contains("password = { command = \"pass show mail/extra\" }"),
"{saved}"
);
let again = Config::load(&path).unwrap();
assert_eq!(again.accounts, cfg.accounts);
}
#[test]
fn save_round_trip_leaves_out_a_default_ui_table() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
Config::parse(SAMPLE).unwrap().save(&path).unwrap();
assert!(!std::fs::read_to_string(&path).unwrap().contains("[ui]"));
}
#[test]
fn check_updates_is_off_unless_turned_on() {
assert!(!Config::default().ui.check_updates);
assert!(!Config::parse(SAMPLE).unwrap().ui.check_updates);
let theme_only = format!("{SAMPLE}\n[ui]\ntheme = \"dark\"\n");
assert!(!Config::parse(&theme_only).unwrap().ui.check_updates);
let on = format!("{SAMPLE}\n[ui]\ncheck_updates = true\n");
assert!(Config::parse(&on).unwrap().ui.check_updates);
}
#[test]
fn sender_images_are_off_unless_turned_on() {
assert!(!Config::default().ui.sender_images);
assert!(!Config::parse(SAMPLE).unwrap().ui.sender_images);
let on = format!("{SAMPLE}\n[ui]\nsender_images = true\n");
assert!(Config::parse(&on).unwrap().ui.sender_images);
}
}