From 297f5773aa39fcb6de7ab191b103e545649dbdf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Tue, 3 Mar 2026 16:29:33 +0100 Subject: [PATCH] add support for native tls --- Cargo.lock | 109 ++++----- Cargo.toml | 15 +- flake.lock | 6 +- shell.nix | 34 ++- src/account/arg/mod.rs | 1 - src/account/arg/name.rs | 37 ---- src/account/command/configure.rs | 52 ----- src/account/command/doctor.rs | 233 -------------------- src/account/command/list.rs | 41 ---- src/account/command/mod.rs | 41 ---- src/account/config.rs | 3 - src/account/mod.rs | 102 --------- src/cli.rs | 14 +- src/config.rs | 213 +++++++++++++++++- src/imap/command.rs | 6 +- src/imap/mailbox/command/list.rs | 184 +++++++++------- src/imap/mailbox/command/mod.rs | 6 +- src/imap/mod.rs | 1 + src/imap/stream.rs | 367 +++++++++++++++++++++++++++++++ src/lib.rs | 2 - src/stream.rs | 159 ------------- 21 files changed, 771 insertions(+), 855 deletions(-) delete mode 100644 src/account/arg/mod.rs delete mode 100644 src/account/arg/name.rs delete mode 100644 src/account/command/configure.rs delete mode 100644 src/account/command/doctor.rs delete mode 100644 src/account/command/list.rs delete mode 100644 src/account/command/mod.rs delete mode 100644 src/account/config.rs delete mode 100644 src/account/mod.rs create mode 100644 src/imap/stream.rs delete mode 100644 src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 453d213e..7689a045 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,9 +95,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" dependencies = [ "aws-lc-sys", "zeroize", @@ -105,9 +105,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" dependencies = [ "cc", "cmake", @@ -457,19 +457,19 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -525,6 +525,7 @@ dependencies = [ "io-process", "io-stream", "log", + "native-tls", "pimalaya-toolbox", "rustls", "rustls-platform-verifier", @@ -536,16 +537,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -752,9 +743,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.21" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940" +checksum = "819b44bc7c87d9117eb522f14d46e918add69ff12713c475946b0a29363ed1c2" dependencies = [ "jiff-static", "log", @@ -765,9 +756,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.21" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818" +checksum = "470252db18ecc35fd766c0891b1e3ec6cbbcd62507e85276c01bf75d8e94d4a1" dependencies = [ "proc-macro2", "quote", @@ -808,9 +799,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.90" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -842,11 +833,10 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.11.0", "libc", ] @@ -1007,16 +997,13 @@ dependencies = [ "dirs", "env_logger", "git2", - "http", "io-process", "log", - "native-tls", "secrecy", "serde", "serde-toml-merge", "serde_json", "shellexpand", - "thiserror 2.0.18", "toml", ] @@ -1093,6 +1080,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1203,7 +1196,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -1235,9 +1227,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.5.3" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation", "core-foundation-sys", @@ -1250,8 +1242,8 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 0.26.11", - "windows-sys 0.59.0", + "webpki-root-certs", + "windows-sys 0.61.2", ] [[package]] @@ -1464,7 +1456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1605,6 +1597,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -1625,7 +1618,7 @@ version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -1672,9 +1665,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -1685,9 +1678,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1695,9 +1688,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -1708,9 +1701,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -1749,15 +1742,6 @@ dependencies = [ "semver", ] -[[package]] -name = "webpki-root-certs" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" -dependencies = [ - "webpki-root-certs 1.0.6", -] - [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -1800,15 +1784,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -2147,18 +2122,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index e8c1aacc..828b4e00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,9 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = ["imap", "rustls-ring"] imap = ["dep:io-imap"] -rustls-aws = ["dep:rustls", "dep:rustls-platform-verifier", "rustls/aws-lc-rs"] -rustls-ring = ["dep:rustls", "dep:rustls-platform-verifier", "rustls/ring"] -native-tls = ["pimalaya-toolbox/native-tls"] +rustls-aws = ["dep:rustls-platform-verifier", "rustls/aws-lc-rs"] +rustls-ring = ["dep:rustls-platform-verifier", "rustls/ring"] +native-tls = ["dep:native-tls"] # maildir = ["email-lib/maildir", "pimalaya-tui/maildir"] # notmuch = ["email-lib/notmuch", "pimalaya-tui/notmuch"] # smtp = ["email-lib/smtp", "pimalaya-tui/smtp"] @@ -47,14 +47,15 @@ io-imap = { version = "0.0.1", default-features = false, optional = true } io-process = { version = "0.0.2", default-features = false } io-stream = { version = "0.0.2", default-features = false, features = ["std"] } log = "0.4" -pimalaya-toolbox = { version = "0.0.4", default-features = false, features = ["config", "secret", "stream", "terminal", "command"] } -rustls = { version = "0.23", default-features = false, features = ["logging", "std", "tls12"], optional = true } -rustls-platform-verifier = { version = "0.5", optional = true } +native-tls = { version = "0.2", optional = true } +pimalaya-toolbox = { version = "0.0.4", default-features = false, features = ["config", "terminal", "secret", "command"] } +rustls = { version = "0.23", default-features = false, optional = true } +rustls-platform-verifier = { version = "0.6", optional = true } secrecy = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3.1" -url = "2.2" +url = { version = "2.2", features = ["serde"] } uuid = { version = "1.19", features = ["v4"] } [patch.crates-io] diff --git a/flake.lock b/flake.lock index edd00137..6a75f65f 100644 --- a/flake.lock +++ b/flake.lock @@ -24,11 +24,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1736437047, - "narHash": "sha256-JJBziecfU+56SUNxeJlDIgixJN5WYuADd+/TVd5sQos=", + "lastModified": 1738537163, + "narHash": "sha256-vRjFUwVd06mqXC0wSP5ZNrQD5F5SkFvUqmYSWxWqoMg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "f17b95775191ea44bc426831235d87affb10faba", + "rev": "736314654d0ede857a249cb06ae7afaea8bcd185", "type": "github" }, "original": { diff --git a/shell.nix b/shell.nix index 6e9a40bc..dbb6b16c 100644 --- a/shell.nix +++ b/shell.nix @@ -6,11 +6,29 @@ fenix ? import (fetchTarball "https://github.com/nix-community/fenix/archive/monthly.tar.gz") { }, }: -pimalaya.mkShell { - inherit - nixpkgs - system - pkgs - fenix - ; -} +let + inherit (pkgs) openssl pkg-config; + + shell = pimalaya.mkShell { + inherit + nixpkgs + system + pkgs + fenix + ; + }; + +in +shell.overrideAttrs (prev: { + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + openssl + ]; + + nativeBuildInputs = (prev.nativeBuildInputs or [ ]) ++ [ + pkg-config + ]; + + buildInputs = (prev.buildInputs or [ ]) ++ [ + openssl + ]; +}) diff --git a/src/account/arg/mod.rs b/src/account/arg/mod.rs deleted file mode 100644 index c427f91a..00000000 --- a/src/account/arg/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod name; diff --git a/src/account/arg/name.rs b/src/account/arg/name.rs deleted file mode 100644 index 57ff2a12..00000000 --- a/src/account/arg/name.rs +++ /dev/null @@ -1,37 +0,0 @@ -use clap::Parser; - -/// The account name argument parser. -#[derive(Debug, Parser)] -pub struct AccountNameArg { - /// The name of the account. - /// - /// An account name corresponds to an entry in the table at the - /// root level of your TOML configuration file. - #[arg(name = "account_name", value_name = "ACCOUNT")] - pub name: String, -} - -/// The optional account name argument parser. -#[derive(Debug, Parser)] -pub struct OptionalAccountNameArg { - /// The name of the account. - /// - /// An account name corresponds to an entry in the table at the - /// root level of your TOML configuration file. - /// - /// If omitted, the account marked as default will be used. - #[arg(name = "account_name", value_name = "ACCOUNT")] - pub name: Option, -} - -/// The account name flag parser. -#[derive(Debug, Default, Parser)] -pub struct AccountNameFlag { - /// Override the default account. - /// - /// An account name corresponds to an entry in the table at the - /// root level of your TOML configuration file. - #[arg(long = "account", short = 'a')] - #[arg(name = "account_name", value_name = "NAME")] - pub name: Option, -} diff --git a/src/account/command/configure.rs b/src/account/command/configure.rs deleted file mode 100644 index be2c9390..00000000 --- a/src/account/command/configure.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::path::PathBuf; - -use clap::Parser; -use color_eyre::Result; - -use crate::{account::arg::name::AccountNameArg, config::TomlConfig}; - -/// Configure the given account. -/// -/// This command allows you to configure an existing account or to -/// create a new one, using the wizard. The `wizard` cargo feature is -/// required. -#[derive(Debug, Parser)] -pub struct AccountConfigureCommand { - #[command(flatten)] - pub account: AccountNameArg, -} - -impl AccountConfigureCommand { - #[cfg(feature = "wizard")] - pub async fn execute( - self, - mut config: TomlConfig, - config_path: Option<&PathBuf>, - ) -> Result<()> { - use pimalaya_tui::{himalaya::wizard, terminal::config::TomlConfig as _}; - use tracing::info; - - info!("executing account configure command"); - - let path = match config_path { - Some(path) => path.clone(), - None => TomlConfig::default_path()?, - }; - - let account_name = Some(self.account.name.as_str()); - - let account_config = config - .accounts - .remove(&self.account.name) - .unwrap_or_default(); - - wizard::edit(path, config, account_name, account_config).await?; - - Ok(()) - } - - #[cfg(not(feature = "wizard"))] - pub async fn execute(self, _: TomlConfig, _: Option<&PathBuf>) -> Result<()> { - color_eyre::eyre::bail!("This command requires the `wizard` cargo feature to work"); - } -} diff --git a/src/account/command/doctor.rs b/src/account/command/doctor.rs deleted file mode 100644 index 0a807182..00000000 --- a/src/account/command/doctor.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::{ - io::{stdout, Write}, - sync::Arc, -}; - -use clap::Parser; -use color_eyre::{Result, Section}; -#[cfg(all(feature = "keyring", feature = "imap"))] -use email::imap::config::ImapAuthConfig; -#[cfg(feature = "imap")] -use email::imap::ImapContextBuilder; -#[cfg(feature = "maildir")] -use email::maildir::MaildirContextBuilder; -#[cfg(feature = "notmuch")] -use email::notmuch::NotmuchContextBuilder; -#[cfg(feature = "sendmail")] -use email::sendmail::SendmailContextBuilder; -#[cfg(all(feature = "keyring", feature = "smtp"))] -use email::smtp::config::SmtpAuthConfig; -#[cfg(feature = "smtp")] -use email::smtp::SmtpContextBuilder; -use email::{backend::BackendBuilder, config::Config}; -#[cfg(feature = "keyring")] -use pimalaya_tui::terminal::prompt; -use pimalaya_tui::{ - himalaya::config::{Backend, SendingBackend}, - terminal::config::TomlConfig as _, -}; - -use crate::{account::arg::name::OptionalAccountNameArg, config::TomlConfig}; - -/// Diagnose and fix the given account. -/// -/// This command diagnoses the given account and can even try to fix -/// it. It mostly checks if the configuration is valid, if backends -/// can be instanciated and if sessions work as expected. -#[derive(Debug, Parser)] -pub struct AccountDoctorCommand { - #[command(flatten)] - pub account: OptionalAccountNameArg, - - /// Try to fix the given account. - /// - /// This argument can be used to (re)configure keyring entries for - /// example. - #[arg(long, short)] - pub fix: bool, -} - -impl AccountDoctorCommand { - pub async fn execute(self, config: &TomlConfig) -> Result<()> { - let mut stdout = stdout(); - - if let Some(name) = self.account.name.as_ref() { - print!("Checking TOML configuration integrity for account {name}… "); - } else { - print!("Checking TOML configuration integrity for default account… "); - } - - stdout.flush()?; - - let (toml_account_config, account_config) = config - .clone() - .into_account_configs(self.account.name.as_deref(), |c: &Config, name| { - c.account(name).ok() - })?; - let account_config = Arc::new(account_config); - - println!("OK"); - - #[cfg(feature = "keyring")] - if self.fix { - if prompt::bool("Would you like to reset existing keyring entries?", false)? { - print!("Resetting keyring entries… "); - stdout.flush()?; - - #[cfg(feature = "imap")] - match toml_account_config.imap_auth_config() { - Some(ImapAuthConfig::Password(config)) => config.reset().await?, - #[cfg(feature = "oauth2")] - Some(ImapAuthConfig::OAuth2(config)) => config.reset().await?, - _ => (), - } - - #[cfg(feature = "smtp")] - match toml_account_config.smtp_auth_config() { - Some(SmtpAuthConfig::Password(config)) => config.reset().await?, - #[cfg(feature = "oauth2")] - Some(SmtpAuthConfig::OAuth2(config)) => config.reset().await?, - _ => (), - } - - #[cfg(any(feature = "pgp-gpg", feature = "pgp-commands", feature = "pgp-native"))] - if let Some(config) = &toml_account_config.pgp { - config.reset().await?; - } - - println!("OK"); - } - - #[cfg(feature = "imap")] - match toml_account_config.imap_auth_config() { - Some(ImapAuthConfig::Password(config)) => { - config - .configure(|| Ok(prompt::password("IMAP password")?)) - .await?; - } - #[cfg(feature = "oauth2")] - Some(ImapAuthConfig::OAuth2(config)) => { - config - .configure(|| Ok(prompt::secret("IMAP OAuth 2.0 client secret")?)) - .await?; - } - _ => (), - }; - - #[cfg(feature = "smtp")] - match toml_account_config.smtp_auth_config() { - Some(SmtpAuthConfig::Password(config)) => { - config - .configure(|| Ok(prompt::password("SMTP password")?)) - .await?; - } - #[cfg(feature = "oauth2")] - Some(SmtpAuthConfig::OAuth2(config)) => { - config - .configure(|| Ok(prompt::secret("SMTP OAuth 2.0 client secret")?)) - .await?; - } - _ => (), - }; - - #[cfg(any(feature = "pgp-gpg", feature = "pgp-commands", feature = "pgp-native"))] - if let Some(config) = &toml_account_config.pgp { - config - .configure(&toml_account_config.email, || { - Ok(prompt::password("PGP secret key password")?) - }) - .await?; - } - } - - match toml_account_config.backend { - #[cfg(feature = "maildir")] - Some(Backend::Maildir(mdir_config)) => { - print!("Checking Maildir integrity… "); - stdout.flush()?; - - let ctx = MaildirContextBuilder::new(account_config.clone(), Arc::new(mdir_config)); - BackendBuilder::new(account_config.clone(), ctx) - .check_up() - .await?; - - println!("OK"); - } - #[cfg(feature = "imap")] - Some(Backend::Imap(imap_config)) => { - print!("Checking IMAP integrity… "); - stdout.flush()?; - - let ctx = ImapContextBuilder::new(account_config.clone(), Arc::new(imap_config)) - .with_pool_size(1); - let res = BackendBuilder::new(account_config.clone(), ctx) - .check_up() - .await; - - if self.fix { - res?; - } else { - res.note("Run with --fix to (re)configure your account.")?; - } - - println!("OK"); - } - #[cfg(feature = "notmuch")] - Some(Backend::Notmuch(notmuch_config)) => { - print!("Checking Notmuch integrity… "); - stdout.flush()?; - - let ctx = - NotmuchContextBuilder::new(account_config.clone(), Arc::new(notmuch_config)); - BackendBuilder::new(account_config.clone(), ctx) - .check_up() - .await?; - - println!("OK"); - } - _ => (), - } - - let sending_backend = toml_account_config - .message - .and_then(|msg| msg.send) - .and_then(|send| send.backend); - - match sending_backend { - #[cfg(feature = "smtp")] - Some(SendingBackend::Smtp(smtp_config)) => { - print!("Checking SMTP integrity… "); - stdout.flush()?; - - let ctx = SmtpContextBuilder::new(account_config.clone(), Arc::new(smtp_config)); - let res = BackendBuilder::new(account_config.clone(), ctx) - .check_up() - .await; - - if self.fix { - res?; - } else { - res.note("Run with --fix to (re)configure your account.")?; - } - - println!("OK"); - } - #[cfg(feature = "sendmail")] - Some(SendingBackend::Sendmail(sendmail_config)) => { - print!("Checking Sendmail integrity… "); - stdout.flush()?; - - let ctx = - SendmailContextBuilder::new(account_config.clone(), Arc::new(sendmail_config)); - BackendBuilder::new(account_config.clone(), ctx) - .check_up() - .await?; - - println!("OK"); - } - _ => (), - } - - Ok(()) - } -} diff --git a/src/account/command/list.rs b/src/account/command/list.rs deleted file mode 100644 index 2117c707..00000000 --- a/src/account/command/list.rs +++ /dev/null @@ -1,41 +0,0 @@ -use clap::Parser; -use color_eyre::Result; -use pimalaya_tui::{ - himalaya::config::{Accounts, AccountsTable}, - terminal::cli::printer::Printer, -}; -use tracing::info; - -use crate::config::TomlConfig; - -/// List all existing accounts. -/// -/// This command lists all the accounts defined in your TOML -/// configuration file. -#[derive(Debug, Parser)] -pub struct AccountListCommand { - /// The maximum width the table should not exceed. - /// - /// This argument will force the table not to exceed the given - /// width, in pixels. Columns may shrink with ellipsis in order to - /// fit the width. - #[arg(long = "max-width", short = 'w')] - #[arg(name = "table_max_width", value_name = "PIXELS")] - pub table_max_width: Option, -} - -impl AccountListCommand { - pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> { - info!("executing list accounts command"); - - let accounts = Accounts::from(config.accounts.iter()); - let table = AccountsTable::from(accounts) - .with_some_width(self.table_max_width) - .with_some_preset(config.account_list_table_preset()) - .with_some_name_color(config.account_list_table_name_color()) - .with_some_backends_color(config.account_list_table_backends_color()) - .with_some_default_color(config.account_list_table_default_color()); - - printer.out(table) - } -} diff --git a/src/account/command/mod.rs b/src/account/command/mod.rs deleted file mode 100644 index 469afc4f..00000000 --- a/src/account/command/mod.rs +++ /dev/null @@ -1,41 +0,0 @@ -mod configure; -mod doctor; -mod list; - -use std::path::PathBuf; - -use clap::Subcommand; -use color_eyre::Result; -use pimalaya_tui::terminal::cli::printer::Printer; - -use crate::config::TomlConfig; - -use self::{ - configure::AccountConfigureCommand, doctor::AccountDoctorCommand, list::AccountListCommand, -}; - -/// Configure, list and diagnose your accounts. -/// -/// An account is a group of settings, identified by a unique -/// name. This subcommand allows you to manage your accounts. -#[derive(Debug, Subcommand)] -pub enum AccountSubcommand { - Configure(AccountConfigureCommand), - Doctor(AccountDoctorCommand), - List(AccountListCommand), -} - -impl AccountSubcommand { - pub async fn execute( - self, - printer: &mut impl Printer, - config: TomlConfig, - config_path: Option<&PathBuf>, - ) -> Result<()> { - match self { - Self::Configure(cmd) => cmd.execute(config, config_path).await, - Self::Doctor(cmd) => cmd.execute(&config).await, - Self::List(cmd) => cmd.execute(printer, &config).await, - } - } -} diff --git a/src/account/config.rs b/src/account/config.rs deleted file mode 100644 index daf6f389..00000000 --- a/src/account/config.rs +++ /dev/null @@ -1,3 +0,0 @@ -use pimalaya_tui::himalaya::config::HimalayaTomlAccountConfig; - -pub type TomlAccountConfig = HimalayaTomlAccountConfig; diff --git a/src/account/mod.rs b/src/account/mod.rs deleted file mode 100644 index 6c9bb464..00000000 --- a/src/account/mod.rs +++ /dev/null @@ -1,102 +0,0 @@ -// pub mod arg; -// pub mod command; -// pub mod config; - -use std::{fmt, path::PathBuf}; - -use serde::{de::Visitor, Deserialize, Deserializer, Serialize}; - -use crate::{sasl::SaslConfig, tls::TlsConfig}; - -/// The account configuration. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct ImapConfig { - /// The IMAP server host name. - pub host: String, - /// The IMAP server host port. - pub port: Option, - - #[serde(default)] - pub tls: TlsConfig, - #[serde(default)] - pub starttls: bool, - #[serde(default)] - pub sasl: SaslConfig, - - /// The IMAP extensions configuration. - #[serde(default)] - pub extensions: ImapExtensionsConfig, -} - -/// The IMAP configuration dedicated to extensions. -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct ImapExtensionsConfig { - #[serde(default)] - id: ImapIdExtensionConfig, -} - -/// The IMAP configuration dedicated to the ID extension. -/// -/// https://www.rfc-editor.org/rfc/rfc2971.html -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct ImapIdExtensionConfig { - /// Automatically sends the ID command straight after - /// authentication. - #[serde(default)] - send_after_auth: bool, -} - -/// The account configuration. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct Account { - #[serde(default)] - pub default: bool, - - pub imap: Option, - - #[serde(deserialize_with = "shell_expanded_string")] - pub email: String, - pub display_name: Option, - pub signature: Option, - pub signature_delim: Option, - pub downloads_dir: Option, - // pub backend: Option, - // #[cfg(feature = "pgp")] - // pub pgp: Option, - // #[cfg(not(feature = "pgp"))] - // #[serde(default)] - // #[serde(skip_serializing, deserialize_with = "missing_pgp_feature")] - // pub pgp: Option<()>, - - // pub folder: Option, - // pub envelope: Option, - // pub message: Option, - // pub template: Option, -} - -struct ShellExpandedStringVisitor; - -impl<'de> Visitor<'de> for ShellExpandedStringVisitor { - type Value = String; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("an string containing environment variable(s)") - } - - fn visit_string(self, v: String) -> Result { - match shellexpand::full(&v) { - Ok(v) => Ok(v.to_string()), - Err(_) => Ok(v), - } - } -} - -pub fn shell_expanded_string<'de, D: Deserializer<'de>>( - deserializer: D, -) -> Result { - deserializer.deserialize_string(ShellExpandedStringVisitor) -} diff --git a/src/cli.rs b/src/cli.rs index 4c2b1dff..7d196706 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,13 +1,13 @@ use std::path::PathBuf; -use anyhow::Result; +use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; use pimalaya_toolbox::{ config::TomlConfig, long_version, terminal::{ clap::{ - args::{AccountArg, JsonFlag, LogFlags}, + args::{AccountFlag, JsonFlag, LogFlags}, parsers::path_parser, }, printer::Printer, @@ -41,7 +41,7 @@ pub struct HimalayaCli { #[arg(value_name = "PATH", value_parser = path_parser, value_delimiter = ':')] pub config_paths: Vec, #[command(flatten)] - pub account: AccountArg, + pub account: AccountFlag, #[command(flatten)] pub json: JsonFlag, #[command(flatten)] @@ -65,8 +65,12 @@ impl BackendCommand { match self { Self::Imap(cmd) => { let config = Config::from_paths_or_default(config_paths)?; - let (_, account) = config.get_account(account_name)?; - cmd.execute(printer, account) + let (_, account_config) = config.get_account(account_name)?; + let Some(imap_config) = account_config.imap else { + bail!("IMAP config is missing for this account") + }; + + cmd.execute(printer, imap_config) } } } diff --git a/src/config.rs b/src/config.rs index bc2a4712..bde6b589 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,12 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::{collections::HashMap, fmt, path::PathBuf, process::Command}; +use anyhow::{bail, Result}; use pimalaya_toolbox::config::TomlConfig; -use serde::{Deserialize, Serialize}; +use secrecy::SecretString; +use serde::{de::Visitor, Deserialize, Deserializer}; +use url::Url; -use crate::account::Account; - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Config { #[serde(alias = "name")] @@ -13,12 +14,12 @@ pub struct Config { pub signature: Option, pub signature_delim: Option, pub downloads_dir: Option, - pub accounts: HashMap, + pub accounts: HashMap, // pub account: Option, } impl TomlConfig for Config { - type Account = Account; + type Account = AccountConfig; fn project_name() -> &'static str { env!("CARGO_PKG_NAME") @@ -37,3 +38,201 @@ impl TomlConfig for Config { .map(|account| (name.to_owned(), account.clone())) } } + +/// The account configuration. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct AccountConfig { + #[serde(default)] + pub default: bool, + pub imap: Option, + #[serde(deserialize_with = "shell_expanded_string")] + pub email: String, + pub display_name: Option, + pub signature: Option, + pub signature_delim: Option, + pub downloads_dir: Option, + // pub backend: Option, + // #[cfg(feature = "pgp")] + // pub pgp: Option, + // #[cfg(not(feature = "pgp"))] + // #[serde(default)] + // #[serde(skip_serializing, deserialize_with = "missing_pgp_feature")] + // pub pgp: Option<()>, + + // pub folder: Option, + // pub envelope: Option, + // pub message: Option, + // pub template: Option, +} + +/// The account configuration. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ImapConfig { + pub url: Url, + + #[serde(default)] + pub tls: TlsConfig, + #[serde(default)] + pub starttls: bool, + #[serde(default)] + pub sasl: SaslConfig, + + /// The IMAP extensions configuration. + #[serde(default)] + pub extensions: ImapExtensionsConfig, +} + +/// The IMAP configuration dedicated to extensions. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ImapExtensionsConfig { + #[serde(default)] + id: ImapIdExtensionConfig, +} + +/// The IMAP configuration dedicated to the ID extension. +/// +/// https://www.rfc-editor.org/rfc/rfc2971.html +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ImapIdExtensionConfig { + /// Automatically sends the ID command straight after + /// authentication. + #[serde(default)] + always_after_auth: bool, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct TlsConfig { + pub provider: Option, + #[serde(default)] + pub rustls: RustlsConfig, + pub cert: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub enum TlsProviderConfig { + Rustls, + NativeTls, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct RustlsConfig { + pub crypto: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub enum RustlsCryptoConfig { + Aws, + Ring, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct SaslConfig { + #[serde(default = "default_sasl_mechanisms")] + pub mechanisms: Vec, + pub login: Option, + pub plain: Option, + pub anonymous: Option, +} + +fn default_sasl_mechanisms() -> Vec { + vec![SaslMechanismConfig::Plain, SaslMechanismConfig::Login] +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SaslMechanismConfig { + Login, + Plain, + Anonymous, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SecretConfig { + Raw(SecretString), + Command(Vec), +} + +impl SecretConfig { + pub fn get(&self) -> Result { + match self { + Self::Raw(secret) => Ok(secret.clone()), + Self::Command(args) => { + let Some((program, args)) = args.split_first() else { + bail!("Secret command cannot be empty") + }; + + let mut cmd = Command::new(program); + cmd.args(args); + let out = cmd.output()?; + + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + bail!("Cannot read secret from command: {err}"); + } + + let secret = String::from_utf8_lossy(&out.stdout); + let secret = secret.trim_matches(['\r', '\n']); + let secret = match secret.split_once('\n') { + Some((secret, _)) => secret.trim_matches(['\r', '\n']), + None => secret, + }; + + Ok(SecretString::from(secret)) + } + } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct SaslLoginConfig { + pub username: String, + pub password: SecretConfig, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct SaslPlainConfig { + pub authzid: Option, + pub authcid: String, + pub passwd: SecretConfig, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct SaslAnonymousConfig { + pub message: Option, +} + +struct ShellExpandedStringVisitor; + +impl<'de> Visitor<'de> for ShellExpandedStringVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an string containing environment variable(s)") + } + + fn visit_string(self, v: String) -> Result { + match shellexpand::full(&v) { + Ok(v) => Ok(v.to_string()), + Err(_) => Ok(v), + } + } +} + +pub fn shell_expanded_string<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result { + deserializer.deserialize_string(ShellExpandedStringVisitor) +} diff --git a/src/imap/command.rs b/src/imap/command.rs index 2dd19e7e..7b77dd2a 100644 --- a/src/imap/command.rs +++ b/src/imap/command.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Subcommand; use pimalaya_toolbox::terminal::printer::Printer; -use crate::{account::Account, imap::mailbox::command::MailboxCommand}; +use crate::{config::ImapConfig, imap::mailbox::command::MailboxCommand}; /// IMAP CLI (requires `imap` cargo feature). /// @@ -18,9 +18,9 @@ pub enum ImapCommand { } impl ImapCommand { - pub fn execute(self, printer: &mut impl Printer, account: Account) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { match self { - Self::Mailboxes(cmd) => cmd.execute(printer, account), + Self::Mailboxes(cmd) => cmd.execute(printer, config), } } } diff --git a/src/imap/mailbox/command/list.rs b/src/imap/mailbox/command/list.rs index 8c70c64c..b71b219f 100644 --- a/src/imap/mailbox/command/list.rs +++ b/src/imap/mailbox/command/list.rs @@ -1,16 +1,10 @@ use anyhow::{bail, Result}; use clap::Parser; -use io_imap::{ - coroutines::{ - authenticate::*, authenticate_anonymous::*, authenticate_plain::*, list::*, login::*, - }, - types::response::Capability, -}; +use io_imap::coroutines::list::*; use io_stream::runtimes::std::handle; -use log::warn; use pimalaya_toolbox::terminal::printer::Printer; -use crate::{account::Account, sasl::SaslMechanism, stream}; +use crate::{config::ImapConfig, imap::stream}; /// List all mailboxes. /// @@ -29,71 +23,8 @@ pub struct ListMailboxesCommand { } impl ListMailboxesCommand { - pub fn execute(self, printer: &mut impl Printer, account: Account) -> Result<()> { - let imap = account.imap.unwrap(); - - let (mut context, mut stream) = if imap.tls.disable { - let port = imap.port.unwrap_or(143); - stream::tcp(imap.host, port)? - } else { - let port = imap.port.unwrap_or(if imap.starttls { 143 } else { 993 }); - stream::rustls(imap.host, port, imap.starttls, imap.tls.cert)? - }; - - let ir = context.capability.contains(&Capability::SaslIr); - - let mut candidates = vec![]; - - for mechanism in imap.sasl.mechanisms { - match mechanism { - SaslMechanism::Login => { - let Some(ref auth) = imap.sasl.login else { - warn!("missing SASL LOGIN configuration, skipping it"); - continue; - }; - - let params = ImapLoginParams::new(&auth.username, auth.password.get()?)?; - candidates.push(ImapAuthenticateCandidate::Login(params)); - } - SaslMechanism::Plain => { - let Some(ref auth) = imap.sasl.plain else { - warn!("missing SASL PLAIN configuration, skipping it"); - continue; - }; - - let params = ImapAuthenticatePlainParams::new( - auth.authzid.as_ref(), - &auth.authcid, - auth.passwd.get()?, - ir, - ); - - candidates.push(ImapAuthenticateCandidate::Plain(params)) - } - SaslMechanism::Anonymous => { - let msg = imap - .sasl - .anonymous - .as_ref() - .and_then(|auth| auth.message.as_ref()); - - let params = ImapAuthenticateAnonymousParams::new(msg, ir); - - candidates.push(ImapAuthenticateCandidate::Anonymous(params)) - } - } - } - - let mut arg = None; - let mut coroutine = ImapAuthenticate::new(context, candidates); - - loop { - match coroutine.resume(arg.take()) { - ImapAuthenticateResult::Io(io) => arg = Some(handle(&mut stream, io)?), - ImapAuthenticateResult::Ok { context: c } => break context = c, - ImapAuthenticateResult::Err { err, .. } => bail!(err), - } - } + pub fn execute(self, _printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + let (context, mut stream) = stream::connect(config)?; let mut arg = None; let mut coroutine = ImapList::new(context, "".try_into().unwrap(), "*".try_into().unwrap()); @@ -101,23 +32,114 @@ impl ListMailboxesCommand { let mailboxes = loop { match coroutine.resume(arg.take()) { ImapListResult::Io(io) => arg = Some(handle(&mut stream, io)?), - ImapListResult::Ok(ok) => break ok.mailboxes, - ImapListResult::Err(err) => bail!(err), + ImapListResult::Ok { mailboxes, .. } => break mailboxes, + ImapListResult::Err { err, .. } => bail!(err), } }; println!("mailboxes: {mailboxes:#?}"); - // TODO: list folders + // TODO: list mailboxs - // let folders = Folders::from(backend.list_folders().await?); - // let table = FoldersTable::from(folders) + // let mailboxs = Mailboxs::from(backend.list_mailboxs().await?); + // let table = MailboxsTable::from(mailboxs) // .with_some_width(self.table_max_width) - // .with_some_preset(toml_account_config.folder_list_table_preset()) - // .with_some_name_color(toml_account_config.folder_list_table_name_color()) - // .with_some_desc_color(toml_account_config.folder_list_table_desc_color()); + // .with_some_preset(toml_account_config.mailbox_list_table_preset()) + // .with_some_name_color(toml_account_config.mailbox_list_table_name_color()) + // .with_some_desc_color(toml_account_config.mailbox_list_table_desc_color()); // printer.out(table)?; Ok(()) } } + +// #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +// #[serde(rename_all = "kebab-case")] +// pub struct ListMailboxesTableConfig { +// pub preset: Option, +// pub name_color: Option, +// pub desc_color: Option, +// } + +// impl ListMailboxesTableConfig { +// pub fn preset(&self) -> &str { +// self.preset.as_deref().unwrap_or(presets::ASCII_MARKDOWN) +// } + +// pub fn name_color(&self) -> comfy_table::Color { +// map_color(self.name_color.unwrap_or(Color::Blue)) +// } + +// pub fn desc_color(&self) -> comfy_table::Color { +// map_color(self.desc_color.unwrap_or(Color::Green)) +// } +// } + +// pub struct MailboxesTable { +// mailboxes: Vec>, +// width: Option, +// config: ListMailboxesTableConfig, +// } + +// impl MailboxesTable { +// pub fn with_some_width(mut self, width: Option) -> Self { +// self.width = width; +// self +// } + +// pub fn with_some_preset(mut self, preset: Option) -> Self { +// self.config.preset = preset; +// self +// } + +// pub fn with_some_name_color(mut self, color: Option) -> Self { +// self.config.name_color = color; +// self +// } + +// pub fn with_some_desc_color(mut self, color: Option) -> Self { +// self.config.desc_color = color; +// self +// } +// } + +// impl From for MailboxesTable { +// fn from(mailboxes: Mailboxes) -> Self { +// Self { +// mailboxes, +// width: None, +// config: Default::default(), +// } +// } +// } + +// impl fmt::Display for MailboxesTable { +// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +// let mut table = Table::new(); + +// table +// .load_preset(self.config.preset()) +// .set_content_arrangement(ContentArrangement::DynamicFullWidth) +// .set_header(Row::from([Cell::new("NAME"), Cell::new("DESC")])) +// .add_rows( +// self.mailboxes +// .iter() +// .map(|mailbox| mailbox.to_row(&self.config)), +// ); + +// if let Some(width) = self.width { +// table.set_width(width); +// } + +// writeln!(f)?; +// write!(f, "{table}")?; +// writeln!(f)?; +// Ok(()) +// } +// } + +// impl Serialize for MailboxesTable { +// fn serialize(&self, serializer: S) -> Result { +// self.mailboxes.serialize(serializer) +// } +// } diff --git a/src/imap/mailbox/command/mod.rs b/src/imap/mailbox/command/mod.rs index d1e45f86..58350f60 100644 --- a/src/imap/mailbox/command/mod.rs +++ b/src/imap/mailbox/command/mod.rs @@ -8,7 +8,7 @@ use anyhow::Result; use clap::Subcommand; use pimalaya_toolbox::terminal::printer::Printer; -use crate::{account::Account, imap::mailbox::command::list::ListMailboxesCommand}; +use crate::{config::ImapConfig, imap::mailbox::command::list::ListMailboxesCommand}; /// Create, list and purge mailboxes. /// @@ -31,10 +31,10 @@ pub enum MailboxCommand { impl MailboxCommand { #[allow(unused)] - pub fn execute(self, printer: &mut impl Printer, account: Account) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { match self { // Self::Add(cmd) => cmd.execute(printer, config).await, - Self::List(cmd) => cmd.execute(printer, account), + Self::List(cmd) => cmd.execute(printer, config), // Self::Expunge(cmd) => cmd.execute(printer, config).await, // Self::Purge(cmd) => cmd.execute(printer, config).await, // Self::Delete(cmd) => cmd.execute(printer, config).await, diff --git a/src/imap/mod.rs b/src/imap/mod.rs index 45932a43..b2c40526 100644 --- a/src/imap/mod.rs +++ b/src/imap/mod.rs @@ -1,2 +1,3 @@ pub mod command; pub mod mailbox; +pub mod stream; diff --git a/src/imap/stream.rs b/src/imap/stream.rs new file mode 100644 index 00000000..8f7922ec --- /dev/null +++ b/src/imap/stream.rs @@ -0,0 +1,367 @@ +use std::{ + fs, + io::{self, Read, Write}, + net::TcpStream, + sync::Arc, + time::Duration, +}; + +use anyhow::{bail, Result}; +use io_imap::{ + context::ImapContext, + coroutines::{ + authenticate::*, authenticate_anonymous::ImapAuthenticateAnonymousParams, + authenticate_plain::ImapAuthenticatePlainParams, capability::*, + greeting_with_capability::*, login::ImapLoginParams, starttls::*, + }, + types::{auth::AuthMechanism, response::Capability}, +}; +use io_stream::runtimes::std::handle; +use log::{debug, info}; +#[cfg(feature = "native-tls")] +use native_tls::TlsConnector; +#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] +use rustls::{ + crypto::{self, CryptoProvider}, + pki_types::{pem::PemObject, CertificateDer}, + ClientConfig, ClientConnection, StreamOwned, +}; +#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] +use rustls_platform_verifier::{ConfigVerifierExt, Verifier}; + +use crate::config::{ImapConfig, RustlsCryptoConfig, SaslMechanismConfig, TlsProviderConfig}; + +pub enum Stream { + Plain(TcpStream), + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + Rustls(StreamOwned), + #[cfg(feature = "native-tls")] + NativeTls(native_tls::TlsStream), +} + +impl Stream { + pub fn set_read_timeout(&self, dur: Option) -> io::Result<()> { + match self { + Self::Plain(s) => s.set_read_timeout(dur), + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + Self::Rustls(s) => s.get_ref().set_read_timeout(dur), + #[cfg(feature = "native-tls")] + Self::NativeTls(s) => s.get_ref().set_read_timeout(dur), + } + } +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + Self::Plain(s) => s.read(buf), + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + Self::Rustls(s) => s.read(buf), + #[cfg(feature = "native-tls")] + Self::NativeTls(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Self::Plain(s) => s.write(buf), + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + Self::Rustls(s) => s.write(buf), + #[cfg(feature = "native-tls")] + Self::NativeTls(s) => s.write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Plain(s) => s.flush(), + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + Self::Rustls(s) => s.flush(), + #[cfg(feature = "native-tls")] + Self::NativeTls(s) => s.flush(), + } + } +} + +pub fn connect(mut config: ImapConfig) -> Result<(ImapContext, Stream)> { + info!("connecting to IMAP server using {}", config.url); + + let mut context = ImapContext::new(); + let host = config.url.host_str().unwrap_or("127.0.0.1"); + + let (mut context, mut stream) = match config.url.scheme() { + scheme if scheme.eq_ignore_ascii_case("imap") => { + let port = config.url.port().unwrap_or(143); + let mut stream = TcpStream::connect((host, port))?; + + let mut coroutine = GetImapGreetingWithCapability::new(context); + let mut arg = None; + + loop { + match coroutine.resume(arg.take()) { + GetImapGreetingWithCapabilityResult::Io(io) => { + arg = Some(handle(&mut stream, io)?) + } + GetImapGreetingWithCapabilityResult::Ok { context: c } => break context = c, + GetImapGreetingWithCapabilityResult::Err { err, .. } => Err(err)?, + } + } + + (context, Stream::Plain(stream)) + } + scheme if scheme.eq_ignore_ascii_case("imaps") => { + let port = config.url.port().unwrap_or(993); + let mut tcp = TcpStream::connect((host, port))?; + + if config.starttls { + let mut coroutine = ImapStartTls::new(context); + let mut arg = None; + + loop { + match coroutine.resume(arg.take()) { + ImapStartTlsResult::Io(io) => arg = Some(handle(&mut tcp, io)?), + ImapStartTlsResult::Ok { context: c } => break context = c, + ImapStartTlsResult::Err { err, .. } => Err(err)?, + } + } + } + + let tls_provider = match config.tls.provider { + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + Some(TlsProviderConfig::Rustls) => TlsProviderConfig::Rustls, + #[cfg(not(feature = "rustls-aws"))] + #[cfg(not(feature = "rustls-ring"))] + Some(TlsProviderConfig::Rustls) => { + bail!("Required cargo feature: `rustls-aws` or `rustls-ring`") + } + #[cfg(feature = "native-tls")] + Some(TlsProviderConfig::NativeTls) => TlsProviderConfig::NativeTls, + #[cfg(not(feature = "native-tls"))] + Some(TlsProviderConfig::NativeTls) => { + bail!("Required cargo feature: `native-tls`") + } + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + None => TlsProviderConfig::Rustls, + #[cfg(not(feature = "rustls-aws"))] + #[cfg(not(feature = "rustls-ring"))] + #[cfg(feature = "native-tls")] + None => TlsProviderConfig::NativeTls, + #[cfg(not(feature = "rustls-aws"))] + #[cfg(not(feature = "rustls-ring"))] + #[cfg(not(feature = "native-tls"))] + None => { + bail!("Required cargo feature: `rustls-aws`, `rustls-ring` or `native-tls`") + } + }; + + debug!("using TLS provider: {tls_provider:?}"); + + let mut stream = match tls_provider { + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + TlsProviderConfig::Rustls => { + let crypto_provider = match config.tls.rustls.crypto { + #[cfg(feature = "rustls-aws")] + Some(RustlsCryptoConfig::Aws) => RustlsCryptoConfig::Aws, + #[cfg(not(feature = "rustls-aws"))] + Some(RustlsCryptoConfig::Aws) => { + bail!("Required cargo feature: `rustls-aws`"); + } + #[cfg(feature = "rustls-ring")] + Some(RustlsCryptoConfig::Ring) => RustlsCryptoConfig::Ring, + #[cfg(not(feature = "rustls-ring"))] + Some(RustlsCryptoConfig::Ring) => { + bail!("Required cargo feature: `rustls-ring`"); + } + #[cfg(feature = "rustls-ring")] + None => RustlsCryptoConfig::Ring, + #[cfg(not(feature = "rustls-ring"))] + #[cfg(feature = "rustls-aws")] + None => RustlsCryptoConfig::Aws, + #[cfg(not(feature = "rustls-aws"))] + #[cfg(not(feature = "rustls-ring"))] + None => { + bail!("Required cargo feature: `rustls-aws` or `rustls-ring`"); + } + }; + + debug!("using rustls crypto provider: {crypto_provider:?}"); + + let crypto_provider = match crypto_provider { + #[cfg(feature = "rustls-aws")] + RustlsCryptoConfig::Aws => crypto::aws_lc_rs::default_provider(), + #[cfg(feature = "rustls-ring")] + RustlsCryptoConfig::Ring => crypto::ring::default_provider(), + #[allow(unreachable_patterns)] + _ => unreachable!(), + }; + + let crypto_provider = match crypto_provider.install_default() { + Ok(()) => CryptoProvider::get_default().unwrap().clone(), + Err(crypto_provider) => crypto_provider, + }; + + let mut config = if let Some(pem_path) = &config.tls.cert { + debug!("using TLS cert at {}", pem_path.display()); + let pem = fs::read(pem_path)?; + + let Some(cert) = CertificateDer::pem_slice_iter(&pem).next() else { + bail!("empty TLS cert at {}", pem_path.display()) + }; + + let verifier = + Verifier::new_with_extra_roots(vec![cert?], crypto_provider)?; + + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth() + } else { + debug!("using OS TLS certs"); + ClientConfig::with_platform_verifier()? + }; + + config.alpn_protocols = vec![b"imap".to_vec()]; + + let server_name = host.to_string().try_into()?; + let conn = ClientConnection::new(Arc::new(config), server_name)?; + Stream::Rustls(StreamOwned::new(conn, tcp)) + } + #[cfg(feature = "native-tls")] + TlsProviderConfig::NativeTls => { + let mut builder = TlsConnector::builder(); + + if let Some(pem_path) = &config.tls.cert { + debug!("using TLS cert at {}", pem_path.display()); + let pem = fs::read(pem_path)?; + let cert = native_tls::Certificate::from_pem(&pem)?; + builder.add_root_certificate(cert); + } + + let connector = builder.build()?; + Stream::NativeTls(connector.connect(host, tcp)?) + } + #[allow(unreachable_patterns)] + _ => unreachable!(), + }; + + if config.starttls { + let mut coroutine = GetImapCapability::new(context); + let mut arg = None; + + loop { + match coroutine.resume(arg.take()) { + GetImapCapabilityResult::Io(io) => arg = Some(handle(&mut stream, io)?), + GetImapCapabilityResult::Ok { context: c } => break context = c, + GetImapCapabilityResult::Err { err, .. } => Err(err)?, + } + } + } else { + let mut coroutine = GetImapGreetingWithCapability::new(context); + let mut arg = None; + + loop { + match coroutine.resume(arg.take()) { + GetImapGreetingWithCapabilityResult::Io(io) => { + arg = Some(handle(&mut stream, io)?) + } + GetImapGreetingWithCapabilityResult::Ok { context: c } => { + break context = c + } + GetImapGreetingWithCapabilityResult::Err { err, .. } => Err(err)?, + } + } + } + + (context, stream) + } + scheme if scheme.eq_ignore_ascii_case("unix") => { + todo!() + } + scheme => { + bail!("Unknown scheme {scheme}, expected imap, imaps or unix"); + } + }; + + if !context.authenticated { + let mut candidates = vec![]; + + let ir = context.capability.contains(&Capability::SaslIr); + + for mechanism in config.sasl.mechanisms { + match mechanism { + SaslMechanismConfig::Login => { + let Some(auth) = config.sasl.login.take() else { + debug!("missing SASL LOGIN configuration, skipping it"); + continue; + }; + + if context.capability.contains(&Capability::LoginDisabled) { + debug!("SASL LOGIN disabled by the server, skipping it"); + continue; + } + + let login = Capability::Auth(AuthMechanism::Login); + if !context.capability.contains(&login) { + debug!("SASL LOGIN disabled by the server, skipping it"); + continue; + } + + candidates.push(ImapAuthenticateCandidate::Login(ImapLoginParams::new( + auth.username, + auth.password.get()?, + )?)); + } + SaslMechanismConfig::Plain => { + let Some(auth) = config.sasl.plain.take() else { + debug!("missing SASL PLAIN configuration, skipping it"); + continue; + }; + + let plain = Capability::Auth(AuthMechanism::Plain); + if !context.capability.contains(&plain) { + debug!("SASL PLAIN disabled by the server, skipping it"); + continue; + } + + candidates.push(ImapAuthenticateCandidate::Plain( + ImapAuthenticatePlainParams::new( + auth.authzid, + auth.authcid, + auth.passwd.get()?, + ir, + ), + )); + } + SaslMechanismConfig::Anonymous => { + // TODO: check if capability available + + let message = config + .sasl + .anonymous + .take() + .and_then(|auth| auth.message) + .unwrap_or_default(); + + candidates.push(ImapAuthenticateCandidate::Anonymous( + ImapAuthenticateAnonymousParams::new(message, ir), + )); + } + }; + } + + let mut arg = None; + let mut coroutine = ImapAuthenticate::new(context, candidates); + + loop { + match coroutine.resume(arg.take()) { + ImapAuthenticateResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapAuthenticateResult::Ok { context: c, .. } => break context = c, + ImapAuthenticateResult::Err { err, .. } => bail!(err), + } + } + } + + Ok((context, stream)) +} diff --git a/src/lib.rs b/src/lib.rs index 2a9c7283..b1d65397 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,8 @@ -pub mod account; pub mod cli; pub mod config; #[cfg(feature = "imap")] pub mod imap; pub mod sasl; -pub mod stream; pub mod tls; // pub mod email; diff --git a/src/stream.rs b/src/stream.rs deleted file mode 100644 index 0b411376..00000000 --- a/src/stream.rs +++ /dev/null @@ -1,159 +0,0 @@ -#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] -use std::{fs, path::PathBuf, sync::Arc}; -use std::{ - io::{self, Read, Write}, - net::TcpStream, -}; - -#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] -use anyhow::bail; -use anyhow::Result; -use io_stream::runtimes::std::handle; -#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] -use rustls::{ - pki_types::{pem::PemObject, CertificateDer}, - ClientConfig, ClientConnection, StreamOwned, -}; -#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] -use rustls_platform_verifier::{ConfigVerifierExt, Verifier}; - -#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] -use io_imap::coroutines::starttls::*; -use io_imap::{ - context::ImapContext, - coroutines::{capability::*, greeting_with_capability::*}, -}; - -/// Creates an insecure client, using TCP. -/// -/// This constructor creates a client based on an raw -/// [`TcpStream`], receives greeting then saves server -/// capabilities. -pub fn tcp(host: impl AsRef, port: u16) -> Result<(ImapContext, Stream)> { - let mut context = ImapContext::new(); - let mut tcp = TcpStream::connect((host.as_ref(), port))?; - - let mut coroutine = GetImapGreetingWithCapability::new(context); - let mut arg = None; - - loop { - match coroutine.resume(arg.take()) { - GetImapGreetingWithCapabilityResult::Ok(out) => break context = out.context, - GetImapGreetingWithCapabilityResult::Io(io) => { - arg = Some(handle(&mut tcp, io).unwrap()) - } - GetImapGreetingWithCapabilityResult::Err(err) => Err(err)?, - } - } - - Ok((context, Stream::Tcp(tcp))) -} - -#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] -pub fn rustls( - host: impl ToString, - port: u16, - starttls: bool, - cert: Option, -) -> Result<(ImapContext, Stream)> { - let host = host.to_string(); - let mut context = ImapContext::new(); - let mut tcp = TcpStream::connect((host.as_str(), port))?; - - if starttls { - let mut coroutine = ImapStartTls::new(context); - let mut arg = None; - - loop { - match coroutine.resume(arg.take()) { - ImapStartTlsResult::Ok(out) => break context = out.context, - ImapStartTlsResult::Io(io) => arg = Some(handle(&mut tcp, io)?), - ImapStartTlsResult::Err(err) => Err(err)?, - } - } - } - - let mut config = if let Some(pem_path) = cert { - let pem = fs::read(&pem_path)?; - - let Some(cert) = CertificateDer::pem_slice_iter(&pem).next() else { - bail!("empty cert at {}", pem_path.display()) - }; - - let verifier = Verifier::new_with_extra_roots(vec![cert?])?; - - ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(verifier)) - .with_no_client_auth() - } else { - ClientConfig::with_platform_verifier() - }; - - // See - config.alpn_protocols = vec![b"imap".to_vec()]; - - let server_name = host.try_into()?; - let conn = ClientConnection::new(Arc::new(config), server_name)?; - let mut tls = StreamOwned::new(conn, tcp); - - if starttls { - let mut coroutine = GetImapCapability::new(context); - let mut arg = None; - - loop { - match coroutine.resume(arg.take()) { - GetImapCapabilityResult::Ok { context: c } => break context = c, - GetImapCapabilityResult::Io(io) => arg = Some(handle(&mut tls, io)?), - GetImapCapabilityResult::Err { err, .. } => Err(err)?, - } - } - } else { - let mut coroutine = GetImapGreetingWithCapability::new(context); - let mut arg = None; - - loop { - match coroutine.resume(arg.take()) { - GetImapGreetingWithCapabilityResult::Ok(out) => break context = out.context, - GetImapGreetingWithCapabilityResult::Io(io) => arg = Some(handle(&mut tls, io)?), - GetImapGreetingWithCapabilityResult::Err(err) => Err(err)?, - } - } - }; - - Ok((context, Stream::Rustls(tls))) -} - -pub enum Stream { - Tcp(TcpStream), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Rustls(StreamOwned), -} - -impl Read for Stream { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - match self { - Stream::Tcp(stream) => stream.read(buf), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Stream::Rustls(stream) => stream.read(buf), - } - } -} - -impl Write for Stream { - fn write(&mut self, buf: &[u8]) -> io::Result { - match self { - Stream::Tcp(stream) => stream.write(buf), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Stream::Rustls(stream) => stream.write(buf), - } - } - - fn flush(&mut self) -> io::Result<()> { - match self { - Stream::Tcp(stream) => stream.flush(), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Stream::Rustls(stream) => stream.flush(), - } - } -}