diff --git a/Cargo.lock b/Cargo.lock index 86d701f..f06f6b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1217,7 +1217,7 @@ dependencies = [ [[package]] name = "email-lib" version = "0.22.0" -source = "git+https://git.sr.ht/~soywod/pimalaya#f413917f9110ef4eafe4a8423626b22e4391317e" +source = "git+https://git.sr.ht/~soywod/pimalaya#01f7e96b55da8d46be00c2face31ee437e1c5c5a" dependencies = [ "advisory-lock", "anyhow", diff --git a/config.sample.toml b/config.sample.toml index c299fa4..ca1b709 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -16,11 +16,16 @@ signature-delim = "-- \n" # Enable the synchronization for this account. Running the command # `account sync example` will synchronize all folders and all emails # to a local Maildir at `$XDG_DATA_HOME/himalaya/example`. -sync.enable = true +sync.enable = false # Override the default Maildir path for synchronization. sync.dir = "/tmp/himalaya-sync-example" +# Filter folders to sync +folder.sync.filter.include = ["INBOX"] +# folder.sync.filter.exclude = ["All mails"] +# folder.sync.filter = "all" + # Define main folder aliases folder.alias.inbox = "INBOX" folder.alias.sent = "Sent" @@ -57,7 +62,7 @@ envelope.watch.received.notify.body = "{subject}" message.send.backend = "smtp" # Save a copy of sent messages to the sent folder. -message.send.save-copy = true +message.send.save-copy = false # IMAP config imap.host = "localhost" diff --git a/src/account/command/check_up.rs b/src/account/command/check_up.rs index 1015f81..3d35a6a 100644 --- a/src/account/command/check_up.rs +++ b/src/account/command/check_up.rs @@ -26,8 +26,11 @@ impl AccountCheckUpCommand { printer.print_log("Checking configuration integrity…")?; - let (toml_account_config, account_config) = - config.clone().into_account_configs(account, true)?; + let (toml_account_config, account_config) = config.clone().into_account_configs( + account, + #[cfg(feature = "account-sync")] + true, + )?; let used_backends = toml_account_config.get_used_backends(); printer.print_log("Checking backend context integrity…")?; diff --git a/src/account/wizard.rs b/src/account/wizard.rs index a032e4c..34f5c76 100644 --- a/src/account/wizard.rs +++ b/src/account/wizard.rs @@ -1,7 +1,7 @@ use anyhow::{bail, Result}; #[cfg(feature = "account-sync")] -use dialoguer::{Confirm, Input}; -use email::account; +use dialoguer::Confirm; +use dialoguer::Input; #[cfg(feature = "account-sync")] use email::account::sync::config::SyncConfig; use email_address::EmailAddress; @@ -9,11 +9,12 @@ use std::str::FromStr; #[cfg(feature = "account-sync")] use crate::wizard_prompt; +#[cfg(feature = "account-discovery")] +use crate::wizard_warn; use crate::{ backend::{self, config::BackendConfig, BackendKind}, message::config::{MessageConfig, MessageSendConfig}, ui::THEME, - wizard_warn, }; use super::TomlAccountConfig; @@ -34,9 +35,14 @@ pub(crate) async fn configure() -> Result> { let addr = EmailAddress::from_str(&config.email).unwrap(); + #[cfg(feature = "account-discovery")] let autoconfig_email = config.email.to_owned(); - let autoconfig = - tokio::spawn(async move { account::discover::from_addr(&autoconfig_email).await.ok() }); + #[cfg(feature = "account-discovery")] + let autoconfig = tokio::spawn(async move { + email::account::discover::from_addr(&autoconfig_email) + .await + .ok() + }); let account_name = Input::with_theme(&*THEME) .with_prompt("Account name") @@ -59,9 +65,12 @@ pub(crate) async fn configure() -> Result> { ); let email = &config.email; + #[cfg(feature = "account-discovery")] let autoconfig = autoconfig.await?; + #[cfg(feature = "account-discovery")] let autoconfig = autoconfig.as_ref(); + #[cfg(feature = "account-discovery")] if let Some(config) = autoconfig { if config.is_gmail() { println!(); @@ -71,7 +80,14 @@ pub(crate) async fn configure() -> Result> { } } - match backend::wizard::configure(&account_name, email, autoconfig).await? { + match backend::wizard::configure( + &account_name, + email, + #[cfg(feature = "account-discovery")] + autoconfig, + ) + .await? + { #[cfg(feature = "imap")] Some(BackendConfig::Imap(imap_config)) => { config.imap = Some(imap_config); @@ -90,7 +106,14 @@ pub(crate) async fn configure() -> Result> { _ => (), }; - match backend::wizard::configure_sender(&account_name, email, autoconfig).await? { + match backend::wizard::configure_sender( + &account_name, + email, + #[cfg(feature = "account-discovery")] + autoconfig, + ) + .await? + { #[cfg(feature = "smtp")] Some(BackendConfig::Smtp(smtp_config)) => { config.smtp = Some(smtp_config); diff --git a/src/backend/wizard.rs b/src/backend/wizard.rs index e2252f1..b37484a 100644 --- a/src/backend/wizard.rs +++ b/src/backend/wizard.rs @@ -1,5 +1,6 @@ use anyhow::Result; use dialoguer::Select; +#[cfg(feature = "account-discovery")] use email::account::discover::config::AutoConfig; #[cfg(feature = "imap")] @@ -35,7 +36,7 @@ const SEND_MESSAGE_BACKEND_KINDS: &[BackendKind] = &[ pub(crate) async fn configure( account_name: &str, email: &str, - autoconfig: Option<&AutoConfig>, + #[cfg(feature = "account-discovery")] autoconfig: Option<&AutoConfig>, ) -> Result> { let kind = Select::with_theme(&*THEME) .with_prompt("Default email backend") @@ -46,9 +47,15 @@ pub(crate) async fn configure( let config = match kind { #[cfg(feature = "imap")] - Some(kind) if kind == BackendKind::Imap => { - Some(imap::wizard::configure(account_name, email, autoconfig).await?) - } + Some(kind) if kind == BackendKind::Imap => Some( + imap::wizard::configure( + account_name, + email, + #[cfg(feature = "account-discovery")] + autoconfig, + ) + .await?, + ), #[cfg(feature = "maildir")] Some(kind) if kind == BackendKind::Maildir => Some(maildir::wizard::configure()?), #[cfg(feature = "notmuch")] @@ -62,7 +69,7 @@ pub(crate) async fn configure( pub(crate) async fn configure_sender( account_name: &str, email: &str, - autoconfig: Option<&AutoConfig>, + #[cfg(feature = "account-discovery")] autoconfig: Option<&AutoConfig>, ) -> Result> { let kind = Select::with_theme(&*THEME) .with_prompt("Backend for sending messages") @@ -73,9 +80,15 @@ pub(crate) async fn configure_sender( let config = match kind { #[cfg(feature = "smtp")] - Some(kind) if kind == BackendKind::Smtp => { - Some(smtp::wizard::configure(account_name, email, autoconfig).await?) - } + Some(kind) if kind == BackendKind::Smtp => Some( + smtp::wizard::configure( + account_name, + email, + #[cfg(feature = "account-discovery")] + autoconfig, + ) + .await?, + ), #[cfg(feature = "sendmail")] Some(kind) if kind == BackendKind::Sendmail => Some(sendmail::wizard::configure()?), _ => None, diff --git a/src/imap/wizard.rs b/src/imap/wizard.rs index 7599688..42431c6 100644 --- a/src/imap/wizard.rs +++ b/src/imap/wizard.rs @@ -1,12 +1,11 @@ use anyhow::Result; use dialoguer::{Confirm, Input, Password, Select}; +#[cfg(feature = "account-discovery")] +use email::account::discover::config::{AuthenticationType, AutoConfig, SecurityType, ServerType}; use email::{ - account::{ - config::{ - oauth2::{OAuth2Config, OAuth2Method, OAuth2Scopes}, - passwd::PasswdConfig, - }, - discover::config::{AuthenticationType, AutoConfig, SecurityType, ServerType}, + account::config::{ + oauth2::{OAuth2Config, OAuth2Method, OAuth2Scopes}, + passwd::PasswdConfig, }, imap::config::{ImapAuthConfig, ImapConfig, ImapEncryptionKind}, }; @@ -34,6 +33,7 @@ const KEYRING: &str = "Ask my password, then save it in my system's global keyri 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"; +#[cfg(feature = "account-discovery")] pub(crate) async fn configure( account_name: &str, email: &str, @@ -333,3 +333,225 @@ pub(crate) async fn configure( Ok(BackendConfig::Imap(config)) } + +#[cfg(not(feature = "account-discovery"))] +pub(crate) async fn configure(account_name: &str, email: &str) -> Result { + let default_host = format!("imap.{}", email.rsplit_once('@').unwrap().1); + + let host = Input::with_theme(&*THEME) + .with_prompt("IMAP hostname") + .default(default_host) + .interact()?; + + let encryption_idx = Select::with_theme(&*THEME) + .with_prompt("IMAP encryption") + .items(ENCRYPTIONS) + .default(0) + .interact_opt()?; + + let (encryption, default_port) = match encryption_idx { + Some(idx) if ENCRYPTIONS[idx] == ImapEncryptionKind::Tls => { + (Some(ImapEncryptionKind::Tls), 993) + } + Some(idx) if ENCRYPTIONS[idx] == ImapEncryptionKind::StartTls => { + (Some(ImapEncryptionKind::StartTls), 143) + } + _ => (Some(ImapEncryptionKind::None), 143), + }; + + let port = Input::with_theme(&*THEME) + .with_prompt("IMAP port") + .validate_with(|input: &String| input.parse::().map(|_| ())) + .default(default_port.to_string()) + .interact() + .map(|input| input.parse::().unwrap())?; + + let default_login = email.to_owned(); + + let login = Input::with_theme(&*THEME) + .with_prompt("IMAP login") + .default(default_login) + .interact()?; + + let oauth2_enabled = Confirm::new() + .with_prompt(wizard_prompt!("Would you like to enable OAuth 2.0?")) + .default(false) + .interact_opt()? + .unwrap_or_default(); + + let auth = if oauth2_enabled { + let mut config = OAuth2Config::default(); + let redirect_host = OAuth2Config::LOCALHOST.to_owned(); + let redirect_port = OAuth2Config::get_first_available_port()?; + + let method_idx = Select::with_theme(&*THEME) + .with_prompt("IMAP OAuth 2.0 mechanism") + .items(OAUTH2_MECHANISMS) + .default(0) + .interact_opt()?; + + config.method = match method_idx { + 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()?; + config.client_secret = + Secret::new_keyring_entry(format!("{account_name}-imap-oauth2-client-secret")); + config + .client_secret + .set_keyring_entry_secret(&client_secret) + .await?; + + 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()?; + + let prompt_scope = |prompt: &str| -> Result> { + Ok(Some( + Input::with_theme(&*THEME) + .with_prompt(prompt) + .default(String::default()) + .interact()? + .to_owned(), + ) + .filter(|scope| !scope.is_empty())) + }; + + if let Some(scope) = prompt_scope("IMAP OAuth 2.0 main scope")? { + config.scopes = OAuth2Scopes::Scope(scope); + } + + let confirm_additional_scope = || -> Result { + let confirm = Confirm::new() + .with_prompt(wizard_prompt!( + "Would you like to add more IMAP OAuth 2.0 scopes?" + )) + .default(false) + .interact_opt()? + .unwrap_or_default(); + + Ok(confirm) + }; + + while confirm_additional_scope()? { + let mut scopes = match config.scopes { + OAuth2Scopes::Scope(scope) => vec![scope], + OAuth2Scopes::Scopes(scopes) => scopes, + }; + + if let Some(scope) = prompt_scope("Additional IMAP OAuth 2.0 scope")? { + scopes.push(scope) + } + + 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 client = Client::new( + config.client_id.clone(), + client_secret, + config.auth_url.clone(), + config.token_url.clone(), + )? + .with_redirect_host(redirect_host.to_owned()) + .with_redirect_port(redirect_port) + .build()?; + + let mut auth_code_grant = AuthorizationCodeGrant::new() + .with_redirect_host(redirect_host.to_owned()) + .with_redirect_port(redirect_port); + + if config.pkce { + auth_code_grant = auth_code_grant.with_pkce(); + } + + for scope in config.scopes.clone() { + auth_code_grant = auth_code_grant.with_scope(scope); + } + + let (redirect_url, csrf_token) = auth_code_grant.get_redirect_url(&client); + + println!("{redirect_url}"); + println!(); + + let (access_token, refresh_token) = auth_code_grant + .wait_for_redirection(&client, csrf_token) + .await?; + + config.access_token = + Secret::new_keyring_entry(format!("{account_name}-imap-oauth2-access-token")); + config + .access_token + .set_keyring_entry_secret(access_token) + .await?; + + if let Some(refresh_token) = &refresh_token { + config.refresh_token = + Secret::new_keyring_entry(format!("{account_name}-imap-oauth2-refresh-token")); + config + .refresh_token + .set_keyring_entry_secret(refresh_token) + .await?; + } + + ImapAuthConfig::OAuth2(config) + } else { + let secret_idx = Select::with_theme(&*THEME) + .with_prompt("IMAP authentication strategy") + .items(SECRETS) + .default(0) + .interact_opt()?; + + let secret = match secret_idx { + Some(idx) if SECRETS[idx] == KEYRING => { + let secret = Secret::new_keyring_entry(format!("{account_name}-imap-passwd")); + secret + .set_keyring_entry_secret(prompt::passwd("IMAP password")?) + .await?; + secret + } + Some(idx) if SECRETS[idx] == RAW => Secret::new_raw(prompt::passwd("IMAP password")?), + Some(idx) if SECRETS[idx] == CMD => Secret::new_cmd( + Input::with_theme(&*THEME) + .with_prompt("Shell command") + .default(format!("pass show {account_name}-imap-passwd")) + .interact()?, + ), + _ => Default::default(), + }; + + ImapAuthConfig::Passwd(PasswdConfig(secret)) + }; + + let config = ImapConfig { + host, + port, + encryption, + login, + auth, + watch: None, + }; + + Ok(BackendConfig::Imap(config)) +} diff --git a/src/smtp/wizard.rs b/src/smtp/wizard.rs index f6ea078..e4e55a5 100644 --- a/src/smtp/wizard.rs +++ b/src/smtp/wizard.rs @@ -1,12 +1,11 @@ use anyhow::Result; use dialoguer::{Confirm, Input, Password, Select}; +#[cfg(feature = "account-discovery")] +use email::account::discover::config::{AuthenticationType, AutoConfig, SecurityType, ServerType}; use email::{ - account::{ - config::{ - oauth2::{OAuth2Config, OAuth2Method, OAuth2Scopes}, - passwd::PasswdConfig, - }, - discover::config::{AuthenticationType, AutoConfig, SecurityType, ServerType}, + account::config::{ + oauth2::{OAuth2Config, OAuth2Method, OAuth2Scopes}, + passwd::PasswdConfig, }, smtp::config::{SmtpAuthConfig, SmtpConfig, SmtpEncryptionKind}, }; @@ -34,6 +33,7 @@ const KEYRING: &str = "Ask my password, then save it in my system's global keyri 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"; +#[cfg(feature = "account-discovery")] pub(crate) async fn configure( account_name: &str, email: &str, @@ -332,3 +332,224 @@ pub(crate) async fn configure( Ok(BackendConfig::Smtp(config)) } + +#[cfg(not(feature = "account-discovery"))] +pub(crate) async fn configure(account_name: &str, email: &str) -> Result { + let default_host = format!("smtp.{}", email.rsplit_once('@').unwrap().1); + + let host = Input::with_theme(&*THEME) + .with_prompt("SMTP hostname") + .default(default_host) + .interact()?; + + let encryption_idx = Select::with_theme(&*THEME) + .with_prompt("SMTP encryption") + .items(ENCRYPTIONS) + .default(0) + .interact_opt()?; + + let (encryption, default_port) = match encryption_idx { + Some(idx) if ENCRYPTIONS[idx] == SmtpEncryptionKind::Tls => { + (Some(SmtpEncryptionKind::Tls), 465) + } + Some(idx) if ENCRYPTIONS[idx] == SmtpEncryptionKind::StartTls => { + (Some(SmtpEncryptionKind::StartTls), 587) + } + _ => (Some(SmtpEncryptionKind::None), 25), + }; + + let port = Input::with_theme(&*THEME) + .with_prompt("SMTP port") + .validate_with(|input: &String| input.parse::().map(|_| ())) + .default(default_port.to_string()) + .interact() + .map(|input| input.parse::().unwrap())?; + + let default_login = email.to_owned(); + + let login = Input::with_theme(&*THEME) + .with_prompt("SMTP login") + .default(default_login) + .interact()?; + + let oauth2_enabled = Confirm::new() + .with_prompt(wizard_prompt!("Would you like to enable OAuth 2.0?")) + .default(false) + .interact_opt()? + .unwrap_or_default(); + + let auth = if oauth2_enabled { + let mut config = OAuth2Config::default(); + let redirect_host = OAuth2Config::LOCALHOST.to_owned(); + let redirect_port = OAuth2Config::get_first_available_port()?; + + let method_idx = Select::with_theme(&*THEME) + .with_prompt("SMTP OAuth 2.0 mechanism") + .items(OAUTH2_MECHANISMS) + .default(0) + .interact_opt()?; + + config.method = match method_idx { + 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 = Password::with_theme(&*THEME) + .with_prompt("SMTP OAuth 2.0 client secret") + .interact()?; + config.client_secret = + Secret::new_keyring_entry(format!("{account_name}-smtp-oauth2-client-secret")); + config + .client_secret + .set_keyring_entry_secret(&client_secret) + .await?; + + 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()?; + + let prompt_scope = |prompt: &str| -> Result> { + Ok(Some( + Input::with_theme(&*THEME) + .with_prompt(prompt) + .default(String::default()) + .interact()? + .to_owned(), + ) + .filter(|scope| !scope.is_empty())) + }; + + if let Some(scope) = prompt_scope("SMTP OAuth 2.0 main scope")? { + config.scopes = OAuth2Scopes::Scope(scope); + } + + let confirm_additional_scope = || -> Result { + let confirm = Confirm::new() + .with_prompt(wizard_prompt!( + "Would you like to add more SMTP OAuth 2.0 scopes?" + )) + .default(false) + .interact_opt()? + .unwrap_or_default(); + + Ok(confirm) + }; + + while confirm_additional_scope()? { + let mut scopes = match config.scopes { + OAuth2Scopes::Scope(scope) => vec![scope], + OAuth2Scopes::Scopes(scopes) => scopes, + }; + + if let Some(scope) = prompt_scope("Additional SMTP OAuth 2.0 scope")? { + scopes.push(scope) + } + + 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 client = Client::new( + config.client_id.clone(), + client_secret, + config.auth_url.clone(), + config.token_url.clone(), + )? + .with_redirect_host(redirect_host.to_owned()) + .with_redirect_port(redirect_port) + .build()?; + + let mut auth_code_grant = AuthorizationCodeGrant::new() + .with_redirect_host(redirect_host.to_owned()) + .with_redirect_port(redirect_port); + + if config.pkce { + auth_code_grant = auth_code_grant.with_pkce(); + } + + for scope in config.scopes.clone() { + auth_code_grant = auth_code_grant.with_scope(scope); + } + + let (redirect_url, csrf_token) = auth_code_grant.get_redirect_url(&client); + + println!("{redirect_url}"); + println!(); + + let (access_token, refresh_token) = auth_code_grant + .wait_for_redirection(&client, csrf_token) + .await?; + + config.access_token = + Secret::new_keyring_entry(format!("{account_name}-smtp-oauth2-access-token")); + config + .access_token + .set_keyring_entry_secret(access_token) + .await?; + + if let Some(refresh_token) = &refresh_token { + config.refresh_token = + Secret::new_keyring_entry(format!("{account_name}-smtp-oauth2-refresh-token")); + config + .refresh_token + .set_keyring_entry_secret(refresh_token) + .await?; + } + + SmtpAuthConfig::OAuth2(config) + } else { + let secret_idx = Select::with_theme(&*THEME) + .with_prompt("SMTP authentication strategy") + .items(SECRETS) + .default(0) + .interact_opt()?; + + let secret = match secret_idx { + Some(idx) if SECRETS[idx] == KEYRING => { + let secret = Secret::new_keyring_entry(format!("{account_name}-smtp-passwd")); + secret + .set_keyring_entry_secret(prompt::passwd("SMTP password")?) + .await?; + secret + } + Some(idx) if SECRETS[idx] == RAW => Secret::new_raw(prompt::passwd("SMTP password")?), + Some(idx) if SECRETS[idx] == CMD => Secret::new_cmd( + Input::with_theme(&*THEME) + .with_prompt("Shell command") + .default(format!("pass show {account_name}-smtp-passwd")) + .interact()?, + ), + _ => Default::default(), + }; + + SmtpAuthConfig::Passwd(PasswdConfig(secret)) + }; + + let config = SmtpConfig { + host, + port, + encryption, + login, + auth, + }; + + Ok(BackendConfig::Smtp(config)) +}