refactor wizard to handle password and oauth2 configuration

This commit is contained in:
Clément DOUIN 2023-05-16 19:07:56 +02:00
parent d814ae904a
commit 5da1148dc9
No known key found for this signature in database
GPG key ID: 353E4A18EE0FAB72
36 changed files with 981 additions and 588 deletions

View file

@ -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
View file

@ -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"

View file

@ -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"] }

View file

@ -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"));

View file

@ -1,6 +1,6 @@
pub mod args;
pub mod config;
pub mod prelude;
pub(crate) mod wizard;
pub mod wizard;
pub use config::*;

View file

@ -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
View 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()
}

View file

@ -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))
}

View file

@ -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))
}

View file

@ -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()
}

View file

@ -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))
}

View file

@ -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))
}

View file

@ -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))
}

View file

@ -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))
}
}
}

View file

@ -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
},
}
}
}

View file

@ -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(())
}

View file

@ -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::*;

View 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)))
}

View file

@ -1,2 +1,3 @@
pub mod args;
pub mod handlers;
pub(crate) mod wizard;

View 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))
}

View file

@ -0,0 +1 @@
pub(crate) mod wizard;

View 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))
}

View 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;

View file

@ -0,0 +1 @@
pub(crate) mod wizard;

View 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))
}

View 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),
}
}

View file

@ -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
View file

@ -0,0 +1,3 @@
pub mod sendmail;
pub mod smtp;
pub(crate) mod wizard;

View file

@ -0,0 +1 @@
pub(crate) mod wizard;

View 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))
}

View file

@ -0,0 +1 @@
pub(crate) mod wizard;

View 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))
}

View 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),
}
}

View file

@ -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,