mirror of
https://github.com/soywod/himalaya.git
synced 2024-11-21 18:40:19 +00:00
refactor wizard to handle password and oauth2 configuration
This commit is contained in:
parent
d814ae904a
commit
5da1148dc9
36 changed files with 981 additions and 588 deletions
40
CHANGELOG.md
40
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:
|
||||
|
||||
- `<key> = "secret-value"` for the raw secret (unsafe, not
|
||||
recommanded),
|
||||
- `<key>-cmd = "echo 'secret-value'"` for command that retrieve the
|
||||
secret,
|
||||
- `<key>-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 = <secret> }` for the raw secret (unsafe, not recommanded),
|
||||
- `{ cmd = <secret-cmd> }` for command that exposes the secret,
|
||||
- `{ keyring = <secret-entry> }` 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`
|
||||
|
|
70
Cargo.lock
generated
70
Cargo.lock
generated
|
@ -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"
|
||||
|
|
19
Cargo.toml
19
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"] }
|
||||
|
|
|
@ -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<usize>,
|
||||
pub email_reading_headers: Option<Vec<String>>,
|
||||
#[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<Self> {
|
||||
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"));
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
pub mod args;
|
||||
pub mod config;
|
||||
pub mod prelude;
|
||||
pub(crate) mod wizard;
|
||||
pub mod wizard;
|
||||
|
||||
pub use config::*;
|
||||
|
|
|
@ -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<String>")]
|
||||
pub struct PipelineDef;
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(remote = "Pipeline", from = "Vec<String>")]
|
||||
pub struct PipelineDef(
|
||||
#[serde(getter = "Deref::deref", serialize_with = "pipeline")] Vec<SingleCmd>,
|
||||
);
|
||||
|
||||
#[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<SingleCmdOrPipeline> 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<S>(cmds: &Vec<SingleCmd>, s: S) -> Result<S::Ok, S::Error>
|
||||
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<Cmd>", 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<Cmd>", 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<OptionSingleCmdOrPipeline> for Option<Cmd> {
|
||||
fn from(cmd: OptionSingleCmdOrPipeline) -> Option<Cmd> {
|
||||
impl From<OptionCmd> for Option<Cmd> {
|
||||
fn from(cmd: OptionCmd) -> Option<Cmd> {
|
||||
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<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<String>),
|
||||
}
|
||||
|
||||
#[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<String>),
|
||||
}
|
||||
|
||||
#[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<Cmd>,
|
||||
}
|
||||
|
||||
#[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]
|
||||
|
|
122
src/config/wizard.rs
Normal file
122
src/config/wizard.rs
Normal file
|
@ -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<ColorfulTheme> = Lazy::new(ColorfulTheme::default);
|
||||
|
||||
pub(crate) fn configure() -> Result<DeserializedConfig> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
Password::with_theme(&*THEME)
|
||||
.with_prompt(prompt)
|
||||
.report(false)
|
||||
.interact()
|
||||
}
|
|
@ -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<BackendConfig> {
|
||||
// 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::<u16>().map(|_| ()))
|
||||
.default(default_port.to_string())
|
||||
.interact()
|
||||
.map(|input| input.parse::<u16>().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))
|
||||
}
|
|
@ -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<BackendConfig> {
|
||||
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))
|
||||
}
|
|
@ -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<ColorfulTheme> = Lazy::new(ColorfulTheme::default);
|
||||
|
||||
pub(crate) fn wizard() -> Result<DeserializedConfig> {
|
||||
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<Option<DeserializedAccountConfig>> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
Input::with_theme(&*THEME)
|
||||
.with_prompt(prompt)
|
||||
.report(false)
|
||||
.interact()
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use dialoguer::Input;
|
||||
use pimalaya_email::{BackendConfig, NotmuchBackend, NotmuchConfig};
|
||||
|
||||
use super::THEME;
|
||||
|
||||
pub(crate) fn configure() -> Result<BackendConfig> {
|
||||
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))
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use dialoguer::Input;
|
||||
use pimalaya_email::{SenderConfig, SendmailConfig};
|
||||
|
||||
use super::THEME;
|
||||
|
||||
pub(crate) fn configure() -> Result<SenderConfig> {
|
||||
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))
|
||||
}
|
|
@ -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<SenderConfig> {
|
||||
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::<u16>().map(|_| ()))
|
||||
.default(default_port.to_string())
|
||||
.interact()
|
||||
.map(|input| input.parse::<u16>().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))
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
use anyhow::anyhow;
|
||||
use dialoguer::Validator;
|
||||
use email_address::EmailAddress;
|
||||
|
||||
pub(crate) struct EmailValidator;
|
||||
|
||||
impl<T: ToString> Validator<T> 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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<usize>,
|
||||
pub email_reading_headers: Option<Vec<String>>,
|
||||
#[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<bool>,
|
||||
pub sync_dir: Option<PathBuf>,
|
||||
#[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
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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::*;
|
||||
|
|
39
src/domain/account/wizard.rs
Normal file
39
src/domain/account/wizard.rs
Normal file
|
@ -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<Option<(String, DeserializedAccountConfig)>> {
|
||||
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)))
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
pub mod args;
|
||||
pub mod handlers;
|
||||
pub(crate) mod wizard;
|
214
src/domain/backend/imap/wizard.rs
Normal file
214
src/domain/backend/imap/wizard.rs
Normal file
|
@ -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<BackendConfig> {
|
||||
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::<u16>().map(|_| ()))
|
||||
.default(default_port.to_string())
|
||||
.interact()
|
||||
.map(|input| input.parse::<u16>().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))
|
||||
}
|
1
src/domain/backend/maildir/mod.rs
Normal file
1
src/domain/backend/maildir/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub(crate) mod wizard;
|
23
src/domain/backend/maildir/wizard.rs
Normal file
23
src/domain/backend/maildir/wizard.rs
Normal file
|
@ -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<BackendConfig> {
|
||||
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))
|
||||
}
|
6
src/domain/backend/mod.rs
Normal file
6
src/domain/backend/mod.rs
Normal file
|
@ -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;
|
1
src/domain/backend/notmuch/mod.rs
Normal file
1
src/domain/backend/notmuch/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub(crate) mod wizard;
|
20
src/domain/backend/notmuch/wizard.rs
Normal file
20
src/domain/backend/notmuch/wizard.rs
Normal file
|
@ -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<BackendConfig> {
|
||||
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))
|
||||
}
|
44
src/domain/backend/wizard.rs
Normal file
44
src/domain/backend/wizard.rs
Normal file
|
@ -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<BackendConfig> {
|
||||
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),
|
||||
}
|
||||
}
|
|
@ -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::*;
|
||||
|
|
3
src/domain/sender/mod.rs
Normal file
3
src/domain/sender/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod sendmail;
|
||||
pub mod smtp;
|
||||
pub(crate) mod wizard;
|
1
src/domain/sender/sendmail/mod.rs
Normal file
1
src/domain/sender/sendmail/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub(crate) mod wizard;
|
17
src/domain/sender/sendmail/wizard.rs
Normal file
17
src/domain/sender/sendmail/wizard.rs
Normal file
|
@ -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<SenderConfig> {
|
||||
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))
|
||||
}
|
1
src/domain/sender/smtp/mod.rs
Normal file
1
src/domain/sender/smtp/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub(crate) mod wizard;
|
214
src/domain/sender/smtp/wizard.rs
Normal file
214
src/domain/sender/smtp/wizard.rs
Normal file
|
@ -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<SenderConfig> {
|
||||
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::<u16>().map(|_| ()))
|
||||
.default(default_port.to_string())
|
||||
.interact()
|
||||
.map(|input| input.parse::<u16>().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))
|
||||
}
|
36
src/domain/sender/wizard.rs
Normal file
36
src/domain/sender/wizard.rs
Normal file
|
@ -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<SenderConfig> {
|
||||
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),
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue