//! Deserialized config module. //! //! This module contains the raw deserialized representation of the //! user configuration file. use anyhow::{anyhow, Context, Result}; use dialoguer::Confirm; use dirs::{config_dir, home_dir}; use email::{ account::AccountConfig, config::Config, email::{EmailHooks, EmailTextPlainFormat}, }; use log::{debug, trace}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fs, path::PathBuf, process::exit}; use toml; use crate::{ account::DeserializedAccountConfig, backend::BackendKind, config::{prelude::*, wizard}, wizard_prompt, wizard_warn, }; /// Represents the user config file. #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct DeserializedConfig { #[serde(alias = "name")] pub display_name: Option, pub signature_delim: Option, pub signature: Option, pub downloads_dir: Option, pub folder_listing_page_size: Option, pub folder_aliases: Option>, pub email_listing_page_size: Option, pub email_listing_datetime_fmt: Option, pub email_listing_datetime_local_tz: Option, pub email_reading_headers: Option>, #[serde(default, with = "OptionEmailTextPlainFormatDef")] pub email_reading_format: Option, pub email_writing_headers: Option>, pub email_sending_save_copy: Option, #[serde(default, with = "OptionEmailHooksDef")] pub email_hooks: Option, #[serde(flatten)] pub accounts: HashMap, } impl DeserializedConfig { /// Tries to create a config from an optional path. pub async fn from_opt_path(path: Option<&str>) -> Result { debug!("path: {:?}", path); let config = if let Some(path) = path.map(PathBuf::from).or_else(Self::path) { let content = fs::read_to_string(path).context("cannot read config file")?; toml::from_str(&content).context("cannot parse config file")? } else { wizard_warn!("Himalaya could not find an already existing configuration file."); if !Confirm::new() .with_prompt(wizard_prompt!( "Would you like to create one with the wizard?" )) .default(true) .interact_opt()? .unwrap_or_default() { exit(0); } wizard::configure().await? }; if config.accounts.is_empty() { return Err(anyhow!("config file must contain at least one account")); } trace!("config: {:#?}", config); Ok(config) } /// Tries to return a config path from a few default settings. /// /// Tries paths in this order: /// /// - `"$XDG_CONFIG_DIR/himalaya/config.toml"` (or equivalent to `$XDG_CONFIG_DIR` in other /// OSes.) /// - `"$HOME/.config/himalaya/config.toml"` /// - `"$HOME/.himalayarc"` /// /// Returns `Some(path)` if the path exists, otherwise `None`. pub fn path() -> Option { config_dir() .map(|p| p.join("himalaya").join("config.toml")) .filter(|p| p.exists()) .or_else(|| home_dir().map(|p| p.join(".config").join("himalaya").join("config.toml"))) .filter(|p| p.exists()) .or_else(|| home_dir().map(|p| p.join(".himalayarc"))) .filter(|p| p.exists()) } pub fn into_account_configs( self, account_name: Option<&str>, disable_cache: bool, ) -> Result<(DeserializedAccountConfig, AccountConfig)> { let (account_name, mut toml_account_config) = match account_name { Some("default") | Some("") | None => self .accounts .iter() .find_map(|(name, account)| { account .default .filter(|default| *default == true) .map(|_| (name.to_owned(), account.clone())) }) .ok_or_else(|| anyhow!("cannot find default account")), Some(name) => self .accounts .get(name) .map(|account| (name.to_owned(), account.clone())) .ok_or_else(|| anyhow!("cannot find account {name}")), }?; #[cfg(feature = "imap-backend")] if let Some(imap_config) = toml_account_config.imap.as_mut() { imap_config .auth .replace_undefined_keyring_entries(&account_name); } #[cfg(feature = "smtp-sender")] if let Some(smtp_config) = toml_account_config.smtp.as_mut() { smtp_config .auth .replace_undefined_keyring_entries(&account_name); } if let Some(true) = toml_account_config.sync { if !disable_cache { toml_account_config.backend = Some(BackendKind::MaildirForSync); } } let config = Config { display_name: self.display_name, signature_delim: self.signature_delim, signature: self.signature, downloads_dir: self.downloads_dir, folder_listing_page_size: self.folder_listing_page_size, folder_aliases: self.folder_aliases, email_listing_page_size: self.email_listing_page_size, email_listing_datetime_fmt: self.email_listing_datetime_fmt, email_listing_datetime_local_tz: self.email_listing_datetime_local_tz, email_reading_headers: self.email_reading_headers, email_reading_format: self.email_reading_format, email_writing_headers: self.email_writing_headers, email_sending_save_copy: self.email_sending_save_copy, email_hooks: self.email_hooks, accounts: HashMap::from_iter(self.accounts.clone().into_iter().map( |(name, config)| { ( name.clone(), AccountConfig { name, email: config.email, display_name: config.display_name, signature_delim: config.signature_delim, signature: config.signature, downloads_dir: config.downloads_dir, folder_listing_page_size: config.folder_listing_page_size, folder_aliases: config.folder_aliases.unwrap_or_default(), email_listing_page_size: config.email_listing_page_size, email_listing_datetime_fmt: config.email_listing_datetime_fmt, email_listing_datetime_local_tz: config.email_listing_datetime_local_tz, email_reading_headers: config.email_reading_headers, email_reading_format: config.email_reading_format.unwrap_or_default(), email_writing_headers: config.email_writing_headers, email_sending_save_copy: config.email_sending_save_copy, email_hooks: config.email_hooks.unwrap_or_default(), sync: config.sync.unwrap_or_default(), sync_dir: config.sync_dir, sync_folders_strategy: config.sync_folders_strategy.unwrap_or_default(), #[cfg(feature = "pgp")] pgp: config.pgp, }, ) }, )), }; let account_config = config.account(&account_name)?; Ok((toml_account_config, account_config)) } } #[cfg(test)] mod tests { use email::{ account::PasswdConfig, backend::{BackendConfig, MaildirConfig}, sender::{SenderConfig, SendmailConfig}, }; use secret::Secret; #[cfg(feature = "notmuch-backend")] use email::backend::NotmuchConfig; #[cfg(feature = "imap-backend")] use email::backend::{ImapAuthConfig, ImapConfig}; #[cfg(feature = "smtp-sender")] use email::sender::{SmtpAuthConfig, SmtpConfig}; use std::io::Write; use tempfile::NamedTempFile; use super::*; async fn make_config(config: &str) -> Result { let mut file = NamedTempFile::new().unwrap(); write!(file, "{}", config).unwrap(); DeserializedConfig::from_opt_path(file.into_temp_path().to_str()).await } #[tokio::test] async fn empty_config() { let config = make_config("").await; assert_eq!( config.unwrap_err().root_cause().to_string(), "config file must contain at least one account" ); } #[tokio::test] async fn account_missing_email_field() { let config = make_config("[account]").await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `email`")); } #[tokio::test] async fn account_missing_backend_field() { let config = make_config( "[account] email = \"test@localhost\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `backend`")); } #[tokio::test] async fn account_invalid_backend_field() { let config = make_config( "[account] email = \"test@localhost\" backend = \"bad\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("unknown variant `bad`")); } #[tokio::test] async fn imap_account_missing_host_field() { let config = make_config( "[account] email = \"test@localhost\" sender = \"none\" backend = \"imap\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `imap-host`")); } #[tokio::test] async fn account_backend_imap_missing_port_field() { let config = make_config( "[account] email = \"test@localhost\" sender = \"none\" backend = \"imap\" imap-host = \"localhost\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `imap-port`")); } #[tokio::test] async fn account_backend_imap_missing_login_field() { let config = make_config( "[account] email = \"test@localhost\" sender = \"none\" backend = \"imap\" imap-host = \"localhost\" imap-port = 993", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `imap-login`")); } #[tokio::test] async fn account_backend_imap_missing_passwd_cmd_field() { let config = make_config( "[account] email = \"test@localhost\" sender = \"none\" backend = \"imap\" imap-host = \"localhost\" imap-port = 993 imap-login = \"login\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `imap-auth`")); } #[tokio::test] async fn account_backend_maildir_missing_root_dir_field() { let config = make_config( "[account] email = \"test@localhost\" sender = \"none\" backend = \"maildir\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `maildir-root-dir`")); } #[cfg(feature = "notmuch-backend")] #[tokio::test] async fn account_backend_notmuch_missing_db_path_field() { let config = make_config( "[account] email = \"test@localhost\" sender = \"none\" backend = \"notmuch\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `notmuch-db-path`")); } #[tokio::test] async fn account_missing_sender_field() { let config = make_config( "[account] email = \"test@localhost\" backend = \"none\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `sender`")); } #[tokio::test] async fn account_invalid_sender_field() { let config = make_config( "[account] email = \"test@localhost\" backend = \"none\" sender = \"bad\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("unknown variant `bad`, expected one of `none`, `smtp`, `sendmail`"),); } #[tokio::test] async fn account_smtp_sender_missing_host_field() { let config = make_config( "[account] email = \"test@localhost\" backend = \"none\" sender = \"smtp\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `smtp-host`")); } #[tokio::test] async fn account_smtp_sender_missing_port_field() { let config = make_config( "[account] email = \"test@localhost\" backend = \"none\" sender = \"smtp\" smtp-host = \"localhost\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `smtp-port`")); } #[tokio::test] async fn account_smtp_sender_missing_login_field() { let config = make_config( "[account] email = \"test@localhost\" backend = \"none\" sender = \"smtp\" smtp-host = \"localhost\" smtp-port = 25", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `smtp-login`")); } #[tokio::test] async fn account_smtp_sender_missing_auth_field() { let config = make_config( "[account] email = \"test@localhost\" backend = \"none\" sender = \"smtp\" smtp-host = \"localhost\" smtp-port = 25 smtp-login = \"login\"", ) .await; assert!(config .unwrap_err() .root_cause() .to_string() .contains("missing field `smtp-auth`")); } #[tokio::test] async fn account_sendmail_sender_missing_cmd_field() { let config = make_config( "[account] email = \"test@localhost\" backend = \"none\" sender = \"sendmail\"", ) .await; assert_eq!( config.unwrap(), DeserializedConfig { accounts: HashMap::from_iter([( "account".into(), DeserializedAccountConfig { email: "test@localhost".into(), sender: SenderConfig::Sendmail(SendmailConfig { cmd: "/usr/sbin/sendmail".into() }), ..DeserializedAccountConfig::default() } )]), ..DeserializedConfig::default() } ) } #[cfg(feature = "smtp-sender")] #[tokio::test] async fn account_smtp_sender_minimum_config() { use email::sender::SenderConfig; let config = make_config( "[account] email = \"test@localhost\" backend = \"none\" sender = \"smtp\" smtp-host = \"localhost\" smtp-port = 25 smtp-login = \"login\" smtp-auth = \"passwd\" smtp-passwd = { cmd = \"echo password\" }", ) .await; assert_eq!( config.unwrap(), DeserializedConfig { accounts: HashMap::from_iter([( "account".into(), DeserializedAccountConfig { email: "test@localhost".into(), sender: SenderConfig::Smtp(SmtpConfig { host: "localhost".into(), port: 25, login: "login".into(), auth: SmtpAuthConfig::Passwd(PasswdConfig { passwd: Secret::new_cmd(String::from("echo password")) }), ..SmtpConfig::default() }), ..DeserializedAccountConfig::default() } )]), ..DeserializedConfig::default() } ) } #[tokio::test] async fn account_sendmail_sender_minimum_config() { let config = make_config( "[account] email = \"test@localhost\" backend = \"none\" sender = \"sendmail\" sendmail-cmd = \"echo send\"", ) .await; assert_eq!( config.unwrap(), DeserializedConfig { accounts: HashMap::from_iter([( "account".into(), DeserializedAccountConfig { email: "test@localhost".into(), sender: SenderConfig::Sendmail(SendmailConfig { cmd: Cmd::from("echo send") }), ..DeserializedAccountConfig::default() } )]), ..DeserializedConfig::default() } ) } #[tokio::test] async fn account_backend_imap_minimum_config() { let config = make_config( "[account] email = \"test@localhost\" sender = \"none\" backend = \"imap\" imap-host = \"localhost\" imap-port = 993 imap-login = \"login\" imap-auth = \"passwd\" imap-passwd = { cmd = \"echo password\" }", ) .await; assert_eq!( config.unwrap(), DeserializedConfig { accounts: HashMap::from_iter([( "account".into(), DeserializedAccountConfig { email: "test@localhost".into(), backend: BackendConfig::Imap(ImapConfig { host: "localhost".into(), port: 993, login: "login".into(), auth: ImapAuthConfig::Passwd(PasswdConfig { passwd: Secret::new_cmd(String::from("echo password")) }), ..ImapConfig::default() }), ..DeserializedAccountConfig::default() } )]), ..DeserializedConfig::default() } ) } #[tokio::test] async fn account_backend_maildir_minimum_config() { let config = make_config( "[account] email = \"test@localhost\" sender = \"none\" backend = \"maildir\" maildir-root-dir = \"/tmp/maildir\"", ) .await; assert_eq!( config.unwrap(), DeserializedConfig { accounts: HashMap::from_iter([( "account".into(), DeserializedAccountConfig { email: "test@localhost".into(), backend: BackendConfig::Maildir(MaildirConfig { root_dir: "/tmp/maildir".into(), }), ..DeserializedAccountConfig::default() } )]), ..DeserializedConfig::default() } ) } #[cfg(feature = "notmuch-backend")] #[tokio::test] async fn account_backend_notmuch_minimum_config() { let config = make_config( "[account] email = \"test@localhost\" sender = \"none\" backend = \"notmuch\" notmuch-db-path = \"/tmp/notmuch.db\"", ) .await; assert_eq!( config.unwrap(), DeserializedConfig { accounts: HashMap::from_iter([( "account".into(), DeserializedAccountConfig { email: "test@localhost".into(), backend: BackendConfig::Notmuch(NotmuchConfig { db_path: "/tmp/notmuch.db".into(), }), ..DeserializedAccountConfig::default() } )]), ..DeserializedConfig::default() } ); } }