diff --git a/CHANGELOG.md b/CHANGELOG.md index ef171a7..61704dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,42 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added keyring support, which means Himalaya can now use your - system's global keyring to get/set sensitive data like passwords or - tokens. -- Added required IMAP option `imap-auth` and SMTP option - `smtp-auth`. Possible values: `passwd`, `oauth2`. -- Added OAuth 2.0 support for IMAP and SMTP. To use it, set `imap-auth - = "oauth2"`. You also need these options: - - - `imap-oauth2-method` - - `imap-oauth2-client-id` - - `imap-oauth2-client-secret` or `imap-oauth2-client-secret-cmd` or - `imap-oauth2-client-secret-keyring` - - `imap-oauth2-auth-url` - - `imap-oauth2-token-url` - - `imap-oauth2-access-token` or `imap-oauth2-access-token-cmd` or - `imap-oauth2-access-token-keyring` - - `imap-oauth2-refresh-token` or `imap-oauth2-refresh-token-cmd` or - `imap-oauth2-refresh-token-keyring` - - `imap-oauth2-scope` or `imap-oauth2-scopes` - - `imap-oauth2-pkce` +- Added keyring support, which means Himalaya can now use your system's global keyring to get/set sensitive data like passwords or tokens. +- Added required IMAP option `imap-auth` and SMTP option `smtp-auth`. Possible values: `passwd`, `oauth2`. +- Added OAuth 2.0 support for IMAP and SMTP. +- Added passwords and OAuth 2.0 configuration via the wizard. ### Changed -- Changed the way secrets are managed. A secret is a sensitive data - like passwords or tokens. There is 3 possible ways to declare a - secret in the config file: - - - ` = "secret-value"` for the raw secret (unsafe, not - recommanded), - - `-cmd = "echo 'secret-value'"` for command that retrieve the - secret, - - `-keyring = "keyring-entry"` for entry in your system's - global keyring that contains the secret. +- Changed the default TLS provider to `rustls`. You can still use `native-tls` with the cargo feature `native-tls`. +- Changed the way secrets are managed. A secret is a sensitive data like passwords or tokens. There is 3 possible ways to declare a secret in the config file: + - `{ raw = }` for the raw secret (unsafe, not recommanded), + - `{ cmd = }` for command that exposes the secret, + - `{ keyring = }` for entry in your system's global keyring that contains the secret. This applies for: - - `imap-passwd` - `imap-oauth2-client-secret` - `imap-oauth2-access-token` diff --git a/Cargo.lock b/Cargo.lock index 406fb25..f49d5bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,6 +1114,7 @@ dependencies = [ "once_cell", "pimalaya-email", "pimalaya-keyring", + "pimalaya-oauth2", "pimalaya-process", "pimalaya-secret", "rusqlite", @@ -1124,6 +1125,7 @@ dependencies = [ "termcolor", "terminal_size", "toml", + "toml_edit", "unicode-width", "url", "uuid", @@ -1300,11 +1302,11 @@ dependencies = [ [[package]] name = "imap" -version = "3.0.0-alpha.9" +version = "3.0.0-alpha.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c2ff52273d9cd791687b4510d8a0047277e985a348e411c94fe84e193e7a76" +checksum = "9cceec1222cd3c9b196695fe296dc6ddaa617e06b0c49742140ff9bbc87af628" dependencies = [ - "base64 0.13.1", + "base64 0.21.0", "bufstream", "chrono", "imap-proto", @@ -1704,15 +1706,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom8" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae01545c9c7fc4486ab7debaf2aad7003ac19431791868fb2e8066df97fad2f8" -dependencies = [ - "memchr 2.5.0", -] - [[package]] name = "notmuch" version = "0.8.0" @@ -2050,8 +2043,9 @@ dependencies = [ [[package]] name = "pimalaya-email" -version = "0.7.1" -source = "git+https://git.sr.ht/~soywod/pimalaya#05818504b399a911b88255b7d15592ee834bfbb3" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ffcfdf5fbbca7539e3d762a5c5b3b2b6fd58fc3d996b2295f094c7f394553ad" dependencies = [ "advisory-lock", "ammonia", @@ -2092,8 +2086,9 @@ dependencies = [ [[package]] name = "pimalaya-email-tpl" -version = "0.1.0" -source = "git+https://git.sr.ht/~soywod/pimalaya#05818504b399a911b88255b7d15592ee834bfbb3" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0a03c25c249b598bddd24a0fe1c06d044c9bb8362644792e902146c8b5b613" dependencies = [ "ammonia", "chumsky 0.9.0", @@ -2110,7 +2105,8 @@ dependencies = [ [[package]] name = "pimalaya-keyring" version = "0.0.1" -source = "git+https://git.sr.ht/~soywod/pimalaya#05818504b399a911b88255b7d15592ee834bfbb3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5fed5fff1897b4964a5e8efbcd5e41e4492fe7947f827745fe9a14a555fe94" dependencies = [ "keyring", "log", @@ -2119,8 +2115,9 @@ dependencies = [ [[package]] name = "pimalaya-oauth2" -version = "0.0.1" -source = "git+https://git.sr.ht/~soywod/pimalaya#05818504b399a911b88255b7d15592ee834bfbb3" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9bec4262b62b6b14ffa244727e3d86d69e608e664e162e2c73332bed3b3f8a1" dependencies = [ "log", "oauth2", @@ -2131,8 +2128,9 @@ dependencies = [ [[package]] name = "pimalaya-process" -version = "0.0.1" -source = "git+https://git.sr.ht/~soywod/pimalaya#05818504b399a911b88255b7d15592ee834bfbb3" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8d2853bf2f0efbe397ce22bb4e6d53a7464f6dcc0abe7e2936f6f4e8e2726a" dependencies = [ "log", "thiserror", @@ -2141,7 +2139,8 @@ dependencies = [ [[package]] name = "pimalaya-secret" version = "0.0.1" -source = "git+https://git.sr.ht/~soywod/pimalaya#05818504b399a911b88255b7d15592ee834bfbb3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b585f585653ac7f957a608d8cdffd81be6561c2ad92fa82a1e72ed62a1bb31e0" dependencies = [ "log", "pimalaya-keyring", @@ -2673,9 +2672,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" +checksum = "93107647184f6027e3b7dcb2e11034cf95ffa1e3a682c67951963ac69c1c007d" dependencies = [ "serde", ] @@ -2988,9 +2987,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7afcae9e3f0fe2c370fd4657108972cbb2fa9db1b9f84849cefd80741b01cb6" +checksum = "d6135d499e69981f9ff0ef2167955a5333c35e36f6937d382974566b3d5b94ec" dependencies = [ "serde", "serde_spanned", @@ -3000,24 +2999,24 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" +checksum = "5a76a9312f5ba4c2dec6b9161fdf25d87ad8a09256ccea5a556fef03c706a10f" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.19.3" +version = "0.19.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6a7712b49e1775fb9a7b998de6635b299237f48b404dde71704f2e0e7f37e5" +checksum = "92d964908cec0d030b812013af25a0e57fddfadb1e066ecc6681d86253129d4f" dependencies = [ "indexmap", - "nom8", "serde", "serde_spanned", "toml_datetime", + "winnow", ] [[package]] @@ -3532,6 +3531,15 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +[[package]] +name = "winnow" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61de7bac303dc551fe038e2b3cef0f571087a47571ea6e79a87692ac99b99699" +dependencies = [ + "memchr 2.5.0", +] + [[package]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index d8c4700..0acfb65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,11 +15,10 @@ repository = "https://github.com/soywod/himalaya" all-features = true [features] -default = ["rustls-tls", "rustls-native-certs", "imap-backend", "smtp-sender"] +default = ["rustls-tls", "imap-backend", "smtp-sender"] # rustls rustls-tls = ["pimalaya-email/rustls-tls"] -rustls-native-certs = ["pimalaya-email/rustls-native-certs"] # native tls native-tls = ["pimalaya-email/native-tls"] @@ -52,20 +51,18 @@ indicatif = "0.17" log = "0.4" md5 = "0.7.0" once_cell = "1.16.0" -pimalaya-email = { git = "https://git.sr.ht/~soywod/pimalaya" } -pimalaya-keyring = { git = "https://git.sr.ht/~soywod/pimalaya" } -pimalaya-process = { git = "https://git.sr.ht/~soywod/pimalaya" } -pimalaya-secret = { git = "https://git.sr.ht/~soywod/pimalaya" } -# pimalaya-email = { path = "/home/soywod/sourcehut/pimalaya/email" } -# pimalaya-keyring = { path = "/home/soywod/sourcehut/pimalaya/keyring" } -# pimalaya-process = { path = "/home/soywod/sourcehut/pimalaya/process" } -# pimalaya-secret = { path = "/home/soywod/sourcehut/pimalaya/secret" } +pimalaya-email = "=0.8.0" +pimalaya-keyring = "=0.0.1" +pimalaya-oauth2 = "=0.0.2" +pimalaya-process = "=0.0.2" +pimalaya-secret = "=0.0.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" shellexpand = "2.1" termcolor = "1.1" terminal_size = "0.1" -toml = "0.7.2" +toml = "0.7.4" +toml_edit = "0.19.8" unicode-width = "0.1" url = "2.2" uuid = { version = "0.8", features = ["v4"] } diff --git a/src/config/config.rs b/src/config/config.rs index 6fcc7ec..439c562 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -4,17 +4,19 @@ //! user configuration file. use anyhow::{anyhow, Context, Result}; +use dialoguer::Confirm; use dirs::{config_dir, home_dir}; use log::{debug, trace}; use pimalaya_email::{AccountConfig, EmailHooks, EmailTextPlainFormat}; use pimalaya_process::Cmd; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fs, path::PathBuf}; +use std::{collections::HashMap, fs, path::PathBuf, process}; use toml; use crate::{ account::DeserializedAccountConfig, - config::{prelude::*, wizard::wizard}, + config::{prelude::*, wizard}, + wizard_prompt, wizard_warn, }; /// Represents the user config file. @@ -32,7 +34,11 @@ pub struct DeserializedConfig { pub email_listing_page_size: Option, pub email_reading_headers: Option>, - #[serde(default, with = "EmailTextPlainFormatDef")] + #[serde( + default, + with = "EmailTextPlainFormatDef", + skip_serializing_if = "EmailTextPlainFormat::is_default" + )] pub email_reading_format: EmailTextPlainFormat, #[serde( default, @@ -76,15 +82,25 @@ impl DeserializedConfig { pub fn from_opt_path(path: Option<&str>) -> Result { debug!("path: {:?}", path); - // let config: Self = match path.map(|s| s.into()).or_else(Self::path) { - // Some(path) => { - // let content = fs::read_to_string(path).context("cannot read config file")?; - // toml::from_str(&content).context("cannot parse config file")? - // } - // None => wizard()?, - // }; + 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."); - let config = wizard()?; + if !Confirm::new() + .with_prompt(wizard_prompt!( + "Would you like to create one with the wizard?" + )) + .default(true) + .interact_opt()? + .unwrap_or_default() + { + process::exit(0); + } + + wizard::configure()? + }; if config.accounts.is_empty() { return Err(anyhow!("config file must contain at least one account")); diff --git a/src/config/mod.rs b/src/config/mod.rs index 78507a1..feafee6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,6 @@ pub mod args; pub mod config; pub mod prelude; -pub(crate) mod wizard; +pub mod wizard; pub use config::*; diff --git a/src/config/prelude.rs b/src/config/prelude.rs index 2d6c286..078235e 100644 --- a/src/config/prelude.rs +++ b/src/config/prelude.rs @@ -1,80 +1,80 @@ +#[cfg(feature = "imap-backend")] +use pimalaya_email::ImapConfig; +#[cfg(feature = "notmuch-backend")] +use pimalaya_email::NotmuchConfig; use pimalaya_email::{ folder::sync::Strategy as SyncFoldersStrategy, BackendConfig, EmailHooks, EmailTextPlainFormat, ImapAuthConfig, MaildirConfig, OAuth2Config, OAuth2Method, OAuth2Scopes, PasswdConfig, SenderConfig, SendmailConfig, SmtpAuthConfig, SmtpConfig, }; use pimalaya_keyring::Entry; -use pimalaya_process::Cmd; +use pimalaya_process::{Cmd, Pipeline, SingleCmd}; use pimalaya_secret::Secret; -use serde::{Deserialize, Serialize}; -use std::{collections::HashSet, path::PathBuf}; +use serde::{ser::SerializeSeq, Deserialize, Serialize, Serializer}; +use std::{collections::HashSet, ops::Deref, path::PathBuf}; -#[cfg(feature = "imap-backend")] -use pimalaya_email::ImapConfig; - -#[cfg(feature = "notmuch-backend")] -use pimalaya_email::NotmuchConfig; - -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "Entry", from = "String")] -pub struct EntryDef; +pub struct EntryDef(#[serde(getter = "Deref::deref")] String); -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "Cmd", from = "String")] -pub struct SingleCmdDef; +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(remote = "SingleCmd", from = "String")] +pub struct SingleCmdDef(#[serde(getter = "Deref::deref")] String); -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "Cmd", from = "Vec")] -pub struct PipelineDef; +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(remote = "Pipeline", from = "Vec")] +pub struct PipelineDef( + #[serde(getter = "Deref::deref", serialize_with = "pipeline")] Vec, +); -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "Cmd", from = "SingleCmdOrPipeline")] -pub struct CmdDef; - -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(untagged)] -pub enum SingleCmdOrPipeline { - #[serde(with = "SingleCmdDef")] - SingleCmd(Cmd), - #[serde(with = "PipelineDef")] - Pipeline(Cmd), -} - -impl From for Cmd { - fn from(cmd: SingleCmdOrPipeline) -> Cmd { - match cmd { - SingleCmdOrPipeline::SingleCmd(cmd) => cmd, - SingleCmdOrPipeline::Pipeline(cmd) => cmd, - } +// NOTE: did not find the way to only do with macros… +pub fn pipeline(cmds: &Vec, s: S) -> Result +where + S: Serializer, +{ + let mut seq = s.serialize_seq(Some(cmds.len()))?; + for cmd in cmds { + seq.serialize_element(&cmd.to_string())?; } + seq.end() } -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "Option", from = "OptionSingleCmdOrPipeline")] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(remote = "Cmd", untagged)] +pub enum CmdDef { + #[serde(with = "SingleCmdDef")] + SingleCmd(SingleCmd), + #[serde(with = "PipelineDef")] + Pipeline(Pipeline), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(remote = "Option", from = "OptionCmd")] pub struct OptionCmdDef; -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(untagged)] -pub enum OptionSingleCmdOrPipeline { +pub enum OptionCmd { #[default] + #[serde(skip_serializing)] None, #[serde(with = "SingleCmdDef")] - SingleCmd(Cmd), + SingleCmd(SingleCmd), #[serde(with = "PipelineDef")] - Pipeline(Cmd), + Pipeline(Pipeline), } -impl From for Option { - fn from(cmd: OptionSingleCmdOrPipeline) -> Option { +impl From for Option { + fn from(cmd: OptionCmd) -> Option { match cmd { - OptionSingleCmdOrPipeline::None => None, - OptionSingleCmdOrPipeline::SingleCmd(cmd) => Some(cmd), - OptionSingleCmdOrPipeline::Pipeline(cmd) => Some(cmd), + OptionCmd::None => None, + OptionCmd::SingleCmd(cmd) => Some(Cmd::SingleCmd(cmd)), + OptionCmd::Pipeline(pipeline) => Some(Cmd::Pipeline(pipeline)), } } } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "Secret", rename_all = "kebab-case")] pub enum SecretDef { Raw(String), @@ -84,7 +84,7 @@ pub enum SecretDef { Keyring(Entry), } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "OAuth2Method")] pub enum OAuth2MethodDef { #[serde(rename = "xoauth2", alias = "XOAUTH2")] @@ -93,7 +93,7 @@ pub enum OAuth2MethodDef { OAuthBearer, } -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "BackendConfig", tag = "backend", rename_all = "kebab-case")] pub enum BackendConfigDef { #[default] @@ -109,7 +109,7 @@ pub enum BackendConfigDef { } #[cfg(feature = "imap-backend")] -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "ImapConfig")] pub struct ImapConfigDef { #[serde(rename = "imap-host")] @@ -134,7 +134,7 @@ pub struct ImapConfigDef { pub watch_cmds: Option>, } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "ImapAuthConfig", tag = "imap-auth")] pub enum ImapAuthConfigDef { #[serde(rename = "passwd", alias = "password", with = "ImapPasswdConfigDef")] @@ -143,7 +143,7 @@ pub enum ImapAuthConfigDef { OAuth2(OAuth2Config), } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "PasswdConfig")] pub struct ImapPasswdConfigDef { #[serde( @@ -155,7 +155,7 @@ pub struct ImapPasswdConfigDef { pub passwd: Secret, } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "OAuth2Config")] pub struct ImapOAuth2ConfigDef { #[serde(rename = "imap-oauth2-method", with = "OAuth2MethodDef", default)] @@ -193,7 +193,7 @@ pub struct ImapOAuth2ConfigDef { pub pkce: bool, } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "OAuth2Scopes")] pub enum ImapOAuth2ScopesDef { #[serde(rename = "imap-oauth2-scope")] @@ -202,7 +202,7 @@ pub enum ImapOAuth2ScopesDef { Scopes(Vec), } -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "MaildirConfig", rename_all = "kebab-case")] pub struct MaildirConfigDef { #[serde(rename = "maildir-root-dir")] @@ -210,14 +210,14 @@ pub struct MaildirConfigDef { } #[cfg(feature = "notmuch-backend")] -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "NotmuchConfig", rename_all = "kebab-case")] pub struct NotmuchConfigDef { #[serde(rename = "notmuch-db-path")] pub db_path: PathBuf, } -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde( remote = "EmailTextPlainFormat", tag = "type", @@ -231,7 +231,7 @@ pub enum EmailTextPlainFormatDef { Fixed(usize), } -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "SenderConfig", tag = "sender", rename_all = "kebab-case")] pub enum SenderConfigDef { #[default] @@ -242,7 +242,7 @@ pub enum SenderConfigDef { Sendmail(SendmailConfig), } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "SmtpConfig")] struct SmtpConfigDef { #[serde(rename = "smtp-host")] @@ -261,7 +261,7 @@ struct SmtpConfigDef { pub auth: SmtpAuthConfig, } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "SmtpAuthConfig", tag = "smtp-auth")] pub enum SmtpAuthConfigDef { #[serde(rename = "passwd", alias = "password", with = "SmtpPasswdConfigDef")] @@ -270,7 +270,7 @@ pub enum SmtpAuthConfigDef { OAuth2(OAuth2Config), } -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "PasswdConfig", default)] pub struct SmtpPasswdConfigDef { #[serde( @@ -282,7 +282,7 @@ pub struct SmtpPasswdConfigDef { pub passwd: Secret, } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "OAuth2Config")] pub struct SmtpOAuth2ConfigDef { #[serde(rename = "smtp-oauth2-method", with = "OAuth2MethodDef", default)] @@ -320,7 +320,7 @@ pub struct SmtpOAuth2ConfigDef { pub pkce: bool, } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "OAuth2Scopes")] pub enum SmtpOAuth2ScopesDef { #[serde(rename = "smtp-oauth2-scope")] @@ -329,7 +329,7 @@ pub enum SmtpOAuth2ScopesDef { Scopes(Vec), } -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "SendmailConfig", rename_all = "kebab-case")] pub struct SendmailConfigDef { #[serde(rename = "sendmail-cmd", with = "CmdDef")] @@ -338,19 +338,15 @@ pub struct SendmailConfigDef { /// Represents the email hooks. Useful for doing extra email /// processing before or after sending it. -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "EmailHooks", rename_all = "kebab-case")] pub struct EmailHooksDef { /// Represents the hook called just before sending an email. - #[serde( - default, - with = "OptionCmdDef", - skip_serializing_if = "Option::is_none" - )] + #[serde(default, with = "OptionCmdDef")] pub pre_send: Option, } -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(remote = "SyncFoldersStrategy", rename_all = "kebab-case")] pub enum SyncFoldersStrategyDef { #[default] diff --git a/src/config/wizard.rs b/src/config/wizard.rs new file mode 100644 index 0000000..200627e --- /dev/null +++ b/src/config/wizard.rs @@ -0,0 +1,122 @@ +use super::DeserializedConfig; +use crate::account; +use anyhow::Result; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select}; +use once_cell::sync::Lazy; +use std::{env, fs, io, path::PathBuf, process}; + +#[macro_export] +macro_rules! wizard_warn { + ($($arg:tt)*) => { + println!("{}", console::style(format!($($arg)*)).yellow().bold()); + }; +} + +#[macro_export] +macro_rules! wizard_prompt { + ($($arg:tt)*) => { + format!("{}", console::style(format!($($arg)*)).italic()) + }; +} + +#[macro_export] +macro_rules! wizard_log { + ($($arg:tt)*) => { + println!(""); + println!("{}", console::style(format!($($arg)*)).underlined()); + println!(""); + }; +} + +pub(crate) static THEME: Lazy = Lazy::new(ColorfulTheme::default); + +pub(crate) fn configure() -> Result { + wizard_log!("Configuring your first account:"); + + let mut config = DeserializedConfig::default(); + + while let Some((name, account_config)) = account::wizard::configure()? { + config.accounts.insert(name, account_config); + + if !Confirm::new() + .with_prompt(wizard_prompt!( + "Would you like to configure another account?" + )) + .default(false) + .interact_opt()? + .unwrap_or_default() + { + break; + } + + wizard_log!("Configuring another account:"); + } + + // If one acounts is setup, make it the default. If multiple + // accounts are setup, decide which will be the default. If no + // accounts are setup, exit the process. + let default_account = match config.accounts.len() { + 0 => process::exit(0), + 1 => Some(config.accounts.values_mut().next().unwrap()), + _ => { + let accounts = config.accounts.clone(); + let accounts: Vec<&String> = accounts.keys().collect(); + + println!("{} accounts have been configured.", accounts.len()); + + Select::with_theme(&*THEME) + .with_prompt(wizard_prompt!( + "Which account would you like to set as your default?" + )) + .items(&accounts) + .default(0) + .interact_opt()? + .and_then(|idx| config.accounts.get_mut(accounts[idx])) + } + }; + + if let Some(account) = default_account { + account.default = Some(true); + } else { + process::exit(0) + } + + let path = Input::with_theme(&*THEME) + .with_prompt(wizard_prompt!( + "Where would you like to save your configuration?" + )) + .default( + dirs::config_dir() + .map(|p| p.join("himalaya").join("config.toml")) + .unwrap_or_else(|| env::temp_dir().join("himalaya").join("config.toml")) + .to_string_lossy() + .to_string(), + ) + .validate_with(|path: &String| shellexpand::full(path).map(|_| ())) + .interact()?; + let path: PathBuf = shellexpand::full(&path).unwrap().to_string().into(); + + println!("Writing the configuration to {path:?}…"); + + fs::create_dir_all(path.parent().unwrap_or(&path))?; + fs::write(path, toml::to_string(&config)?)?; + + Ok(config) +} + +pub(crate) fn prompt_passwd(prompt: &str) -> io::Result { + Password::with_theme(&*THEME) + .with_prompt(prompt) + .with_confirmation( + "Confirm password", + "Passwords do not match, please try again.", + ) + .interact() +} + +pub(crate) fn prompt_secret(prompt: &str) -> io::Result { + Password::with_theme(&*THEME) + .with_prompt(prompt) + .report(false) + .interact() +} diff --git a/src/config/wizard/imap.rs b/src/config/wizard/imap.rs deleted file mode 100644 index b758b0b..0000000 --- a/src/config/wizard/imap.rs +++ /dev/null @@ -1,89 +0,0 @@ -use anyhow::Result; -use dialoguer::{Input, Select}; -use pimalaya_email::{BackendConfig, ImapConfig}; - -use crate::account::DeserializedAccountConfig; - -use super::{AUTH_MECHANISMS, CMD, KEYRING, RAW, SECRET, SECURITY_PROTOCOLS, THEME}; - -#[cfg(feature = "imap-backend")] -pub(crate) fn configure(base: &DeserializedAccountConfig) -> Result { - // TODO: Validate by checking as valid URI - - use dialoguer::Password; - use pimalaya_email::{ImapAuthConfig, PasswdConfig}; - use pimalaya_secret::Secret; - - use super::PASSWD; - - let mut imap_config = ImapConfig::default(); - - imap_config.host = Input::with_theme(&*THEME) - .with_prompt("What is your IMAP host:") - .default(format!("imap.{}", base.email.rsplit_once('@').unwrap().1)) - .interact()?; - - let default_port = match Select::with_theme(&*THEME) - .with_prompt("Which security protocol do you want to use?") - .items(SECURITY_PROTOCOLS) - .default(0) - .interact_opt()? - { - Some(idx) if SECURITY_PROTOCOLS[idx] == "SSL/TLS" => { - imap_config.ssl = Some(true); - 993 - } - Some(idx) if SECURITY_PROTOCOLS[idx] == "STARTTLS" => { - imap_config.starttls = Some(true); - 143 - } - _ => 143, - }; - - imap_config.port = Input::with_theme(&*THEME) - .with_prompt("Which IMAP port would you like to use?") - .validate_with(|input: &String| input.parse::().map(|_| ())) - .default(default_port.to_string()) - .interact() - .map(|input| input.parse::().unwrap())?; - - imap_config.login = Input::with_theme(&*THEME) - .with_prompt("What is your IMAP login?") - .default(base.email.clone()) - .interact()?; - - let auth = Select::with_theme(&*THEME) - .with_prompt("Which IMAP authentication mechanism would you like to use?") - .items(AUTH_MECHANISMS) - .default(0) - .interact_opt()?; - - imap_config.auth = match auth { - Some(idx) if AUTH_MECHANISMS[idx] == PASSWD => { - let secret = Select::with_theme(&*THEME) - .with_prompt("How would you like to store your password?") - .items(SECRET) - .default(0) - .interact_opt()?; - match secret { - Some(idx) if SECRET[idx] == RAW => ImapAuthConfig::Passwd(PasswdConfig { - passwd: Secret::new_raw( - Password::with_theme(&*THEME) - .with_prompt("What is your IMAP password?") - .interact()?, - ), - }), - _ => ImapAuthConfig::default(), - } - } - _ => ImapAuthConfig::default(), - }; - - // FIXME: add all variants: password, password command and oauth2 - // backend.passwd_cmd = Input::with_theme(&*THEME) - // .with_prompt("What shell command should we run to get your password?") - // .default(format!("pass show {}", &base.email)) - // .interact()?; - - Ok(BackendConfig::Imap(imap_config)) -} diff --git a/src/config/wizard/maildir.rs b/src/config/wizard/maildir.rs deleted file mode 100644 index b12b309..0000000 --- a/src/config/wizard/maildir.rs +++ /dev/null @@ -1,25 +0,0 @@ -use anyhow::Result; -use dialoguer::Input; -use dirs::home_dir; -use pimalaya_email::{BackendConfig, MaildirConfig}; - -use super::THEME; - -pub(crate) fn configure() -> Result { - let mut maildir_config = MaildirConfig::default(); - - let input = if let Some(home) = home_dir() { - Input::with_theme(&*THEME) - .default(home.join("Mail").display().to_string()) - .with_prompt("Enter the path to your maildir") - .interact_text()? - } else { - Input::with_theme(&*THEME) - .with_prompt("Enter the path to your maildir") - .interact_text()? - }; - - maildir_config.root_dir = input.into(); - - Ok(BackendConfig::Maildir(maildir_config)) -} diff --git a/src/config/wizard/mod.rs b/src/config/wizard/mod.rs deleted file mode 100644 index 1d32014..0000000 --- a/src/config/wizard/mod.rs +++ /dev/null @@ -1,184 +0,0 @@ -#[cfg(feature = "imap-backend")] -pub(crate) mod imap; -mod maildir; -#[cfg(feature = "notmuch-backend")] -mod notmuch; -mod sendmail; -mod smtp; -mod validators; - -use super::DeserializedConfig; -use crate::account::DeserializedAccountConfig; -use anyhow::{anyhow, Result}; -use console::style; -use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select}; -use log::trace; -use once_cell::sync::Lazy; -use pimalaya_email::{BackendConfig, SenderConfig}; -use std::{fs, io, process}; - -const BACKENDS: &[&str] = &[ - #[cfg(feature = "imap-backend")] - "IMAP", - "Maildir", - #[cfg(feature = "notmuch-backend")] - "Notmuch", - "None", -]; - -const SENDERS: &[&str] = &["SMTP", "Sendmail"]; - -const SECURITY_PROTOCOLS: &[&str] = &["SSL/TLS", "STARTTLS", "None"]; - -const AUTH_MECHANISMS: &[&str] = &[PASSWD, OAUTH2]; -const PASSWD: &str = "Password"; -const OAUTH2: &str = "OAuth 2.0"; - -const SECRET: &[&str] = &[RAW, CMD, KEYRING]; -const RAW: &str = "In clear, in your configuration (not recommanded)"; -const CMD: &str = "From a shell command"; -const KEYRING: &str = "From your system's global keyring"; - -// A wizard should have pretty colors 💅 -static THEME: Lazy = Lazy::new(ColorfulTheme::default); - -pub(crate) fn wizard() -> Result { - println!("Himalaya couldn't find an already existing configuration file."); - - match Confirm::new() - .with_prompt("Do you want to create one with the wizard?") - .default(true) - .report(false) - .interact_opt()? - { - Some(false) | None => process::exit(0), - _ => {} - } - - // Determine path to save to - // let path = dirs::config_dir() - // .map(|p| p.join("himalaya").join("config.toml")) - // .ok_or_else(|| anyhow!("The wizard could not determine the config directory. Aborting"))?; - let path = std::path::PathBuf::from("/home/soywod/config.wizard.toml"); - - let mut config = DeserializedConfig::default(); - - // Setup one or multiple accounts - println!("\n{}", style("First let's setup an account").underlined()); - while let Some(account_config) = configure_account()? { - let name: String = Input::with_theme(&*THEME) - .with_prompt("What would you like to name your account?") - .default("Personal".to_owned()) - .interact()?; - - config.accounts.insert(name, account_config); - - match Confirm::new() - .with_prompt("Setup another account?") - .default(false) - .report(false) - .interact_opt()? - { - Some(true) => println!("\n{}", style("Setting up another account").underlined()), - _ => break, - } - } - - // If one acounts is setup, make it the default. If multiple accounts are setup, decide which - // will be the default. If no accounts are setup, exit the process - let default_account = match config.accounts.len() { - 1 => Some(config.accounts.values_mut().next().unwrap()), - i if i > 1 => { - let accounts = config.accounts.clone(); - let accounts: Vec<&String> = accounts.keys().collect(); - - println!( - "\n{}", - style(format!("You've setup {} accounts", accounts.len())).underlined() - ); - match Select::with_theme(&*THEME) - .with_prompt("Which account would you like to set as your default?") - .items(&accounts) - .default(0) - .interact_opt()? - { - Some(i) => Some(config.accounts.get_mut(accounts[i]).unwrap()), - _ => process::exit(0), - } - } - _ => process::exit(0), - }; - - if let Some(account) = default_account { - account.default = Some(true); - } - - // Serialize config to file - println!("\nWriting the configuration to {path:?}..."); - fs::create_dir_all(path.parent().unwrap())?; - fs::write(path, toml::to_string(&config)?)?; - - trace!("<< wizard"); - Ok(config) -} - -fn configure_account() -> Result> { - let mut config = DeserializedAccountConfig::default(); - - config.email = Input::with_theme(&*THEME) - .with_prompt("What is your email address?") - .validate_with(validators::EmailValidator) - .interact()?; - - config.display_name = Some( - Input::with_theme(&*THEME) - .with_prompt("Which name would you like to display with your email?") - .interact()?, - ); - - let backend = Select::with_theme(&*THEME) - .with_prompt("Which backend would you like to configure your account for?") - .items(BACKENDS) - .default(0) - .interact_opt()?; - - config.backend = match backend { - Some(idx) if BACKENDS[idx] == "IMAP" => imap::configure(&config), - Some(idx) if BACKENDS[idx] == "Maildir" => maildir::configure(), - Some(idx) if BACKENDS[idx] == "Notmuch" => notmuch::configure(), - Some(idx) if BACKENDS[idx] == "None" => Ok(BackendConfig::None), - _ => return Ok(None), - }?; - - let sender = Select::with_theme(&*THEME) - .with_prompt("Which sender would you like use with your account?") - .items(SENDERS) - .default(0) - .interact_opt()?; - - config.sender = match sender { - Some(idx) if SENDERS[idx] == "SMTP" => smtp::configure(&config), - Some(idx) if SENDERS[idx] == "Sendmail" => sendmail::configure(), - Some(idx) if SENDERS[idx] == "None" => Ok(SenderConfig::None), - _ => return Ok(None), - }?; - - Ok(Some(config)) -} - -pub(crate) fn prompt_passwd(prompt: &str) -> io::Result { - Password::with_theme(&*THEME) - .with_prompt(prompt) - .with_confirmation( - "Confirm password:", - "Passwords do not match, please try again.", - ) - .interact() -} - -pub(crate) fn prompt_secret(prompt: &str) -> io::Result { - Input::with_theme(&*THEME) - .with_prompt(prompt) - .report(false) - .interact() -} diff --git a/src/config/wizard/notmuch.rs b/src/config/wizard/notmuch.rs deleted file mode 100644 index 29fff9c..0000000 --- a/src/config/wizard/notmuch.rs +++ /dev/null @@ -1,21 +0,0 @@ -use anyhow::Result; -use dialoguer::Input; -use pimalaya_email::{BackendConfig, NotmuchBackend, NotmuchConfig}; - -use super::THEME; - -pub(crate) fn configure() -> Result { - let mut notmuch_config = NotmuchConfig::default(); - - notmuch_config.db_path = match NotmuchBackend::get_default_db_path() { - Ok(db) => db, - _ => { - let input: String = Input::with_theme(&*THEME) - .with_prompt("Could not find a notmuch database. Enter path manually:") - .interact_text()?; - input.into() - } - }; - - Ok(BackendConfig::Notmuch(notmuch_config)) -} diff --git a/src/config/wizard/sendmail.rs b/src/config/wizard/sendmail.rs deleted file mode 100644 index 30c86e3..0000000 --- a/src/config/wizard/sendmail.rs +++ /dev/null @@ -1,17 +0,0 @@ -use anyhow::Result; -use dialoguer::Input; -use pimalaya_email::{SenderConfig, SendmailConfig}; - -use super::THEME; - -pub(crate) fn configure() -> Result { - let mut sendmail_config = SendmailConfig::default(); - - sendmail_config.cmd = Input::with_theme(&*THEME) - .with_prompt("Enter an external command to send an email: ") - .default("/usr/bin/msmtp".to_owned()) - .interact()? - .into(); - - Ok(SenderConfig::Sendmail(sendmail_config)) -} diff --git a/src/config/wizard/smtp.rs b/src/config/wizard/smtp.rs deleted file mode 100644 index 7e0e868..0000000 --- a/src/config/wizard/smtp.rs +++ /dev/null @@ -1,54 +0,0 @@ -use anyhow::Result; -use dialoguer::{Input, Select}; -use pimalaya_email::{SenderConfig, SmtpConfig}; - -use crate::account::DeserializedAccountConfig; - -use super::{SECURITY_PROTOCOLS, THEME}; - -pub(crate) fn configure(config: &DeserializedAccountConfig) -> Result { - let mut smtp_config = SmtpConfig { - host: Input::with_theme(&*THEME) - .with_prompt("Enter the SMTP host: ") - .default(format!("smtp.{}", config.email.rsplit_once('@').unwrap().1)) - .interact()?, - ..Default::default() - }; - - let default_port = match Select::with_theme(&*THEME) - .with_prompt("Which security protocol do you want to use?") - .items(SECURITY_PROTOCOLS) - .default(0) - .interact_opt()? - { - Some(idx) if SECURITY_PROTOCOLS[idx] == "SSL/TLS" => { - smtp_config.ssl = Some(true); - 465 - } - Some(idx) if SECURITY_PROTOCOLS[idx] == "STARTTLS" => { - smtp_config.starttls = Some(true); - 587 - } - _ => 25, - }; - - smtp_config.port = Input::with_theme(&*THEME) - .with_prompt("Enter the SMTP port:") - .validate_with(|input: &String| input.parse::().map(|_| ())) - .default(default_port.to_string()) - .interact() - .map(|input| input.parse::().unwrap())?; - - smtp_config.login = Input::with_theme(&*THEME) - .with_prompt("Enter your SMTP login:") - .default(config.email.clone()) - .interact()?; - - // FIXME: add all variants: password, password command and oauth2 - // smtp_config.auth = Input::with_theme(&*THEME) - // .with_prompt("What shell command should we run to get your password?") - // .default(format!("pass show {}", &base.email)) - // .interact()?; - - Ok(SenderConfig::Smtp(smtp_config)) -} diff --git a/src/config/wizard/validators.rs b/src/config/wizard/validators.rs deleted file mode 100644 index 4a1c290..0000000 --- a/src/config/wizard/validators.rs +++ /dev/null @@ -1,18 +0,0 @@ -use anyhow::anyhow; -use dialoguer::Validator; -use email_address::EmailAddress; - -pub(crate) struct EmailValidator; - -impl Validator for EmailValidator { - type Err = anyhow::Error; - - fn validate(&mut self, input: &T) -> Result<(), Self::Err> { - let input = input.to_string(); - if EmailAddress::is_valid(&input) { - Ok(()) - } else { - Err(anyhow!("Invalid email address: {}", input)) - } - } -} diff --git a/src/domain/account/config.rs b/src/domain/account/config.rs index 792a433..674ab4f 100644 --- a/src/domain/account/config.rs +++ b/src/domain/account/config.rs @@ -3,6 +3,10 @@ //! This module contains the raw deserialized representation of an //! account in the accounts section of the user configuration file. +#[cfg(feature = "imap-backend")] +use pimalaya_email::ImapAuthConfig; +#[cfg(feature = "smtp-sender")] +use pimalaya_email::SmtpAuthConfig; use pimalaya_email::{ folder::sync::Strategy as SyncFoldersStrategy, AccountConfig, BackendConfig, EmailHooks, EmailTextPlainFormat, SenderConfig, @@ -29,7 +33,11 @@ pub struct DeserializedAccountConfig { pub email_listing_page_size: Option, pub email_reading_headers: Option>, - #[serde(default, with = "EmailTextPlainFormatDef")] + #[serde( + default, + with = "EmailTextPlainFormatDef", + skip_serializing_if = "EmailTextPlainFormat::is_default" + )] pub email_reading_format: EmailTextPlainFormat, #[serde( default, @@ -64,10 +72,13 @@ pub struct DeserializedAccountConfig { )] pub email_hooks: EmailHooks, - #[serde(default)] - pub sync: bool, + pub sync: Option, pub sync_dir: Option, - #[serde(default, with = "SyncFoldersStrategyDef")] + #[serde( + default, + with = "SyncFoldersStrategyDef", + skip_serializing_if = "SyncFoldersStrategy::is_default" + )] pub sync_folders_strategy: SyncFoldersStrategy, #[serde(flatten, with = "BackendConfigDef")] @@ -91,7 +102,7 @@ impl DeserializedAccountConfig { ); AccountConfig { - name, + name: name.clone(), email: self.email.to_owned(), display_name: self .display_name @@ -175,12 +186,60 @@ impl DeserializedAccountConfig { email_hooks: EmailHooks { pre_send: self.email_hooks.pre_send.clone(), }, - sync: self.sync, + sync: self.sync.unwrap_or_default(), sync_dir: self.sync_dir.clone(), sync_folders_strategy: self.sync_folders_strategy.clone(), - backend: self.backend.clone(), - sender: self.sender.clone(), + backend: { + let mut backend = self.backend.clone(); + + #[cfg(feature = "imap-backend")] + if let BackendConfig::Imap(config) = &mut backend { + match &mut config.auth { + ImapAuthConfig::Passwd(secret) => { + secret.replace_undefined_entry_with(format!("{name}-imap-passwd")); + } + ImapAuthConfig::OAuth2(config) => { + config.client_secret.replace_undefined_entry_with(format!( + "{name}-imap-oauth2-client-secret" + )); + config.access_token.replace_undefined_entry_with(format!( + "{name}-imap-oauth2-access-token" + )); + config.refresh_token.replace_undefined_entry_with(format!( + "{name}-imap-oauth2-refresh-token" + )); + } + }; + } + + backend + }, + sender: { + let mut sender = self.sender.clone(); + + #[cfg(feature = "smtp-sender")] + if let SenderConfig::Smtp(config) = &mut sender { + match &mut config.auth { + SmtpAuthConfig::Passwd(secret) => { + secret.replace_undefined_entry_with(format!("{name}-smtp-passwd")); + } + SmtpAuthConfig::OAuth2(config) => { + config.client_secret.replace_undefined_entry_with(format!( + "{name}-smtp-oauth2-client-secret" + )); + config.access_token.replace_undefined_entry_with(format!( + "{name}-smtp-oauth2-access-token" + )); + config.refresh_token.replace_undefined_entry_with(format!( + "{name}-smtp-oauth2-refresh-token" + )); + } + }; + } + + sender + }, } } } diff --git a/src/domain/account/handlers.rs b/src/domain/account/handlers.rs index 9caeccc..44c0872 100644 --- a/src/domain/account/handlers.rs +++ b/src/domain/account/handlers.rs @@ -52,11 +52,9 @@ pub fn configure(config: &AccountConfig, reset: bool) -> Result<()> { #[cfg(feature = "imap-backend")] if let BackendConfig::Imap(imap_config) = &config.backend { match &imap_config.auth { - ImapAuthConfig::Passwd(passwd) => { - passwd.configure(|| prompt_passwd("Enter your IMAP password:")) - } + ImapAuthConfig::Passwd(passwd) => passwd.configure(|| prompt_passwd("IMAP password")), ImapAuthConfig::OAuth2(oauth2) => { - oauth2.configure(|| prompt_secret("Enter your IMAP OAuth 2.0 client secret:")) + oauth2.configure(|| prompt_secret("IMAP OAuth 2.0 client secret")) } }?; } @@ -64,16 +62,18 @@ pub fn configure(config: &AccountConfig, reset: bool) -> Result<()> { #[cfg(feature = "smtp-sender")] if let SenderConfig::Smtp(smtp_config) = &config.sender { match &smtp_config.auth { - SmtpAuthConfig::Passwd(passwd) => { - passwd.configure(|| prompt_passwd("Enter your SMTP password:")) - } + SmtpAuthConfig::Passwd(passwd) => passwd.configure(|| prompt_passwd("SMTP password")), SmtpAuthConfig::OAuth2(oauth2) => { - oauth2.configure(|| prompt_secret("Enter your SMTP OAuth 2.0 client secret:")) + oauth2.configure(|| prompt_secret("SMTP OAuth 2.0 client secret")) } }?; } - println!("Account successfully configured!"); + println!( + "Account successfully {}configured!", + if reset { "re" } else { "" } + ); + Ok(()) } diff --git a/src/domain/account/mod.rs b/src/domain/account/mod.rs index 2a2854a..0f10b25 100644 --- a/src/domain/account/mod.rs +++ b/src/domain/account/mod.rs @@ -3,6 +3,7 @@ pub mod accounts; pub mod args; pub mod config; pub mod handlers; +pub(crate) mod wizard; pub use account::*; pub use accounts::*; diff --git a/src/domain/account/wizard.rs b/src/domain/account/wizard.rs new file mode 100644 index 0000000..2198e31 --- /dev/null +++ b/src/domain/account/wizard.rs @@ -0,0 +1,39 @@ +use anyhow::{anyhow, Result}; +use dialoguer::Input; +use email_address::EmailAddress; + +use crate::{backend, config::wizard::THEME, sender}; + +use super::DeserializedAccountConfig; + +pub(crate) fn configure() -> Result> { + let mut config = DeserializedAccountConfig::default(); + + let account_name = Input::with_theme(&*THEME) + .with_prompt("Account name") + .default(String::from("Personal")) + .interact()?; + + config.email = Input::with_theme(&*THEME) + .with_prompt("Email address") + .validate_with(|email: &String| { + if EmailAddress::is_valid(email) { + Ok(()) + } else { + Err(anyhow!("Invalid email address: {email}")) + } + }) + .interact()?; + + config.display_name = Some( + Input::with_theme(&*THEME) + .with_prompt("Full display name") + .interact()?, + ); + + config.backend = backend::wizard::configure(&account_name, &config.email)?; + + config.sender = sender::wizard::configure(&account_name, &config.email)?; + + Ok(Some((account_name, config))) +} diff --git a/src/domain/imap/args.rs b/src/domain/backend/imap/args.rs similarity index 100% rename from src/domain/imap/args.rs rename to src/domain/backend/imap/args.rs diff --git a/src/domain/imap/handlers.rs b/src/domain/backend/imap/handlers.rs similarity index 100% rename from src/domain/imap/handlers.rs rename to src/domain/backend/imap/handlers.rs diff --git a/src/domain/imap/mod.rs b/src/domain/backend/imap/mod.rs similarity index 58% rename from src/domain/imap/mod.rs rename to src/domain/backend/imap/mod.rs index b0b957b..723c3b8 100644 --- a/src/domain/imap/mod.rs +++ b/src/domain/backend/imap/mod.rs @@ -1,2 +1,3 @@ pub mod args; pub mod handlers; +pub(crate) mod wizard; diff --git a/src/domain/backend/imap/wizard.rs b/src/domain/backend/imap/wizard.rs new file mode 100644 index 0000000..685d985 --- /dev/null +++ b/src/domain/backend/imap/wizard.rs @@ -0,0 +1,214 @@ +use anyhow::Result; +use dialoguer::{Confirm, Input, Password, Select}; +use pimalaya_email::{ + BackendConfig, ImapAuthConfig, ImapConfig, OAuth2Config, OAuth2Method, OAuth2Scopes, + PasswdConfig, +}; +use pimalaya_oauth2::AuthorizationCodeGrant; +use pimalaya_secret::Secret; + +use crate::{ + config::wizard::{prompt_passwd, THEME}, + wizard_log, wizard_prompt, +}; + +const SSL_TLS: &str = "SSL/TLS"; +const STARTTLS: &str = "STARTTLS"; +const NONE: &str = "None"; +const PROTOCOLS: &[&str] = &[SSL_TLS, STARTTLS, NONE]; + +const PASSWD: &str = "Password"; +const OAUTH2: &str = "OAuth 2.0"; +const AUTH_MECHANISMS: &[&str] = &[PASSWD, OAUTH2]; + +const XOAUTH2: &str = "XOAUTH2"; +const OAUTHBEARER: &str = "OAUTHBEARER"; +const OAUTH2_MECHANISMS: &[&str] = &[XOAUTH2, OAUTHBEARER]; + +const SECRETS: &[&str] = &[KEYRING, RAW, CMD]; +const KEYRING: &str = "Ask my password, then save it in my system's global keyring"; +const RAW: &str = "Ask my password, then save it in the configuration file (not safe)"; +const CMD: &str = "Ask me a shell command that exposes my password"; + +pub(crate) fn configure(account_name: &str, email: &str) -> Result { + let mut config = ImapConfig::default(); + + config.host = Input::with_theme(&*THEME) + .with_prompt("IMAP host") + .default(format!("imap.{}", email.rsplit_once('@').unwrap().1)) + .interact()?; + + let protocol = Select::with_theme(&*THEME) + .with_prompt("IMAP security protocol") + .items(PROTOCOLS) + .default(0) + .interact_opt()?; + + let default_port = match protocol { + Some(idx) if PROTOCOLS[idx] == SSL_TLS => { + config.ssl = Some(true); + 993 + } + Some(idx) if PROTOCOLS[idx] == STARTTLS => { + config.starttls = Some(true); + 143 + } + _ => 143, + }; + + config.port = Input::with_theme(&*THEME) + .with_prompt("IMAP port") + .validate_with(|input: &String| input.parse::().map(|_| ())) + .default(default_port.to_string()) + .interact() + .map(|input| input.parse::().unwrap())?; + + config.login = Input::with_theme(&*THEME) + .with_prompt("IMAP login") + .default(email.to_owned()) + .interact()?; + + let auth = Select::with_theme(&*THEME) + .with_prompt("IMAP authentication mechanism") + .items(AUTH_MECHANISMS) + .default(0) + .interact_opt()?; + + config.auth = match auth { + Some(idx) if AUTH_MECHANISMS[idx] == PASSWD => { + let secret = Select::with_theme(&*THEME) + .with_prompt("IMAP authentication strategy") + .items(SECRETS) + .default(0) + .interact_opt()?; + + let config = match secret { + Some(idx) if SECRETS[idx] == KEYRING => { + Secret::new_keyring(format!("{account_name}-imap-passwd")) + .set(prompt_passwd("IMAP password")?)?; + PasswdConfig::default() + } + Some(idx) if SECRETS[idx] == RAW => PasswdConfig { + passwd: Secret::Raw(prompt_passwd("IMAP password")?), + }, + Some(idx) if SECRETS[idx] == CMD => PasswdConfig { + passwd: Secret::new_cmd( + Input::with_theme(&*THEME) + .with_prompt("Shell command") + .default(format!("pass show {account_name}-imap-passwd")) + .interact()?, + ), + }, + _ => PasswdConfig::default(), + }; + ImapAuthConfig::Passwd(config) + } + Some(idx) if AUTH_MECHANISMS[idx] == OAUTH2 => { + let mut config = OAuth2Config::default(); + + let method = Select::with_theme(&*THEME) + .with_prompt("IMAP OAuth 2.0 mechanism") + .items(OAUTH2_MECHANISMS) + .default(0) + .interact_opt()?; + + config.method = match method { + Some(idx) if OAUTH2_MECHANISMS[idx] == XOAUTH2 => OAuth2Method::XOAuth2, + Some(idx) if OAUTH2_MECHANISMS[idx] == OAUTHBEARER => OAuth2Method::OAuthBearer, + _ => OAuth2Method::XOAuth2, + }; + + config.client_id = Input::with_theme(&*THEME) + .with_prompt("IMAP OAuth 2.0 client id") + .interact()?; + + let client_secret: String = Password::with_theme(&*THEME) + .with_prompt("IMAP OAuth 2.0 client secret") + .interact()?; + Secret::new_keyring(format!("{account_name}-imap-oauth2-client-secret")) + .set(&client_secret)?; + + config.auth_url = Input::with_theme(&*THEME) + .with_prompt("IMAP OAuth 2.0 authorization URL") + .interact()?; + + config.token_url = Input::with_theme(&*THEME) + .with_prompt("IMAP OAuth 2.0 token URL") + .interact()?; + + config.scopes = OAuth2Scopes::Scope( + Input::with_theme(&*THEME) + .with_prompt("IMAP OAuth 2.0 main scope") + .interact()?, + ); + + while Confirm::new() + .with_prompt(wizard_prompt!( + "Would you like to add more IMAP OAuth 2.0 scopes?" + )) + .default(false) + .interact_opt()? + .unwrap_or_default() + { + let mut scopes = match config.scopes { + OAuth2Scopes::Scope(scope) => vec![scope], + OAuth2Scopes::Scopes(scopes) => scopes, + }; + + scopes.push( + Input::with_theme(&*THEME) + .with_prompt("Additional IMAP OAuth 2.0 scope") + .interact()?, + ); + + config.scopes = OAuth2Scopes::Scopes(scopes); + } + + config.pkce = Confirm::new() + .with_prompt(wizard_prompt!( + "Would you like to enable PKCE verification?" + )) + .default(true) + .interact_opt()? + .unwrap_or(true); + + wizard_log!("To complete your OAuth 2.0 setup, click on the following link:"); + + let mut builder = AuthorizationCodeGrant::new( + config.client_id.clone(), + client_secret, + config.auth_url.clone(), + config.token_url.clone(), + )?; + + if config.pkce { + builder = builder.with_pkce(); + } + + for scope in config.scopes.clone() { + builder = builder.with_scope(scope); + } + + let client = builder.get_client()?; + let (redirect_url, csrf_token) = builder.get_redirect_url(&client); + + println!("{}", redirect_url.to_string()); + println!(""); + + let (access_token, refresh_token) = builder.wait_for_redirection(client, csrf_token)?; + + Secret::new_keyring(format!("{account_name}-imap-oauth2-access-token")) + .set(access_token)?; + + if let Some(refresh_token) = &refresh_token { + Secret::new_keyring(format!("{account_name}-imap-oauth2-refresh-token")) + .set(refresh_token)?; + } + + ImapAuthConfig::OAuth2(config) + } + _ => ImapAuthConfig::default(), + }; + + Ok(BackendConfig::Imap(config)) +} diff --git a/src/domain/backend/maildir/mod.rs b/src/domain/backend/maildir/mod.rs new file mode 100644 index 0000000..73818b4 --- /dev/null +++ b/src/domain/backend/maildir/mod.rs @@ -0,0 +1 @@ +pub(crate) mod wizard; diff --git a/src/domain/backend/maildir/wizard.rs b/src/domain/backend/maildir/wizard.rs new file mode 100644 index 0000000..e0ac51e --- /dev/null +++ b/src/domain/backend/maildir/wizard.rs @@ -0,0 +1,23 @@ +use anyhow::Result; +use dialoguer::Input; +use dirs::home_dir; +use pimalaya_email::{BackendConfig, MaildirConfig}; + +use crate::config::wizard::THEME; + +pub(crate) fn configure() -> Result { + let mut config = MaildirConfig::default(); + + let mut input = Input::with_theme(&*THEME); + + if let Some(home) = home_dir() { + input.default(home.join("Mail").display().to_string()); + }; + + config.root_dir = input + .with_prompt("Maildir directory") + .interact_text()? + .into(); + + Ok(BackendConfig::Maildir(config)) +} diff --git a/src/domain/backend/mod.rs b/src/domain/backend/mod.rs new file mode 100644 index 0000000..548ca7c --- /dev/null +++ b/src/domain/backend/mod.rs @@ -0,0 +1,6 @@ +#[cfg(feature = "imap-backend")] +pub mod imap; +pub mod maildir; +#[cfg(feature = "notmuch-backend")] +pub mod notmuch; +pub(crate) mod wizard; diff --git a/src/domain/backend/notmuch/mod.rs b/src/domain/backend/notmuch/mod.rs new file mode 100644 index 0000000..73818b4 --- /dev/null +++ b/src/domain/backend/notmuch/mod.rs @@ -0,0 +1 @@ +pub(crate) mod wizard; diff --git a/src/domain/backend/notmuch/wizard.rs b/src/domain/backend/notmuch/wizard.rs new file mode 100644 index 0000000..1438e76 --- /dev/null +++ b/src/domain/backend/notmuch/wizard.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use dialoguer::Input; +use pimalaya_email::{BackendConfig, NotmuchBackend, NotmuchConfig}; + +use crate::config::wizard::THEME; + +pub(crate) fn configure() -> Result { + let mut config = NotmuchConfig::default(); + + config.db_path = if let Ok(db_path) = NotmuchBackend::get_default_db_path() { + db_path + } else { + let db_path: String = Input::with_theme(&*THEME) + .with_prompt("Notmuch database path") + .interact_text()?; + db_path.into() + }; + + Ok(BackendConfig::Notmuch(config)) +} diff --git a/src/domain/backend/wizard.rs b/src/domain/backend/wizard.rs new file mode 100644 index 0000000..d8840b2 --- /dev/null +++ b/src/domain/backend/wizard.rs @@ -0,0 +1,44 @@ +use anyhow::Result; +use dialoguer::Select; +use pimalaya_email::BackendConfig; + +use crate::config::wizard::THEME; + +#[cfg(feature = "imap-backend")] +use super::imap; +use super::maildir; +#[cfg(feature = "notmuch-backend")] +use super::notmuch; + +#[cfg(feature = "imap-backend")] +const IMAP: &str = "IMAP"; +const MAILDIR: &str = "Maildir"; +#[cfg(feature = "notmuch-backend")] +const NOTMUCH: &str = "Notmuch"; +const NONE: &str = "None"; + +const BACKENDS: &[&str] = &[ + #[cfg(feature = "imap-backend")] + IMAP, + MAILDIR, + #[cfg(feature = "notmuch-backend")] + NOTMUCH, + NONE, +]; + +pub(crate) fn configure(account_name: &str, email: &str) -> Result { + let backend = Select::with_theme(&*THEME) + .with_prompt("Email backend") + .items(BACKENDS) + .default(0) + .interact_opt()?; + + match backend { + #[cfg(feature = "imap-backend")] + Some(idx) if BACKENDS[idx] == IMAP => imap::wizard::configure(account_name, email), + Some(idx) if BACKENDS[idx] == MAILDIR => maildir::wizard::configure(), + #[cfg(feature = "notmuch-backend")] + Some(idx) if BACKENDS[idx] == NOTMUCH => notmuch::wizard::configure(), + _ => Ok(BackendConfig::None), + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 34862e7..d9b40c6 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,17 +1,16 @@ pub mod account; +pub mod backend; pub mod email; pub mod envelope; pub mod flag; pub mod folder; -#[cfg(feature = "imap-backend")] -pub mod imap; +pub mod sender; pub mod tpl; pub use self::account::{args, handlers, Account, Accounts}; +pub use self::backend::*; pub use self::email::*; pub use self::envelope::*; pub use self::flag::*; pub use self::folder::*; -#[cfg(feature = "imap-backend")] -pub use self::imap::*; pub use self::tpl::*; diff --git a/src/domain/sender/mod.rs b/src/domain/sender/mod.rs new file mode 100644 index 0000000..0d6be82 --- /dev/null +++ b/src/domain/sender/mod.rs @@ -0,0 +1,3 @@ +pub mod sendmail; +pub mod smtp; +pub(crate) mod wizard; diff --git a/src/domain/sender/sendmail/mod.rs b/src/domain/sender/sendmail/mod.rs new file mode 100644 index 0000000..73818b4 --- /dev/null +++ b/src/domain/sender/sendmail/mod.rs @@ -0,0 +1 @@ +pub(crate) mod wizard; diff --git a/src/domain/sender/sendmail/wizard.rs b/src/domain/sender/sendmail/wizard.rs new file mode 100644 index 0000000..59994d3 --- /dev/null +++ b/src/domain/sender/sendmail/wizard.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use dialoguer::Input; +use pimalaya_email::{SenderConfig, SendmailConfig}; + +use crate::config::wizard::THEME; + +pub(crate) fn configure() -> Result { + let mut config = SendmailConfig::default(); + + config.cmd = Input::with_theme(&*THEME) + .with_prompt("Sendmail-compatible shell command to send emails") + .default(String::from("/usr/bin/msmtp")) + .interact()? + .into(); + + Ok(SenderConfig::Sendmail(config)) +} diff --git a/src/domain/sender/smtp/mod.rs b/src/domain/sender/smtp/mod.rs new file mode 100644 index 0000000..73818b4 --- /dev/null +++ b/src/domain/sender/smtp/mod.rs @@ -0,0 +1 @@ +pub(crate) mod wizard; diff --git a/src/domain/sender/smtp/wizard.rs b/src/domain/sender/smtp/wizard.rs new file mode 100644 index 0000000..2572524 --- /dev/null +++ b/src/domain/sender/smtp/wizard.rs @@ -0,0 +1,214 @@ +use anyhow::Result; +use dialoguer::{Confirm, Input, Select}; +use pimalaya_email::{ + OAuth2Config, OAuth2Method, OAuth2Scopes, PasswdConfig, SenderConfig, SmtpAuthConfig, + SmtpConfig, +}; +use pimalaya_oauth2::AuthorizationCodeGrant; +use pimalaya_secret::Secret; + +use crate::{ + config::wizard::{prompt_passwd, THEME}, + wizard_log, wizard_prompt, +}; + +const SSL_TLS: &str = "SSL/TLS"; +const STARTTLS: &str = "STARTTLS"; +const NONE: &str = "None"; +const PROTOCOLS: &[&str] = &[SSL_TLS, STARTTLS, NONE]; + +const PASSWD: &str = "Password"; +const OAUTH2: &str = "OAuth 2.0"; +const AUTH_MECHANISMS: &[&str] = &[PASSWD, OAUTH2]; + +const XOAUTH2: &str = "XOAUTH2"; +const OAUTHBEARER: &str = "OAUTHBEARER"; +const OAUTH2_MECHANISMS: &[&str] = &[XOAUTH2, OAUTHBEARER]; + +const SECRETS: &[&str] = &[KEYRING, RAW, CMD]; +const KEYRING: &str = "Ask the password, then save it in my system's global keyring"; +const RAW: &str = "Ask the password, then save it in the configuration file (not safe)"; +const CMD: &str = "Use a shell command that exposes the password"; + +pub(crate) fn configure(account_name: &str, email: &str) -> Result { + let mut config = SmtpConfig::default(); + + config.host = Input::with_theme(&*THEME) + .with_prompt("SMTP host") + .default(format!("smtp.{}", email.rsplit_once('@').unwrap().1)) + .interact()?; + + let protocol = Select::with_theme(&*THEME) + .with_prompt("SMTP security protocol") + .items(PROTOCOLS) + .default(0) + .interact_opt()?; + + let default_port = match protocol { + Some(idx) if PROTOCOLS[idx] == SSL_TLS => { + config.ssl = Some(true); + 465 + } + Some(idx) if PROTOCOLS[idx] == STARTTLS => { + config.starttls = Some(true); + 587 + } + _ => 25, + }; + + config.port = Input::with_theme(&*THEME) + .with_prompt("SMTP port") + .validate_with(|input: &String| input.parse::().map(|_| ())) + .default(default_port.to_string()) + .interact() + .map(|input| input.parse::().unwrap())?; + + config.login = Input::with_theme(&*THEME) + .with_prompt("SMTP login") + .default(email.to_owned()) + .interact()?; + + let auth = Select::with_theme(&*THEME) + .with_prompt("SMTP authentication mechanism") + .items(AUTH_MECHANISMS) + .default(0) + .interact_opt()?; + + config.auth = match auth { + Some(idx) if AUTH_MECHANISMS[idx] == PASSWD => { + let secret = Select::with_theme(&*THEME) + .with_prompt("SMTP authentication strategy") + .items(SECRETS) + .default(0) + .interact_opt()?; + + let config = match secret { + Some(idx) if SECRETS[idx] == KEYRING => { + Secret::new_keyring(format!("{account_name}-smtp-passwd")) + .set(prompt_passwd("SMTP password")?)?; + PasswdConfig::default() + } + Some(idx) if SECRETS[idx] == RAW => PasswdConfig { + passwd: Secret::Raw(prompt_passwd("SMTP password")?), + }, + Some(idx) if SECRETS[idx] == CMD => PasswdConfig { + passwd: Secret::new_cmd( + Input::with_theme(&*THEME) + .with_prompt("Shell command") + .default(format!("pass show {account_name}-smtp-passwd")) + .interact()?, + ), + }, + _ => PasswdConfig::default(), + }; + SmtpAuthConfig::Passwd(config) + } + Some(idx) if AUTH_MECHANISMS[idx] == OAUTH2 => { + let mut config = OAuth2Config::default(); + + let method = Select::with_theme(&*THEME) + .with_prompt("SMTP OAuth 2.0 mechanism") + .items(OAUTH2_MECHANISMS) + .default(0) + .interact_opt()?; + + config.method = match method { + Some(idx) if OAUTH2_MECHANISMS[idx] == XOAUTH2 => OAuth2Method::XOAuth2, + Some(idx) if OAUTH2_MECHANISMS[idx] == OAUTHBEARER => OAuth2Method::OAuthBearer, + _ => OAuth2Method::XOAuth2, + }; + + config.client_id = Input::with_theme(&*THEME) + .with_prompt("SMTP OAuth 2.0 client id") + .interact()?; + + let client_secret: String = Input::with_theme(&*THEME) + .with_prompt("SMTP OAuth 2.0 client secret") + .interact()?; + Secret::new_keyring(format!("{account_name}-smtp-oauth2-client-secret")) + .set(&client_secret)?; + + config.auth_url = Input::with_theme(&*THEME) + .with_prompt("SMTP OAuth 2.0 authorization URL") + .interact()?; + + config.token_url = Input::with_theme(&*THEME) + .with_prompt("SMTP OAuth 2.0 token URL") + .interact()?; + + config.scopes = OAuth2Scopes::Scope( + Input::with_theme(&*THEME) + .with_prompt("SMTP OAuth 2.0 main scope") + .interact()?, + ); + + while Confirm::new() + .with_prompt(wizard_prompt!( + "Would you like to add more SMTP OAuth 2.0 scopes?" + )) + .default(false) + .interact_opt()? + .unwrap_or_default() + { + let mut scopes = match config.scopes { + OAuth2Scopes::Scope(scope) => vec![scope], + OAuth2Scopes::Scopes(scopes) => scopes, + }; + + scopes.push( + Input::with_theme(&*THEME) + .with_prompt("Additional SMTP OAuth 2.0 scope") + .interact()?, + ); + + config.scopes = OAuth2Scopes::Scopes(scopes); + } + + config.pkce = Confirm::new() + .with_prompt(wizard_prompt!( + "Would you like to enable PKCE verification?" + )) + .default(true) + .interact_opt()? + .unwrap_or(true); + + wizard_log!("To complete your OAuth 2.0 setup, click on the following link:"); + + let mut builder = AuthorizationCodeGrant::new( + config.client_id.clone(), + client_secret, + config.auth_url.clone(), + config.token_url.clone(), + )?; + + if config.pkce { + builder = builder.with_pkce(); + } + + for scope in config.scopes.clone() { + builder = builder.with_scope(scope); + } + + let client = builder.get_client()?; + let (redirect_url, csrf_token) = builder.get_redirect_url(&client); + + println!("{}", redirect_url.to_string()); + println!(""); + + let (access_token, refresh_token) = builder.wait_for_redirection(client, csrf_token)?; + + Secret::new_keyring(format!("{account_name}-smtp-oauth2-access-token")) + .set(access_token)?; + + if let Some(refresh_token) = &refresh_token { + Secret::new_keyring(format!("{account_name}-smtp-oauth2-refresh-token")) + .set(refresh_token)?; + } + + SmtpAuthConfig::OAuth2(config) + } + _ => SmtpAuthConfig::default(), + }; + + Ok(SenderConfig::Smtp(config)) +} diff --git a/src/domain/sender/wizard.rs b/src/domain/sender/wizard.rs new file mode 100644 index 0000000..8bc2e8f --- /dev/null +++ b/src/domain/sender/wizard.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use dialoguer::Select; +use pimalaya_email::SenderConfig; + +use crate::config::wizard::THEME; + +use super::sendmail; +#[cfg(feature = "smtp-sender")] +use super::smtp; + +#[cfg(feature = "smtp-sender")] +const SMTP: &str = "SMTP"; +const SENDMAIL: &str = "Sendmail"; +const NONE: &str = "None"; + +const SENDERS: &[&str] = &[ + #[cfg(feature = "smtp-sender")] + SMTP, + SENDMAIL, + NONE, +]; + +pub(crate) fn configure(account_name: &str, email: &str) -> Result { + let sender = Select::with_theme(&*THEME) + .with_prompt("Email sender") + .items(SENDERS) + .default(0) + .interact_opt()?; + + match sender { + #[cfg(feature = "smtp-sender")] + Some(n) if SENDERS[n] == SMTP => smtp::wizard::configure(account_name, email), + Some(n) if SENDERS[n] == SENDMAIL => sendmail::wizard::configure(), + _ => Ok(SenderConfig::None), + } +} diff --git a/src/main.rs b/src/main.rs index a1dba41..8c5c5dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,9 +111,8 @@ fn main() -> Result<()> { } // inits services - let mut sender = SenderBuilder::new().build(&account_config)?; - let mut printer = StdoutPrinter::try_from(&m)?; let disable_cache = cache::args::parse_disable_cache_flag(&m); + let mut printer = StdoutPrinter::try_from(&m)?; // checks account commands match account::args::matches(&m)? { @@ -237,6 +236,7 @@ fn main() -> Result<()> { let mut backend = BackendBuilder::new() .disable_cache(disable_cache) .build(&account_config)?; + let mut sender = SenderBuilder::new().build(&account_config)?; let id_mapper = IdMapper::new(backend.as_ref(), &account_config.name, &folder)?; return email::handlers::forward( &account_config, @@ -307,6 +307,7 @@ fn main() -> Result<()> { let mut backend = BackendBuilder::new() .disable_cache(disable_cache) .build(&account_config)?; + let mut sender = SenderBuilder::new().build(&account_config)?; let id_mapper = IdMapper::new(backend.as_ref(), &account_config.name, &folder)?; return email::handlers::reply( &account_config, @@ -377,6 +378,7 @@ fn main() -> Result<()> { let mut backend = BackendBuilder::new() .disable_cache(disable_cache) .build(&account_config)?; + let mut sender = SenderBuilder::new().build(&account_config)?; return email::handlers::send( &account_config, &mut printer, @@ -492,6 +494,7 @@ fn main() -> Result<()> { let mut backend = BackendBuilder::new() .disable_cache(disable_cache) .build(&account_config)?; + let mut sender = SenderBuilder::new().build(&account_config)?; return tpl::handlers::send( &account_config, &mut printer, @@ -507,6 +510,7 @@ fn main() -> Result<()> { let mut backend = BackendBuilder::new() .disable_cache(disable_cache) .build(&account_config)?; + let mut sender = SenderBuilder::new().build(&account_config)?; return email::handlers::write( &account_config, &mut printer,