diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a76684..ef171a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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` + +### Changed + +- Changed the way secrets are managed. A secret is a sensitive data + like passwords or tokens. There is 3 possible ways to declare a + secret in the config file: + + - ` = "secret-value"` for the raw secret (unsafe, not + recommanded), + - `-cmd = "echo 'secret-value'"` for command that retrieve the + secret, + - `-keyring = "keyring-entry"` for entry in your system's + global keyring that contains the secret. + + This applies for: + + - `imap-passwd` + - `imap-oauth2-client-secret` + - `imap-oauth2-access-token` + - `imap-oauth2-refresh-token` + - `smtp-passwd` + - `smtp-oauth2-client-secret` + - `smtp-oauth2-access-token` + - `smtp-oauth2-refresh-token` + ## [0.7.3] - 2023-05-01 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 8f444ad..f0634dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1147,6 +1147,9 @@ dependencies = [ "log", "once_cell", "pimalaya-email", + "pimalaya-keyring", + "pimalaya-process", + "pimalaya-secret", "serde", "serde_json", "shellexpand", @@ -2098,7 +2101,7 @@ dependencies = [ [[package]] name = "pimalaya-email" version = "0.7.1" -source = "git+https://git.sr.ht/~soywod/pimalaya#22db3ec886536897c54b72d4ab7d20beff0ffecc" +source = "git+https://git.sr.ht/~soywod/pimalaya#524f8049566845369f55300a20843c48dfe7a620" dependencies = [ "ammonia", "chrono", @@ -2107,7 +2110,6 @@ dependencies = [ "html-escape", "imap", "imap-proto", - "keyring", "lettre", "log", "mail-parser", @@ -2120,6 +2122,7 @@ dependencies = [ "once_cell", "ouroboros", "pimalaya-oauth2", + "pimalaya-secret", "proc-lock", "rayon", "regex", @@ -2136,10 +2139,20 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "pimalaya-keyring" +version = "0.0.1" +source = "git+https://git.sr.ht/~soywod/pimalaya#524f8049566845369f55300a20843c48dfe7a620" +dependencies = [ + "keyring", + "log", + "thiserror", +] + [[package]] name = "pimalaya-oauth2" version = "0.0.1" -source = "git+https://git.sr.ht/~soywod/pimalaya#22db3ec886536897c54b72d4ab7d20beff0ffecc" +source = "git+https://git.sr.ht/~soywod/pimalaya#524f8049566845369f55300a20843c48dfe7a620" dependencies = [ "log", "oauth2", @@ -2148,6 +2161,27 @@ dependencies = [ "url", ] +[[package]] +name = "pimalaya-process" +version = "0.0.1" +source = "git+https://git.sr.ht/~soywod/pimalaya#524f8049566845369f55300a20843c48dfe7a620" +dependencies = [ + "log", + "thiserror", +] + +[[package]] +name = "pimalaya-secret" +version = "0.0.1" +source = "git+https://git.sr.ht/~soywod/pimalaya#524f8049566845369f55300a20843c48dfe7a620" +dependencies = [ + "log", + "pimalaya-keyring", + "pimalaya-oauth2", + "pimalaya-process", + "thiserror", +] + [[package]] name = "pin-project-lite" version = "0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 245ed59..3585f73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,15 +43,22 @@ clap = "4.0" clap_complete = "4.0" clap_mangen = "0.2" console = "0.15.2" -dirs = "4.0.0" dialoguer = "0.10.2" +dirs = "4.0.0" email_address = "0.2.4" env_logger = "0.8" erased-serde = "0.3" -pimalaya-email = { git = "https://git.sr.ht/~soywod/pimalaya" } indicatif = "0.17" log = "0.4" 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" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" shellexpand = "2.1" diff --git a/src/config/prelude.rs b/src/config/prelude.rs index ad36f49..919efd2 100644 --- a/src/config/prelude.rs +++ b/src/config/prelude.rs @@ -1,8 +1,11 @@ use pimalaya_email::{ folder::sync::Strategy as SyncFoldersStrategy, EmailHooks, EmailSender, EmailTextPlainFormat, - ImapAuthConfig, MaildirConfig, OAuth2ClientSecret, OAuth2Config, OAuth2Method, OAuth2Scopes, - SendmailConfig, SmtpConfig, + ImapAuthConfig, MaildirConfig, OAuth2Config, OAuth2Method, OAuth2Scopes, PasswdConfig, + SendmailConfig, SmtpAuthConfig, SmtpConfig, }; +use pimalaya_keyring::Entry; +use pimalaya_process::Cmd; +use pimalaya_secret::Secret; use serde::{Deserialize, Serialize}; use std::{collections::HashSet, path::PathBuf}; @@ -12,27 +15,61 @@ use pimalaya_email::ImapConfig; #[cfg(feature = "notmuch-backend")] use pimalaya_email::NotmuchConfig; -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "SmtpConfig")] -struct SmtpConfigDef { - #[serde(rename = "smtp-host")] - pub host: String, - #[serde(rename = "smtp-port")] - pub port: u16, - #[serde(rename = "smtp-ssl")] - pub ssl: Option, - #[serde(rename = "smtp-starttls")] - pub starttls: Option, - #[serde(rename = "smtp-insecure")] - pub insecure: Option, - #[serde(rename = "smtp-login")] - pub login: String, - #[serde(rename = "smtp-passwd-cmd")] - pub passwd_cmd: String, +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "Entry", from = "String")] +pub struct EntryDef; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "Cmd", from = "String")] +pub struct SingleCmdDef; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "Cmd", from = "Vec")] +pub struct PipelineDef; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "Cmd", from = "SingleCmdOrPipeline")] +pub struct SingleCmdOrPipelineDef; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum SingleCmdOrPipeline { + #[serde(with = "SingleCmdDef")] + SingleCmd(Cmd), + #[serde(with = "PipelineDef")] + Pipeline(Cmd), +} + +impl From for Cmd { + fn from(cmd: SingleCmdOrPipeline) -> Cmd { + match cmd { + SingleCmdOrPipeline::SingleCmd(cmd) => cmd, + SingleCmdOrPipeline::Pipeline(cmd) => cmd, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "Secret", rename_all = "kebab-case")] +pub enum SecretDef { + Raw(String), + #[serde(with = "SingleCmdOrPipelineDef")] + Cmd(Cmd), + #[serde(with = "EntryDef")] + Keyring(Entry), +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "OAuth2Method")] +pub enum OAuth2MethodDef { + #[serde(rename = "xoauth2", alias = "XOAUTH2")] + XOAuth2, + #[serde(rename = "oauthbearer", alias = "OAUTHBEARER")] + OAuthBearer, } #[cfg(feature = "imap-backend")] -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(remote = "ImapConfig")] pub struct ImapConfigDef { #[serde(rename = "imap-host")] @@ -47,7 +84,7 @@ pub struct ImapConfigDef { pub insecure: Option, #[serde(rename = "imap-login")] pub login: String, - #[serde(rename = "imap-auth", with = "ImapAuthConfigDef")] + #[serde(flatten, with = "ImapAuthConfigDef")] pub auth: ImapAuthConfig, #[serde(rename = "imap-notify-cmd")] pub notify_cmd: Option, @@ -58,51 +95,50 @@ pub struct ImapConfigDef { } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "ImapAuthConfig", rename_all = "kebab-case")] +#[serde(remote = "ImapAuthConfig", tag = "imap-auth")] pub enum ImapAuthConfigDef { - #[serde(skip)] - None, - RawPasswd(String), - PasswdCmd(String), - #[serde(with = "OAuth2ConfigDef", rename = "oauth2")] + #[serde(rename = "passwd", alias = "password", with = "ImapPasswdConfigDef")] + Passwd(#[serde(default)] PasswdConfig), + #[serde(rename = "oauth2", with = "ImapOAuth2ConfigDef")] OAuth2(OAuth2Config), } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "OAuth2Config", rename_all = "kebab-case")] -pub struct OAuth2ConfigDef { - #[serde(with = "OAuth2MethodDef")] +#[serde(remote = "PasswdConfig")] +pub struct ImapPasswdConfigDef { + #[serde(rename = "imap-passwd", with = "SecretDef", default)] + pub passwd: Secret, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "OAuth2Config")] +pub struct ImapOAuth2ConfigDef { + #[serde(rename = "imap-oauth2-method", with = "OAuth2MethodDef", default)] pub method: OAuth2Method, + #[serde(rename = "imap-oauth2-client-id")] pub client_id: String, - #[serde(with = "OAuth2ClientSecretDef")] - pub client_secret: OAuth2ClientSecret, + #[serde(rename = "imap-oauth2-client-secret", with = "SecretDef", default)] + pub client_secret: Secret, + #[serde(rename = "imap-oauth2-auth-url")] pub auth_url: String, + #[serde(rename = "imap-oauth2-token-url")] pub token_url: String, - #[serde(flatten, with = "OAuth2ScopesDef")] + #[serde(rename = "imap-oauth2-access-token", with = "SecretDef", default)] + pub access_token: Secret, + #[serde(rename = "imap-oauth2-refresh-token", with = "SecretDef", default)] + pub refresh_token: Secret, + #[serde(flatten, with = "ImapOAuth2ScopesDef")] pub scopes: OAuth2Scopes, - #[serde(default)] + #[serde(rename = "imap-oauth2-pkce", default)] pub pkce: bool, } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "OAuth2ClientSecret", rename_all = "kebab-case")] -pub enum OAuth2ClientSecretDef { - Raw(String), - Cmd(String), - Keyring, -} - -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "OAuth2Method", rename_all = "lowercase")] -pub enum OAuth2MethodDef { - XOAuth2, - OAuthBearer, -} - -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(remote = "OAuth2Scopes", rename_all = "kebab-case")] -pub enum OAuth2ScopesDef { +#[serde(remote = "OAuth2Scopes")] +pub enum ImapOAuth2ScopesDef { + #[serde(rename = "imap-oauth2-scope")] Scope(String), + #[serde(rename = "imap-oauth2-scopes")] Scopes(Vec), } @@ -146,6 +182,73 @@ pub enum EmailSenderDef { Sendmail(SendmailConfig), } +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "SmtpConfig")] +struct SmtpConfigDef { + #[serde(rename = "smtp-host")] + pub host: String, + #[serde(rename = "smtp-port")] + pub port: u16, + #[serde(rename = "smtp-ssl")] + pub ssl: Option, + #[serde(rename = "smtp-starttls")] + pub starttls: Option, + #[serde(rename = "smtp-insecure")] + pub insecure: Option, + #[serde(rename = "smtp-login")] + pub login: String, + #[serde(flatten, with = "SmtpAuthConfigDef")] + pub auth: SmtpAuthConfig, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "SmtpAuthConfig", tag = "smtp-auth")] +pub enum SmtpAuthConfigDef { + #[serde(rename = "passwd", alias = "password", with = "SmtpPasswdConfigDef")] + Passwd(#[serde(default)] PasswdConfig), + #[serde(rename = "oauth2", with = "SmtpOAuth2ConfigDef")] + OAuth2(OAuth2Config), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "PasswdConfig", default)] +pub struct SmtpPasswdConfigDef { + #[serde(rename = "smtp-passwd", with = "SecretDef", default)] + pub passwd: Secret, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "OAuth2Config")] +pub struct SmtpOAuth2ConfigDef { + #[serde(rename = "smtp-oauth2-method", with = "OAuth2MethodDef", default)] + pub method: OAuth2Method, + #[serde(rename = "smtp-oauth2-client-id")] + pub client_id: String, + #[serde(rename = "smtp-oauth2-client-secret", with = "SecretDef", default)] + pub client_secret: Secret, + #[serde(rename = "smtp-oauth2-auth-url")] + pub auth_url: String, + #[serde(rename = "smtp-oauth2-token-url")] + pub token_url: String, + #[serde(rename = "smtp-oauth2-access-token", with = "SecretDef", default)] + pub access_token: Secret, + #[serde(rename = "smtp-oauth2-refresh-token", with = "SecretDef", default)] + pub refresh_token: Secret, + #[serde(flatten, with = "SmtpOAuth2ScopesDef")] + pub scopes: OAuth2Scopes, + #[serde(rename = "smtp-oauth2-pkce", default)] + pub pkce: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(remote = "OAuth2Scopes")] +pub enum SmtpOAuth2ScopesDef { + #[serde(rename = "smtp-oauth2-scope")] + Scope(String), + #[serde(rename = "smtp-oauth2-scopes")] + Scopes(Vec), +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] #[serde(remote = "SendmailConfig", rename_all = "kebab-case")] pub struct SendmailConfigDef { diff --git a/src/config/wizard/imap.rs b/src/config/wizard/imap.rs index c6afa41..3cb2c8a 100644 --- a/src/config/wizard/imap.rs +++ b/src/config/wizard/imap.rs @@ -1,7 +1,6 @@ use anyhow::Result; use dialoguer::{Input, Select}; use pimalaya_email::ImapConfig; -use std::io; use crate::account::{ DeserializedAccountConfig, DeserializedBaseAccountConfig, DeserializedImapAccountConfig, @@ -59,11 +58,3 @@ pub(crate) fn configure(base: DeserializedBaseAccountConfig) -> Result io::Result { - Input::with_theme(&*THEME) - .with_prompt("Enter your OAuth 2.0 client secret:") - .report(false) - .interact() -} diff --git a/src/config/wizard/mod.rs b/src/config/wizard/mod.rs index 443ac81..8a12709 100644 --- a/src/config/wizard/mod.rs +++ b/src/config/wizard/mod.rs @@ -11,10 +11,10 @@ use super::DeserializedConfig; use crate::account::{DeserializedAccountConfig, DeserializedBaseAccountConfig}; use anyhow::{anyhow, Result}; use console::style; -use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select}; use log::trace; use once_cell::sync::Lazy; -use std::{fs, process}; +use std::{fs, io, process}; const BACKENDS: &[&str] = &[ "Maildir", @@ -163,3 +163,20 @@ fn configure_base() -> Result { Ok(base_account_config) } + +pub(crate) fn prompt_passwd(prompt: &str) -> io::Result { + Password::with_theme(&*THEME) + .with_prompt(prompt) + .with_confirmation( + "Confirm password:", + "Passwords do not match, please try again.", + ) + .interact() +} + +pub(crate) fn prompt_secret(prompt: &str) -> io::Result { + Input::with_theme(&*THEME) + .with_prompt(prompt) + .report(false) + .interact() +} diff --git a/src/config/wizard/smtp.rs b/src/config/wizard/smtp.rs index 197973f..342723f 100644 --- a/src/config/wizard/smtp.rs +++ b/src/config/wizard/smtp.rs @@ -42,10 +42,11 @@ pub(crate) fn configure(base: &DeserializedBaseAccountConfig) -> Result ( - config.base.to_account_config(name, global_config), - BackendConfig::Imap(config.backend.clone()), - ), + DeserializedAccountConfig::Imap(config) => { + let mut imap_config = config.backend.clone(); + + match &mut imap_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" + )); + } + }; + + let account_config = config.base.to_account_config(name, global_config); + (account_config, BackendConfig::Imap(imap_config)) + } #[cfg(feature = "notmuch-backend")] DeserializedAccountConfig::Notmuch(config) => ( config.base.to_account_config(name, global_config), diff --git a/src/domain/account/handlers.rs b/src/domain/account/handlers.rs index 79f7921..5fe4fe1 100644 --- a/src/domain/account/handlers.rs +++ b/src/domain/account/handlers.rs @@ -4,14 +4,17 @@ use anyhow::Result; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use log::{info, trace}; +use log::{info, trace, warn}; use pimalaya_email::{ folder::sync::Strategy as SyncFoldersStrategy, AccountConfig, Backend, BackendConfig, - BackendSyncBuilder, BackendSyncProgressEvent, + BackendSyncBuilder, BackendSyncProgressEvent, EmailSender, ImapAuthConfig, SmtpAuthConfig, }; use crate::{ - config::{wizard::imap::configure_oauth2_client_secret, DeserializedConfig}, + config::{ + wizard::{prompt_passwd, prompt_secret}, + DeserializedConfig, + }, printer::{PrintTableOpts, Printer}, Accounts, }; @@ -23,21 +26,58 @@ pub fn configure( reset: bool, ) -> Result<()> { info!("entering the configure account handler"); - match backend_config { - BackendConfig::None => (), - BackendConfig::Maildir(_) => (), + + if reset { #[cfg(feature = "imap-backend")] - BackendConfig::Imap(imap_config) => { - imap_config.auth.configure( - &account_config.name, - reset, - configure_oauth2_client_secret, - )?; + if let BackendConfig::Imap(imap_config) = backend_config { + let reset = match &imap_config.auth { + ImapAuthConfig::Passwd(passwd) => passwd.reset(), + ImapAuthConfig::OAuth2(oauth2) => oauth2.reset(), + }; + if let Err(err) = reset { + warn!("error while resetting imap secrets, skipping it"); + warn!("{err}"); + } } - #[cfg(feature = "notmuch-backend")] - BackendConfig::Notmuch(config) => (), - }; - println!("Account {} configured!", account_config.name); + + #[cfg(feature = "smtp-sender")] + if let EmailSender::Smtp(smtp_config) = &account_config.email_sender { + let reset = match &smtp_config.auth { + SmtpAuthConfig::Passwd(passwd) => passwd.reset(), + SmtpAuthConfig::OAuth2(oauth2) => oauth2.reset(), + }; + if let Err(err) = reset { + warn!("error while resetting smtp secrets, skipping it"); + warn!("{err}"); + } + } + } + + #[cfg(feature = "imap-backend")] + if let BackendConfig::Imap(imap_config) = backend_config { + match &imap_config.auth { + ImapAuthConfig::Passwd(passwd) => { + passwd.configure(|| prompt_passwd("Enter your IMAP password:")) + } + ImapAuthConfig::OAuth2(oauth2) => { + oauth2.configure(|| prompt_secret("Enter your IMAP OAuth 2.0 client secret:")) + } + }?; + } + + #[cfg(feature = "smtp-sender")] + if let EmailSender::Smtp(smtp_config) = &account_config.email_sender { + match &smtp_config.auth { + SmtpAuthConfig::Passwd(passwd) => { + passwd.configure(|| prompt_passwd("Enter your SMTP password:")) + } + SmtpAuthConfig::OAuth2(oauth2) => { + oauth2.configure(|| prompt_secret("Enter your SMTP OAuth 2.0 client secret:")) + } + }?; + } + + println!("Account successfully configured!"); Ok(()) }