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 #[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 #[serde(default)]
63 pub check_updates: bool,
64 #[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 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
208pub 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
235fn 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
249fn 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
281fn 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 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
325pub(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}