diff --git a/Cargo.lock b/Cargo.lock index e322159c..b1c24758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,9 +102,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", @@ -123,9 +123,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -133,9 +133,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -206,9 +206,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.61" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -294,9 +294,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.3" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "660c0520455b1013b9bcb0393d5f643d7e4454fb69c915b8d6d2aa0e9a45acc3" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" dependencies = [ "clap", ] @@ -532,7 +532,7 @@ dependencies = [ "bumpalo", "bytes", "domain-macros", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "jiff", "octseq", ] @@ -780,9 +780,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", ] @@ -1027,7 +1027,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1049,6 +1049,7 @@ dependencies = [ [[package]] name = "io-discovery" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-discovery#781bed9fb28f5a116ccb4a8009c678ec2792104d" dependencies = [ "anyhow", "base64", @@ -1068,6 +1069,7 @@ dependencies = [ [[package]] name = "io-email" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-email#3115430bb991dfcd5c254a9c5a7cccedd21d5eb4" dependencies = [ "chrono", "io-imap", @@ -1076,6 +1078,7 @@ dependencies = [ "io-smtp", "log", "mail-parser", + "pimalaya-stream", "secrecy", "serde", "thiserror 2.0.18", @@ -1085,6 +1088,7 @@ dependencies = [ [[package]] name = "io-http" version = "0.0.3" +source = "git+https://github.com/pimalaya/io-http#c8833e8c4c72f6e87f6d2a0e3b907e657384e197" dependencies = [ "anyhow", "base64", @@ -1100,19 +1104,26 @@ dependencies = [ [[package]] name = "io-imap" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-imap#50db256f6fe0b34ad86cdced3af4432192545e96" dependencies = [ + "anyhow", "imap-codec", "log", + "pimalaya-stream", "secrecy", "thiserror 2.0.18", + "url", ] [[package]] name = "io-jmap" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-jmap#3181b593df2d9e9909d1badb1faa395acd77f8c8" dependencies = [ + "anyhow", "io-http", "log", + "pimalaya-stream", "secrecy", "serde", "serde_json", @@ -1123,6 +1134,7 @@ dependencies = [ [[package]] name = "io-maildir" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-maildir#d6756d8bb19c8f9dcc61e10ec580200e6ed308a7" dependencies = [ "gethostname 1.1.0", "log", @@ -1134,14 +1146,18 @@ dependencies = [ [[package]] name = "io-smtp" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-smtp#40cc5917cf1012478de8a17c241cb045696c50e4" dependencies = [ + "anyhow", "base64", "bounded-static", "bounded-static-derive", "chumsky 1.0.0-alpha.8", "log", + "pimalaya-stream", "secrecy", "thiserror 2.0.18", + "url", ] [[package]] @@ -1260,9 +1276,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1284,9 +1300,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libgit2-sys" -version = "0.18.3+1.9.2" +version = "0.18.4+1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +checksum = "9b26f66f35e1871b22efcf7191564123d2a446ca0538cde63c23adfefa9b15b7" dependencies = [ "cc", "libc", @@ -1491,9 +1507,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.3.4" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "is-wsl", "libc", @@ -1502,9 +1518,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", @@ -1542,9 +1558,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -1597,6 +1613,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pimalaya-cli" version = "0.0.1" +source = "git+https://github.com/pimalaya/cli#4be5cd77455111e482ddbd9d93f4e19723623798" dependencies = [ "anyhow", "clap", @@ -1619,6 +1636,7 @@ dependencies = [ [[package]] name = "pimalaya-config" version = "0.0.1" +source = "git+https://github.com/pimalaya/config#5df70410bfecb5e046346b80864273f101bed473" dependencies = [ "anyhow", "dirs", @@ -1634,6 +1652,7 @@ dependencies = [ [[package]] name = "pimalaya-stream" version = "0.0.1" +source = "git+https://github.com/pimalaya/stream#c617b781119cb5826f0c5ac805de091d4fb4c686" dependencies = [ "anyhow", "log", @@ -2546,9 +2565,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2559,9 +2578,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2569,9 +2588,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2582,9 +2601,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -3021,9 +3040,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 85263e7e..fa8d7868 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,16 +17,13 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = ["imap", "smtp", "maildir", "jmap", "rustls-ring"] - imap = ["dep:io-imap", "dep:mail-parser", "dep:rfc2047-decoder", "io-email/imap", "io-imap/client"] jmap = ["dep:base64", "dep:io-jmap", "dep:mail-parser", "dep:serde_json", "io-email/jmap", "io-jmap/client"] smtp = ["dep:io-smtp", "dep:mail-parser", "io-email/smtp"] maildir = ["dep:convert_case", "dep:io-maildir", "dep:mail-parser", "dep:mime_guess", "io-email/maildir", "io-maildir/client"] - -native-tls = ["pimalaya-stream/native-tls", "io-discovery/native-tls"] -rustls-aws = ["pimalaya-stream/rustls-aws", "io-discovery/rustls-aws"] -rustls-ring = ["pimalaya-stream/rustls-ring", "io-discovery/rustls-ring"] - +native-tls = ["pimalaya-stream/native-tls", "io-discovery/native-tls", "io-imap?/native-tls", "io-jmap?/native-tls", "io-smtp?/native-tls"] +rustls-aws = ["pimalaya-stream/rustls-aws", "io-discovery/rustls-aws", "io-imap?/rustls-aws", "io-jmap?/rustls-aws", "io-smtp?/rustls-aws"] +rustls-ring = ["pimalaya-stream/rustls-ring", "io-discovery/rustls-ring", "io-imap?/rustls-ring", "io-jmap?/rustls-ring", "io-smtp?/rustls-ring"] vendored = ["pimalaya-stream/vendored"] [build-dependencies] @@ -76,13 +73,13 @@ tempfile = "3" [patch.crates-io] domain = { git = "https://github.com/soywod/domain", branch = "new-srv" } -io-discovery.path = "../io-discovery" -io-email.path = "../io-email" -io-http.path = "../io-http" -io-imap.path = "../io-imap" -io-jmap.path = "../io-jmap" -io-maildir.path = "../io-maildir" -io-smtp.path = "../io-smtp" -pimalaya-cli.path = "../cli" -pimalaya-config.path = "../config" -pimalaya-stream.path = "../stream" +io-discovery.git = "https://github.com/pimalaya/io-discovery" +io-email.git = "https://github.com/pimalaya/io-email" +io-http.git = "https://github.com/pimalaya/io-http" +io-imap.git = "https://github.com/pimalaya/io-imap" +io-jmap.git = "https://github.com/pimalaya/io-jmap" +io-maildir.git = "https://github.com/pimalaya/io-maildir" +io-smtp.git = "https://github.com/pimalaya/io-smtp" +pimalaya-cli.git = "https://github.com/pimalaya/cli" +pimalaya-config.git = "https://github.com/pimalaya/config" +pimalaya-stream.git = "https://github.com/pimalaya/stream" diff --git a/src/account/check.rs b/src/account/check.rs index d6a22eb9..c052970e 100644 --- a/src/account/check.rs +++ b/src/account/check.rs @@ -7,7 +7,7 @@ use pimalaya_config::toml::TomlConfig; use serde::Serialize; use crate::{ - cli::BackendFlag, + backend::Backend, config::{AccountConfig, Config}, }; @@ -27,7 +27,7 @@ impl AccountCheckCommand { printer: &mut impl Printer, config_paths: &[PathBuf], account_name: Option<&str>, - backend: BackendFlag, + backend: Backend, ) -> Result<()> { let mut config = match Config::from_paths_or_default(config_paths)? { Some(config) => config, @@ -96,14 +96,18 @@ fn check_imap( _account_config: &AccountConfig, imap_config: crate::config::ImapConfig, ) -> BackendCheck { - use crate::imap::session::ImapSession; + use io_imap::client::ImapClientStd; + use pimalaya_stream::{sasl::Sasl, std::stream::StreamStd, tls::Tls}; let result = (|| -> Result<()> { - let _session = ImapSession::new( - imap_config.url.clone(), - imap_config.tls.clone().try_into()?, + let mut tls: Tls = imap_config.tls.clone().into(); + tls.rustls.alpn = vec!["imap".into()]; + let sasl: Sasl = imap_config.sasl.clone().try_into()?; + let _client = ImapClientStd::::connect( + &imap_config.url, + &tls, imap_config.starttls, - imap_config.sasl.clone().try_into()?, + Some(sasl), )?; Ok(()) })(); @@ -117,14 +121,18 @@ fn check_jmap( _account_config: &AccountConfig, jmap_config: crate::config::JmapConfig, ) -> BackendCheck { - use crate::jmap::session::JmapSession; + use io_jmap::client::JmapClientStd; + use pimalaya_stream::tls::Tls; + + use crate::jmap::client::{jmap_http_auth, parse_server_url}; let result = (|| -> Result<()> { - let _session = JmapSession::new( - jmap_config.server.clone(), - jmap_config.tls.clone().try_into()?, - jmap_config.auth.clone().try_into()?, - )?; + let mut tls: Tls = jmap_config.tls.clone().into(); + tls.rustls.alpn = vec!["http/1.1".into()]; + let http_auth = jmap_http_auth(jmap_config.auth.clone())?; + let url = parse_server_url(&jmap_config.server)?; + let mut client = JmapClientStd::connect(&url, &tls, http_auth)?; + client.session_get(&url)?; Ok(()) })(); @@ -156,14 +164,22 @@ fn check_smtp( _account_config: &AccountConfig, smtp_config: crate::config::SmtpConfig, ) -> BackendCheck { - use crate::smtp::session::SmtpSession; + use std::net::Ipv4Addr; + + use io_smtp::{client::SmtpClientStd, rfc5321::types::ehlo_domain::EhloDomain}; + use pimalaya_stream::{sasl::Sasl, std::stream::StreamStd, tls::Tls}; let result = (|| -> Result<()> { - let _session = SmtpSession::new( - smtp_config.url.clone(), - smtp_config.tls.clone().try_into()?, + let mut tls: Tls = smtp_config.tls.clone().into(); + tls.rustls.alpn = vec!["smtp".into()]; + let sasl: Sasl = smtp_config.sasl.clone().try_into()?; + let domain: EhloDomain<'static> = Ipv4Addr::new(127, 0, 0, 1).into(); + let _client = SmtpClientStd::::connect( + &smtp_config.url, + &tls, smtp_config.starttls, - smtp_config.sasl.clone().try_into()?, + domain, + Some(sasl), )?; Ok(()) })(); diff --git a/src/account/cli.rs b/src/account/cli.rs index 55a79ef9..c98bb08d 100644 --- a/src/account/cli.rs +++ b/src/account/cli.rs @@ -8,7 +8,7 @@ use crate::{ account::{ check::AccountCheckCommand, configure::AccountConfigureCommand, list::AccountListCommand, }, - cli::BackendFlag, + backend::Backend, }; /// Manage accounts defined in the TOML configuration file. @@ -31,7 +31,7 @@ impl AccountCommand { printer: &mut impl Printer, config_paths: &[PathBuf], account_name: Option<&str>, - backend: BackendFlag, + backend: Backend, ) -> Result<()> { match self { Self::List(cmd) => cmd.execute(printer, config_paths), diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 00000000..44a21f98 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,72 @@ +use std::{fmt, str::FromStr}; + +use anyhow::{bail, Error}; +use clap::Parser; + +/// Selects which backend a cross-protocol command should target. +/// +/// `Auto` lets the command pick the first configured-and-supported +/// backend in its own priority order. The named variants pin the +/// command to that backend; the command bails if it cannot be served +/// (config missing, or the operation has no arm for that backend). +/// +/// The protocol-specific subcommands (`imap`, `jmap`, `maildir`, +/// `smtp`) ignore this arg entirely. +#[derive(Clone, Copy, Debug, Default, Parser, PartialEq, Eq)] +pub enum Backend { + #[default] + Auto, + Imap, + Jmap, + Maildir, + Smtp, +} + +impl Backend { + /// Whether the IMAP arm of a shared command is allowed to run. + pub fn allows_imap(self) -> bool { + matches!(self, Self::Auto | Self::Imap) + } + + /// Whether the JMAP arm of a shared command is allowed to run. + pub fn allows_jmap(self) -> bool { + matches!(self, Self::Auto | Self::Jmap) + } + + /// Whether the Maildir arm of a shared command is allowed to run. + pub fn allows_maildir(self) -> bool { + matches!(self, Self::Auto | Self::Maildir) + } + + /// Whether the SMTP arm of a shared command is allowed to run. + pub fn allows_smtp(self) -> bool { + matches!(self, Self::Auto | Self::Smtp) + } +} + +impl FromStr for Backend { + type Err = Error; + + fn from_str(backend: &str) -> Result { + match backend { + "auto" => Ok(Self::Auto), + "imap" => Ok(Self::Imap), + "jmap" => Ok(Self::Jmap), + "maildir" => Ok(Self::Maildir), + "smtp" => Ok(Self::Smtp), + backend => bail!("Invalid backend {backend}"), + } + } +} + +impl fmt::Display for Backend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Auto => write!(f, "auto"), + Self::Imap => write!(f, "imap"), + Self::Jmap => write!(f, "jmap"), + Self::Maildir => write!(f, "maildir"), + Self::Smtp => write!(f, "smtp"), + } + } +} diff --git a/src/cli.rs b/src/cli.rs index f0cbaad0..08bd1bb0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,6 @@ -use std::{fmt, path::PathBuf, str::FromStr}; +use std::path::PathBuf; -use anyhow::{bail, Error, Result}; +use anyhow::{bail, Result}; use clap::{CommandFactory, Parser, Subcommand}; use pimalaya_cli::{ clap::{ @@ -23,11 +23,11 @@ use crate::maildir::{cli::MaildirCommand, client::build_maildir_client}; use crate::smtp::{cli::SmtpCommand, client::build_smtp_client}; use crate::{ account::cli::AccountCommand, + backend::Backend, config::Config, shared::{ - attachments::cli::AttachmentCommand, client::build_email_client, - envelopes::cli::EnvelopeCommand, flags::cli::FlagCommand, mailboxes::cli::MailboxCommand, - messages::cli::MessageCommand, + attachments::cli::AttachmentCommand, client::EmailClient, envelopes::cli::EnvelopeCommand, + flags::cli::FlagCommand, mailboxes::cli::MailboxCommand, messages::cli::MessageCommand, }, wizard, }; @@ -70,7 +70,7 @@ pub struct HimalayaCli { /// config block, or if the operation has no implementation for it /// — e.g. `--backend smtp mailboxes list`). #[arg(short, long, global = true, default_value_t)] - pub backend: BackendFlag, + pub backend: Backend, #[command(flatten)] pub json: JsonFlag, #[command(flatten)] @@ -115,35 +115,61 @@ pub enum HimalayaCommand { Manuals(ManualCommand), } +/// Loads `Config` from the merged `config_paths` or, when no file +/// exists, runs the wizard to bootstrap one at the target path. Used +/// by every `build_*_client` helper to get a populated `Config` before +/// the per-backend client opens its connection. +pub fn load_or_wizard(config_paths: &[PathBuf]) -> Result { + match Config::from_paths_or_default(config_paths)? { + Some(config) => Ok(config), + None => wizard::run_or_exit(&Config::target_path(config_paths)?), + } +} + impl HimalayaCommand { pub fn execute( self, printer: &mut impl Printer, config_paths: &[PathBuf], account_name: Option<&str>, - backend: BackendFlag, + backend: Backend, ) -> Result<()> { + let configs = || { + let mut config = load_or_wizard(config_paths)?; + + let Some((_, account_config)) = config.take_account(account_name)? else { + bail!("Cannot find account") + }; + + Ok((config, account_config)) + }; + match self { // --- Shared API // Self::Mailboxes(cmd) => { - let client = build_email_client(config_paths, account_name, backend)?; + let (config, account_config) = configs()?; + let client = EmailClient::new(config, account_config, backend)?; cmd.execute(printer, client) } Self::Envelopes(cmd) => { - let client = build_email_client(config_paths, account_name, backend)?; + let (config, account_config) = configs()?; + let client = EmailClient::new(config, account_config, backend)?; cmd.execute(printer, client) } Self::Flags(cmd) => { - let client = build_email_client(config_paths, account_name, backend)?; + let (config, account_config) = configs()?; + let client = EmailClient::new(config, account_config, backend)?; cmd.execute(printer, client) } Self::Messages(cmd) => { - let client = build_email_client(config_paths, account_name, backend)?; + let (config, account_config) = configs()?; + let client = EmailClient::new(config, account_config, backend)?; cmd.execute(printer, client) } Self::Attachments(cmd) => { - let client = build_email_client(config_paths, account_name, backend)?; + let (config, account_config) = configs()?; + let client = EmailClient::new(config, account_config, backend)?; cmd.execute(printer, client) } @@ -178,80 +204,3 @@ impl HimalayaCommand { } } } - -/// Selects which backend a cross-protocol command should target. -/// -/// `Auto` lets the command pick the first configured-and-supported -/// backend in its own priority order. The named variants pin the -/// command to that backend; the command bails if it cannot be served -/// (config missing, or the operation has no arm for that backend). -/// -/// The protocol-specific subcommands (`imap`, `jmap`, `maildir`, -/// `smtp`) ignore this arg entirely. -#[derive(Clone, Copy, Debug, Default, Parser, PartialEq, Eq)] -pub enum BackendFlag { - #[default] - Auto, - Imap, - Jmap, - Maildir, - Smtp, -} - -impl BackendFlag { - /// Whether the IMAP arm of a shared command is allowed to run. - pub fn allows_imap(self) -> bool { - matches!(self, Self::Auto | Self::Imap) - } - - /// Whether the JMAP arm of a shared command is allowed to run. - pub fn allows_jmap(self) -> bool { - matches!(self, Self::Auto | Self::Jmap) - } - - /// Whether the Maildir arm of a shared command is allowed to run. - pub fn allows_maildir(self) -> bool { - matches!(self, Self::Auto | Self::Maildir) - } - - /// Whether the SMTP arm of a shared command is allowed to run. - pub fn allows_smtp(self) -> bool { - matches!(self, Self::Auto | Self::Smtp) - } -} - -impl FromStr for BackendFlag { - type Err = Error; - - fn from_str(backend: &str) -> Result { - match backend { - "auto" => Ok(Self::Auto), - "imap" => Ok(Self::Imap), - "jmap" => Ok(Self::Jmap), - "maildir" => Ok(Self::Maildir), - "smtp" => Ok(Self::Smtp), - backend => bail!("Invalid backend {backend}"), - } - } -} -impl fmt::Display for BackendFlag { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Auto => write!(f, "auto"), - Self::Imap => write!(f, "imap"), - Self::Jmap => write!(f, "jmap"), - Self::Maildir => write!(f, "maildir"), - Self::Smtp => write!(f, "smtp"), - } - } -} - -/// Loads `Config` from `paths`, or runs the wizard if no config file -/// is found. Centralises the `Result>` → `Config` -/// adaptation so call sites stay readable. -pub(crate) fn load_or_wizard(paths: &[PathBuf]) -> Result { - match Config::from_paths_or_default(paths)? { - Some(config) => Ok(config), - None => wizard::run_or_exit(&Config::target_path(paths)?), - } -} diff --git a/src/config.rs b/src/config.rs index d60c243a..03c29349 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,12 +3,12 @@ use std::{collections::HashMap, fs, path::Path, path::PathBuf}; use anyhow::{Context, Result}; use comfy_table::ContentArrangement; use pimalaya_config::{ - secret::{Secret, SecretError}, + secret::Secret, toml::{shell_expanded_string, TomlConfig}, }; use pimalaya_stream::{ - sasl::{Sasl, SaslAnonymous, SaslLogin, SaslMechanism, SaslPlain}, - std::tls::{Rustls, RustlsCrypto, Tls, TlsProvider}, + sasl::{Sasl, SaslAnonymous, SaslLogin, SaslPlain}, + tls::{Rustls, RustlsCrypto, Tls, TlsProvider}, }; use serde::{Deserialize, Serialize}; use url::Url; @@ -256,11 +256,9 @@ pub enum RustlsCryptoConfig { Ring, } -impl TryFrom for Tls { - type Error = SecretError; - - fn try_from(config: TlsConfig) -> Result { - Ok(Tls { +impl From for Tls { + fn from(config: TlsConfig) -> Self { + Tls { provider: config.provider.map(|config| match config { TlsProviderConfig::Rustls => TlsProvider::Rustls, TlsProviderConfig::NativeTls => TlsProvider::NativeTls, @@ -270,9 +268,10 @@ impl TryFrom for Tls { RustlsCryptoConfig::Aws => RustlsCrypto::Aws, RustlsCryptoConfig::Ring => RustlsCrypto::Ring, }), + alpn: Vec::new(), }, cert: config.cert, - }) + } } } @@ -323,37 +322,46 @@ pub struct SaslAnonymousConfig { } impl TryFrom for Sasl { - type Error = SecretError; + type Error = anyhow::Error; - fn try_from(config: SaslConfig) -> Result { - Ok(Sasl { - mechanism: config.mechanism.map(|m| match m { - SaslMechanismConfig::Anonymous => SaslMechanism::Anonymous, - SaslMechanismConfig::Plain => SaslMechanism::Plain, - SaslMechanismConfig::Login => SaslMechanism::Login, - }), - anonymous: match config.anonymous { - None => None, - Some(config) => Some(SaslAnonymous { - message: config.message, - }), - }, - plain: match config.plain { - None => None, - Some(config) => Some(SaslPlain { - authzid: config.authzid, - authcid: config.authcid, - passwd: config.passwd.get()?, - }), - }, - login: match config.login { - None => None, - Some(config) => Some(SaslLogin { - username: config.username, - password: config.password.get()?, - }), - }, - }) + fn try_from(config: SaslConfig) -> Result { + // Pick the mechanism explicitly if set; otherwise infer from the + // first populated credential block. Anonymous is the last-resort + // fallback since it carries no secrets. + let mechanism = config.mechanism.unwrap_or_else(|| { + if config.plain.is_some() { + SaslMechanismConfig::Plain + } else if config.login.is_some() { + SaslMechanismConfig::Login + } else { + SaslMechanismConfig::Anonymous + } + }); + + match mechanism { + SaslMechanismConfig::Anonymous => Ok(Sasl::Anonymous(SaslAnonymous { + message: config.anonymous.and_then(|c| c.message), + })), + SaslMechanismConfig::Login => { + let c = config + .login + .ok_or_else(|| anyhow::anyhow!("missing SASL LOGIN configuration"))?; + Ok(Sasl::Login(SaslLogin { + username: c.username, + password: c.password.get()?, + })) + } + SaslMechanismConfig::Plain => { + let c = config + .plain + .ok_or_else(|| anyhow::anyhow!("missing SASL PLAIN configuration"))?; + Ok(Sasl::Plain(SaslPlain { + authzid: c.authzid, + authcid: c.authcid, + passwd: c.passwd.get()?, + })) + } + } } } @@ -405,19 +413,3 @@ pub enum JmapAuthConfig { password: Secret, }, } - -#[cfg(feature = "jmap")] -impl TryFrom for crate::jmap::session::JmapAuth { - type Error = pimalaya_config::secret::SecretError; - - fn try_from(config: JmapAuthConfig) -> Result { - match config { - JmapAuthConfig::Header(token) => Ok(Self::Header(token.get()?)), - JmapAuthConfig::Bearer { token } => Ok(Self::Bearer(token.get()?)), - JmapAuthConfig::Basic { username, password } => Ok(Self::Basic { - username, - password: password.get()?, - }), - } - } -} diff --git a/src/imap/client.rs b/src/imap/client.rs index 7099aa93..092c51dd 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -1,4 +1,4 @@ -//! Himalaya wrapper around [`io_imap::client::ImapClient`] that +//! Himalaya wrapper around [`io_imap::client::ImapClientStd`] that //! bundles the merged [`Account`] alongside the live IMAP client. //! //! This is what every IMAP-specific subcommand receives: the dispatch @@ -11,36 +11,31 @@ use std::{ }; use anyhow::{anyhow, Result}; -use io_imap::client::ImapClient as Inner; +use io_imap::client::ImapClientStd as Inner; use pimalaya_config::toml::TomlConfig; +use pimalaya_stream::{sasl::Sasl, std::stream::StreamStd, tls::Tls}; -use crate::{ - account::context::Account, cli::load_or_wizard, config::ImapConfig, imap::session::ImapSession, -}; +use crate::{account::context::Account, cli::load_or_wizard, config::ImapConfig}; pub struct ImapClient { - inner: Inner, + inner: Inner, pub account: Account, } impl ImapClient { /// Opens the IMAP connection (TCP/TLS/STARTTLS, greeting, SASL) - /// then wraps the resulting stream + context in an - /// [`io_imap::client::ImapClient`] alongside `account`. + /// then wraps the resulting client alongside `account`. pub fn new(config: ImapConfig, account: Account) -> Result { - let session = ImapSession::new( - config.url, - config.tls.try_into()?, - config.starttls, - config.sasl.try_into()?, - )?; - let inner = Inner::from_parts(session.stream, session.context); + let mut tls: Tls = config.tls.into(); + tls.rustls.alpn = vec!["imap".into()]; + let sasl: Sasl = config.sasl.try_into()?; + let inner = Inner::::connect(&config.url, &tls, config.starttls, Some(sasl))?; Ok(Self { inner, account }) } } impl Deref for ImapClient { - type Target = Inner; + type Target = Inner; fn deref(&self) -> &Self::Target { &self.inner diff --git a/src/imap/mod.rs b/src/imap/mod.rs index c4c1638d..f4f16573 100644 --- a/src/imap/mod.rs +++ b/src/imap/mod.rs @@ -5,4 +5,3 @@ pub mod flag; pub mod id; pub mod mailbox; pub mod message; -pub mod session; diff --git a/src/imap/session.rs b/src/imap/session.rs deleted file mode 100644 index 6e45b3da..00000000 --- a/src/imap/session.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Transitional IMAP session helper ported from `pimalaya-toolbox`. -//! -//! Will be replaced by `io_imap::client::ImapClient` once the -//! protocol-specific subcommands switch over. - -#[cfg(unix)] -use std::os::unix::net::UnixStream; -use std::{ - io::{Read, Write}, - net::TcpStream, -}; - -use anyhow::{bail, Result}; -use io_imap::{ - context::ImapContext, - rfc3501::{ - capability::{ImapCapabilityGet, ImapCapabilityGetResult}, - greeting_with_capability::{ - ImapGreetingWithCapabilityGet, ImapGreetingWithCapabilityGetResult, - }, - login::{ImapSessionLogin, ImapSessionLoginParams, ImapSessionLoginResult}, - starttls::{ImapStartTls, ImapStartTlsResult}, - }, - sasl::authenticate_plain::{ - ImapSessionAuthenticatePlain, ImapSessionAuthenticatePlainParams, - ImapSessionAuthenticatePlainResult, - }, - types::response::Capability, -}; -use log::info; -use pimalaya_stream::{ - sasl::{Sasl, SaslMechanism}, - std::{ - stream::Stream, - tls::{upgrade_tls, Tls}, - }, -}; -#[cfg(windows)] -use uds_windows::UnixStream; -use url::Url; - -const READ_BUFFER_SIZE: usize = 16 * 1024; - -#[derive(Debug)] -pub struct ImapSession { - pub context: ImapContext, - pub stream: Stream, -} - -fn drive_greeting_with_capability( - stream: &mut S, - context: ImapContext, -) -> Result { - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = ImapGreetingWithCapabilityGet::new(context); - let mut arg: Option<&[u8]> = None; - - loop { - match coroutine.resume(arg.take()) { - ImapGreetingWithCapabilityGetResult::Ok(context) => return Ok(context), - ImapGreetingWithCapabilityGetResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - ImapGreetingWithCapabilityGetResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - ImapGreetingWithCapabilityGetResult::Err { err, .. } => bail!(err), - } - } -} - -fn drive_capability(stream: &mut S, context: ImapContext) -> Result { - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = ImapCapabilityGet::new(context); - let mut arg: Option<&[u8]> = None; - - loop { - match coroutine.resume(arg.take()) { - ImapCapabilityGetResult::Ok(context) => return Ok(context), - ImapCapabilityGetResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - ImapCapabilityGetResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - ImapCapabilityGetResult::Err { err, .. } => bail!(err), - } - } -} - -fn drive_starttls(stream: &mut S, context: ImapContext) -> Result { - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = ImapStartTls::new(context); - let mut arg: Option<&[u8]> = None; - - loop { - match coroutine.resume(arg.take()) { - ImapStartTlsResult::WantsStartTls { context, .. } => return Ok(context), - ImapStartTlsResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - ImapStartTlsResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - ImapStartTlsResult::Err { err, .. } => bail!(err), - } - } -} - -impl ImapSession { - pub fn new(url: Url, tls: Tls, starttls: bool, mut sasl: Sasl) -> Result { - info!("connecting to IMAP server using {url}"); - - let context = ImapContext::new(); - let host = url.host_str().unwrap_or("127.0.0.1"); - - let (mut context, mut stream) = match url.scheme() { - scheme if scheme.eq_ignore_ascii_case("imap") => { - let port = url.port().unwrap_or(143); - let mut tcp = TcpStream::connect((host, port))?; - let context = drive_greeting_with_capability(&mut tcp, context)?; - (context, Stream::Tcp(tcp)) - } - scheme if scheme.eq_ignore_ascii_case("imaps") => { - let port = url.port().unwrap_or(993); - let mut tcp = TcpStream::connect((host, port))?; - - let context = if starttls { - drive_starttls(&mut tcp, context)? - } else { - context - }; - - let mut stream = upgrade_tls(host, tcp, &tls, &[b"imap"])?; - - let context = if starttls { - drive_capability(&mut stream, context)? - } else { - drive_greeting_with_capability(&mut stream, context)? - }; - - (context, stream) - } - scheme if scheme.eq_ignore_ascii_case("unix") => { - let sock_path = url.path(); - let mut unix = UnixStream::connect(sock_path)?; - let context = drive_greeting_with_capability(&mut unix, context)?; - (context, Stream::Unix(unix)) - } - scheme => { - bail!("Unknown scheme {scheme}, expected imap, imaps or unix"); - } - }; - - if !context.authenticated { - let ir = context.capability.contains(&Capability::SaslIr); - - let mechanism = sasl - .mechanism - .or(Some(SaslMechanism::Plain).filter(|_| sasl.plain.is_some())) - .or(Some(SaslMechanism::Login).filter(|_| sasl.login.is_some())); - - match mechanism { - None => bail!("no SASL mechanism configured"), - Some(SaslMechanism::Login) => { - let Some(auth) = sasl.login.take() else { - bail!("missing SASL LOGIN configuration"); - }; - - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = ImapSessionLogin::new( - context, - ImapSessionLoginParams::new(auth.username, auth.password)?, - ); - let mut arg: Option<&[u8]> = None; - - context = loop { - match coroutine.resume(arg.take()) { - ImapSessionLoginResult::Ok(c) => break c, - ImapSessionLoginResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - ImapSessionLoginResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - ImapSessionLoginResult::Err { err, .. } => bail!(err), - } - }; - } - Some(SaslMechanism::Plain) => { - let Some(auth) = sasl.plain.take() else { - bail!("missing SASL PLAIN configuration"); - }; - - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = ImapSessionAuthenticatePlain::new( - context, - ImapSessionAuthenticatePlainParams::new( - auth.authzid, - auth.authcid, - auth.passwd, - ir, - ), - ); - let mut arg: Option<&[u8]> = None; - - context = loop { - match coroutine.resume(arg.take()) { - ImapSessionAuthenticatePlainResult::Ok(c) => break c, - ImapSessionAuthenticatePlainResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - ImapSessionAuthenticatePlainResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - ImapSessionAuthenticatePlainResult::Err { err, .. } => bail!(err), - } - }; - } - Some(SaslMechanism::Anonymous) => { - unimplemented!("ANONYMOUS SASL mechanism not yet implemented") - } - } - } - - Ok(Self { context, stream }) - } -} diff --git a/src/jmap/client.rs b/src/jmap/client.rs index ab213177..b9f47816 100644 --- a/src/jmap/client.rs +++ b/src/jmap/client.rs @@ -1,4 +1,4 @@ -//! Himalaya wrapper around [`io_jmap::client::JmapClient`] that +//! Himalaya wrapper around [`io_jmap::client::JmapClientStd`] that //! bundles the merged [`Account`] alongside the live JMAP client. //! //! Built up front by the dispatch layer (`crate::cli`) via @@ -11,11 +11,17 @@ use std::{ }; use anyhow::{anyhow, Result}; -use io_jmap::client::JmapClient as Inner; +use base64::{prelude::BASE64_STANDARD, Engine}; +use io_jmap::client::JmapClientStd as Inner; use pimalaya_config::toml::TomlConfig; +use pimalaya_stream::tls::Tls; +use secrecy::{ExposeSecret, SecretString}; +use url::Url; use crate::{ - account::context::Account, cli::load_or_wizard, config::JmapConfig, jmap::session::JmapSession, + account::context::Account, + cli::load_or_wizard, + config::{JmapAuthConfig, JmapConfig}, }; pub struct JmapClient { @@ -33,12 +39,14 @@ impl JmapClient { /// discovery) then wraps the resulting client alongside /// `account`. pub fn new(config: JmapConfig, account: Account) -> Result { - let session = JmapSession::new( - config.server.clone(), - config.tls.clone().try_into()?, - config.auth.clone().try_into()?, - )?; - let inner = Inner::from_parts(session.stream, session.http_auth, session.session); + let mut tls: Tls = config.tls.clone().into(); + tls.rustls.alpn = vec!["http/1.1".into()]; + + let http_auth = jmap_http_auth(config.auth.clone())?; + let url = parse_server_url(&config.server)?; + + let mut inner = Inner::connect(&url, &tls, http_auth)?; + inner.session_get(&url)?; Ok(Self { inner, @@ -80,3 +88,34 @@ pub fn build_jmap_client( let account = Account::from(config).merge(Account::from(ac)); JmapClient::new(jmap_config, account) } + +/// Parses the JMAP `server` field into a [`Url`], defaulting bare +/// authorities (e.g. `mail.example.com`) to `https://`. +pub fn parse_server_url(server: &str) -> Result { + match Url::parse(server) { + Ok(url) => Ok(url), + Err(url::ParseError::RelativeUrlWithoutBase) => { + Ok(Url::parse(&format!("https://{server}"))?) + } + Err(err) => Err(err.into()), + } +} + +/// Converts a [`JmapAuthConfig`] into the pre-formatted HTTP +/// `Authorization` header value [`JmapClientStd::connect`] expects. +/// +/// [`JmapClientStd::connect`]: io_jmap::client::JmapClientStd::connect +pub fn jmap_http_auth(config: JmapAuthConfig) -> Result { + match config { + JmapAuthConfig::Header(token) => Ok(token.get()?), + JmapAuthConfig::Bearer { token } => { + let token = token.get()?; + Ok(format!("Bearer {}", token.expose_secret()).into()) + } + JmapAuthConfig::Basic { username, password } => { + let creds = format!("{}:{}", username, password.get()?.expose_secret()); + let encoded = BASE64_STANDARD.encode(creds.into_bytes()); + Ok(format!("Basic {encoded}").into()) + } + } +} diff --git a/src/jmap/email/export.rs b/src/jmap/email/export.rs index af5f166c..66623668 100644 --- a/src/jmap/email/export.rs +++ b/src/jmap/email/export.rs @@ -1,14 +1,11 @@ -use std::net::TcpStream; - use anyhow::{anyhow, Result}; use clap::Parser; -use io_jmap::{client::JmapClient as InnerJmapClient, rfc8621::capabilities::MAIL}; +use io_jmap::{client::JmapClientStd, rfc8621::capabilities::MAIL}; use pimalaya_cli::printer::{Message, Printer}; -use pimalaya_stream::std::tls::upgrade_tls; -use secrecy::SecretString; +use pimalaya_stream::tls::Tls; use url::Url; -use crate::jmap::{client::JmapClient, session::JmapAuth}; +use crate::jmap::client::{jmap_http_auth, JmapClient}; /// Export a raw RFC 5322 message to stdout (Email/get + blob download). /// @@ -22,10 +19,6 @@ pub struct JmapEmailExportCommand { impl JmapEmailExportCommand { pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { - let tls = client.config.tls.clone().try_into()?; - let auth: JmapAuth = client.config.auth.clone().try_into()?; - let http_auth: SecretString = auth.into(); - let properties = Some(vec!["id".to_owned(), "blobId".to_owned()]); let output = client.email_get(vec![self.id.clone()], properties, false, false, 0)?; @@ -55,11 +48,10 @@ impl JmapEmailExportCommand { let data = if same_authority(&api_url, &download_url) { client.blob_download(&download_url)? } else { - let host = download_url.host_str().unwrap_or("localhost"); - let port = download_url.port_or_known_default().unwrap_or(443); - let tcp = TcpStream::connect((host, port))?; - let stream = upgrade_tls(host, tcp, &tls, &[b"http/1.1"])?; - let mut download_client = InnerJmapClient::new(stream, http_auth); + let mut tls: Tls = client.config.tls.clone().into(); + tls.rustls.alpn = vec!["http/1.1".into()]; + let http_auth = jmap_http_auth(client.config.auth.clone())?; + let mut download_client = JmapClientStd::connect(&download_url, &tls, http_auth)?; download_client.blob_download(&download_url)? }; diff --git a/src/jmap/email/import.rs b/src/jmap/email/import.rs index 0177c8b5..43d86c42 100644 --- a/src/jmap/email/import.rs +++ b/src/jmap/email/import.rs @@ -1,21 +1,22 @@ use std::{ collections::BTreeMap, io::{stdin, BufRead, IsTerminal}, - net::TcpStream, }; use anyhow::{bail, Result}; use clap::Parser; use io_jmap::{ - client::JmapClient as InnerJmapClient, + client::JmapClientStd, rfc8621::{capabilities::MAIL, email::EmailImport}, }; use pimalaya_cli::printer::{Message, Printer}; -use pimalaya_stream::std::tls::upgrade_tls; -use secrecy::SecretString; +use pimalaya_stream::tls::Tls; use url::Url; -use crate::jmap::{client::JmapClient, error::format_set_error, session::JmapAuth}; +use crate::jmap::{ + client::{jmap_http_auth, JmapClient}, + error::format_set_error, +}; /// Import an RFC 5322 message into a mailbox (upload + Email/import). /// @@ -47,10 +48,6 @@ pub struct JmapEmailImportCommand { impl JmapEmailImportCommand { pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { - let tls = client.config.tls.clone().try_into()?; - let auth: JmapAuth = client.config.auth.clone().try_into()?; - let http_auth: SecretString = auth.into(); - let data: Vec = if stdin().is_terminal() || printer.is_json() { self.message .join(" ") @@ -79,11 +76,10 @@ impl JmapEmailImportCommand { .blob_upload(&upload_url, "message/rfc822", data)? .blob_id } else { - let host = upload_url.host_str().unwrap_or("localhost"); - let port = upload_url.port_or_known_default().unwrap_or(443); - let tcp = TcpStream::connect((host, port))?; - let stream = upgrade_tls(host, tcp, &tls, &[b"http/1.1"])?; - let mut upload_client = InnerJmapClient::new(stream, http_auth); + let mut tls: Tls = client.config.tls.clone().into(); + tls.rustls.alpn = vec!["http/1.1".into()]; + let http_auth = jmap_http_auth(client.config.auth.clone())?; + let mut upload_client = JmapClientStd::connect(&upload_url, &tls, http_auth)?; upload_client .blob_upload(&upload_url, "message/rfc822", data)? .blob_id diff --git a/src/jmap/mod.rs b/src/jmap/mod.rs index 2c84c302..238aa0a7 100644 --- a/src/jmap/mod.rs +++ b/src/jmap/mod.rs @@ -5,7 +5,6 @@ pub mod error; pub mod identity; pub mod mailbox; pub mod query; -pub mod session; pub mod submission; pub mod thread; pub mod vacation; diff --git a/src/jmap/session.rs b/src/jmap/session.rs deleted file mode 100644 index 66fe37a3..00000000 --- a/src/jmap/session.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Transitional JMAP session helper ported from `pimalaya-toolbox`. -//! -//! Will be replaced by `io_jmap::client::JmapClient` once the -//! protocol-specific subcommands switch over. - -use std::{ - io::{Read, Write}, - net::TcpStream, -}; - -use anyhow::{bail, Result}; -use base64::{prelude::BASE64_STANDARD, Engine}; -use io_jmap::rfc8620::{ - session::JmapSession as IoJmapSession, - session_get::{JmapSessionGet, JmapSessionGetResult}, -}; -use log::info; -use pimalaya_stream::std::{ - stream::Stream, - tls::{upgrade_tls, Tls}, -}; -use secrecy::{ExposeSecret, SecretString}; -use url::Url; - -const READ_BUFFER_SIZE: usize = 16 * 1024; - -/// Authentication for a JMAP session. -// https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes -#[derive(Clone, Debug)] -pub enum JmapAuth { - Header(SecretString), - /// Bearer token (OAuth 2.0). - Bearer(SecretString), - /// HTTP Basic authentication. - Basic { - username: String, - password: SecretString, - }, -} - -impl From for SecretString { - fn from(auth: JmapAuth) -> SecretString { - match auth { - JmapAuth::Header(auth) => auth, - JmapAuth::Bearer(token) => { - let token = token.expose_secret(); - format!("Bearer {token}").into() - } - JmapAuth::Basic { username, password } => { - let creds = format!("{}:{}", username, password.expose_secret()); - let creds = BASE64_STANDARD.encode(creds.into_bytes()); - format!("Basic {creds}").into() - } - } - } -} - -/// A live JMAP session over a TLS connection. -#[derive(Debug)] -pub struct JmapSession { - pub session: IoJmapSession, - pub stream: Stream, - pub http_auth: SecretString, -} - -fn use_tls(scheme: &str) -> bool { - scheme.eq_ignore_ascii_case("https") || scheme.eq_ignore_ascii_case("jmaps") -} - -fn default_port(scheme: &str) -> u16 { - if use_tls(scheme) { - 443 - } else { - 80 - } -} - -fn connect(url: &Url, tls: &Tls) -> Result { - let host = url.host_str().unwrap_or("localhost"); - let port = url.port().unwrap_or_else(|| default_port(url.scheme())); - let tcp = TcpStream::connect((host, port))?; - - if use_tls(url.scheme()) { - upgrade_tls(host, tcp, tls, &[b"http/1.1"]) - } else { - Ok(Stream::Tcp(tcp)) - } -} - -impl JmapSession { - /// Establishes a JMAP session. - pub fn new(server: String, tls: Tls, auth: JmapAuth) -> Result { - let url = match Url::parse(&server) { - Ok(url) => url, - Err(url::ParseError::RelativeUrlWithoutBase) => { - Url::parse(&format!("https://{server}"))? - } - Err(e) => return Err(e.into()), - }; - - info!("connecting to JMAP server {url}"); - - match url.scheme() { - s if s.eq_ignore_ascii_case("https") || s.eq_ignore_ascii_case("jmaps") => {} - s if s.eq_ignore_ascii_case("http") || s.eq_ignore_ascii_case("jmap") => {} - scheme => bail!("unsupported JMAP scheme `{scheme}`, expected http/https/jmap/jmaps"), - } - - let mut stream = connect(&url, &tls)?; - - let http_auth: SecretString = auth.into(); - let mut coroutine = JmapSessionGet::new(&http_auth, &url); - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut arg: Option<&[u8]> = None; - - let session = loop { - match coroutine.resume(arg.take()) { - JmapSessionGetResult::Ok { session, .. } => break session, - JmapSessionGetResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - JmapSessionGetResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - JmapSessionGetResult::WantsRedirect { url: new_url, .. } => { - stream = connect(&new_url, &tls)?; - coroutine = JmapSessionGet::new(&http_auth, &new_url); - arg = None; - } - JmapSessionGetResult::Err(err) => return Err(err.into()), - } - }; - - Ok(Self { - session, - stream, - http_auth, - }) - } -} diff --git a/src/maildir/message/get.rs b/src/maildir/message/get.rs index f52c9c0b..ccb218ec 100644 --- a/src/maildir/message/get.rs +++ b/src/maildir/message/get.rs @@ -2,7 +2,8 @@ use std::fmt; use anyhow::{bail, Result}; use clap::Parser; -use io_maildir::{maildir::Maildir, types::Message}; +use io_maildir::maildir::Maildir; +use mail_parser::Message; use pimalaya_cli::printer::Printer; use serde::Serialize; diff --git a/src/maildir/message/read.rs b/src/maildir/message/read.rs index 27486499..37a034e1 100644 --- a/src/maildir/message/read.rs +++ b/src/maildir/message/read.rs @@ -2,7 +2,8 @@ use std::fmt; use anyhow::{bail, Result}; use clap::Parser; -use io_maildir::{maildir::Maildir, types::Message}; +use io_maildir::maildir::Maildir; +use mail_parser::Message; use pimalaya_cli::printer::Printer; use serde::Serialize; diff --git a/src/main.rs b/src/main.rs index 5f76cb5e..3e89f5aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod account; +mod backend; mod cli; mod config; #[cfg(feature = "imap")] diff --git a/src/shared/client.rs b/src/shared/client.rs index 7241f27b..6129fd6d 100644 --- a/src/shared/client.rs +++ b/src/shared/client.rs @@ -1,151 +1,115 @@ //! Cross-protocol [`EmailClient`] for the shared subcommands //! (`mailboxes`, `envelopes`, `flags`, `messages`, `attachments`). //! -//! Wraps [`io_email::client::EmailClient`] and bundles the active +//! Wraps [`io_email::client::EmailClientStd`] and bundles the active //! [`Account`] (display, identity, composer/reader registries) the //! shared commands need alongside the I/O client. Implements //! [`Deref`]/[`DerefMut`] onto the inner client so callers can call //! its methods directly. //! //! Construction is backend-asymmetric (IMAP needs TLS + SASL, JMAP -//! needs an HTTP credential, Maildir just needs a root path). Each -//! `new_` constructor delegates to the transitional -//! [`ImapSession`] / [`JmapSession`] helpers for the handshake/auth -//! flow then bridges the resulting `(stream, context)` pairs into -//! [`io_imap::client::ImapClient`] / [`io_jmap::client::JmapClient`] -//! via their `from_parts` constructors. -//! -//! [`ImapSession`]: crate::imap::session::ImapSession -//! [`JmapSession`]: crate::jmap::session::JmapSession +//! needs an HTTP credential, Maildir just needs a root path). The +//! single [`EmailClient::new`] entry-point loads the configuration, +//! picks the merged account, then walks the configured backends in +//! `jmap → imap → maildir` order and opens the first one allowed by +//! the `BackendFlag`. -use std::{ - ops::{Deref, DerefMut}, - path::PathBuf, -}; +use std::ops::{Deref, DerefMut}; -use anyhow::{anyhow, bail, Result}; -use io_email::client::SendMessageOpts; -use pimalaya_config::toml::TomlConfig; +use anyhow::{bail, Result}; use crate::{ account::context::Account, - cli::{load_or_wizard, BackendFlag}, + backend::Backend, + config::{AccountConfig, Config}, }; pub struct EmailClient { - inner: io_email::client::EmailClient, + inner: io_email::client::EmailClientStd, pub account: Account, - /// Pre-computed options for [`io_email::client::EmailClient::send_message`]. - /// Populated by the per-protocol constructors with the bits each - /// backend needs (currently only the JMAP identity / drafts - /// mailbox ids); other fields are filled in at send time from the - /// outgoing message itself. - pub send_opts: SendMessageOpts, } impl EmailClient { /// Loads the configuration, picks the active account, builds the - /// merged [`Account`], then constructs an [`EmailClient`] for the - /// first backend allowed by `backend` that is configured on the - /// account. Selection order is `jmap → imap → maildir`. Bails when - /// no backend matches. + /// merged [`Account`], then opens the first backend allowed by + /// `backend` that is configured on the account. Selection order + /// is `jmap → imap → maildir`. Bails when no backend matches. pub fn new( - config_paths: &[PathBuf], - account_name: Option<&str>, - backend: BackendFlag, + config: Config, + mut account_config: AccountConfig, + backend: Backend, ) -> Result { - let mut config = load_or_wizard(config_paths)?; - let (_, mut ac) = config - .take_account(account_name)? - .ok_or_else(|| anyhow!("Cannot find account"))?; - #[cfg(feature = "jmap")] if backend.allows_jmap() { - if let Some(jmap_config) = ac.jmap.take() { - let account = Account::from(config).merge(Account::from(ac)); - return EmailClient::new_jmap(jmap_config, account); + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::client::EmailClientStd; + use io_jmap::client::JmapClientStd; + use pimalaya_stream::tls::Tls; + + use crate::jmap::client::{jmap_http_auth, parse_server_url}; + + let account = Account::from(config).merge(Account::from(account_config)); + let mut tls: Tls = jmap_config.tls.clone().into(); + tls.rustls.alpn = vec!["http/1.1".into()]; + let http_auth = jmap_http_auth(jmap_config.auth.clone())?; + let url = parse_server_url(&jmap_config.server)?; + let mut client = JmapClientStd::connect(&url, &tls, http_auth)?; + client.session_get(&url)?; + + return Ok(Self { + inner: EmailClientStd::Jmap(client), + account, + }); } } #[cfg(feature = "imap")] if backend.allows_imap() { - if let Some(imap_config) = ac.imap.take() { - let account = Account::from(config).merge(Account::from(ac)); - return EmailClient::new_imap(imap_config, account); + if let Some(imap_config) = account_config.imap.take() { + use io_email::client::EmailClientStd; + use io_imap::client::ImapClientStd; + use pimalaya_stream::{sasl::Sasl, std::stream::StreamStd, tls::Tls}; + + let account = Account::from(config).merge(Account::from(account_config)); + let mut tls: Tls = imap_config.tls.into(); + tls.rustls.alpn = vec!["imap".into()]; + let sasl: Sasl = imap_config.sasl.try_into()?; + let client = ImapClientStd::::connect( + &imap_config.url, + &tls, + imap_config.starttls, + Some(sasl), + )?; + + return Ok(Self { + inner: EmailClientStd::Imap(client), + account, + }); } } #[cfg(feature = "maildir")] if backend.allows_maildir() { - if let Some(maildir_config) = ac.maildir.take() { - let account = Account::from(config).merge(Account::from(ac)); - return EmailClient::new_maildir(maildir_config, account); + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::client::EmailClientStd; + use io_maildir::client::MaildirClient; + + let account = Account::from(config).merge(Account::from(account_config)); + let client = MaildirClient::new(maildir_config.root); + + return Ok(Self { + inner: EmailClientStd::Maildir(client), + account, + }); } } bail!("no backend matching `{backend}` is configured for this account") } - - #[cfg(feature = "imap")] - pub fn new_imap(config: crate::config::ImapConfig, account: Account) -> Result { - use io_imap::client::ImapClient; - - use crate::imap::session::ImapSession; - - let session = ImapSession::new( - config.url, - config.tls.try_into()?, - config.starttls, - config.sasl.try_into()?, - )?; - let client = ImapClient::from_parts(session.stream, session.context); - Ok(Self { - inner: client.into(), - account, - send_opts: SendMessageOpts::default(), - }) - } - - #[cfg(feature = "jmap")] - pub fn new_jmap(config: crate::config::JmapConfig, account: Account) -> Result { - use io_jmap::client::JmapClient; - - use crate::jmap::session::JmapSession; - - let send_opts = SendMessageOpts { - jmap_identity_id: config.identity_id.clone(), - jmap_drafts_mailbox_id: config.drafts_mailbox_id.clone(), - ..SendMessageOpts::default() - }; - - let session = JmapSession::new( - config.server, - config.tls.try_into()?, - config.auth.try_into()?, - )?; - let client = JmapClient::from_parts(session.stream, session.http_auth, session.session); - Ok(Self { - inner: client.into(), - account, - send_opts, - }) - } - - #[cfg(feature = "maildir")] - pub fn new_maildir(config: crate::config::MaildirConfig, account: Account) -> Result { - use io_maildir::client::MaildirClient; - - let client = MaildirClient::new(config.root); - Ok(Self { - inner: client.into(), - account, - send_opts: SendMessageOpts::default(), - }) - } } impl Deref for EmailClient { - type Target = io_email::client::EmailClient; + type Target = io_email::client::EmailClientStd; fn deref(&self) -> &Self::Target { &self.inner diff --git a/src/shared/messages/output.rs b/src/shared/messages/output.rs index 476fc01d..8eae0efa 100644 --- a/src/shared/messages/output.rs +++ b/src/shared/messages/output.rs @@ -8,7 +8,8 @@ use std::io::{stdout, Write}; -use anyhow::Result; +use anyhow::{anyhow, bail, Result}; +use mail_parser::{Address as ParserAddress, HeaderValue, MessageParser}; use pimalaya_cli::printer::{Message, Printer}; use crate::shared::client::EmailClient; @@ -35,10 +36,67 @@ pub fn route( } if send { - let opts = client.send_opts.clone(); - client.send_message(raw, opts)?; + let (from, to) = extract_envelope(&raw)?; + let to_refs: Vec<&str> = to.iter().map(String::as_str).collect(); + client.send_message(raw, &from, &to_refs)?; return printer.out(Message::new("Message successfully sent")); } printer.out(Message::new("Message saved")) } + +/// Extracts the envelope sender from `From:` and envelope recipients +/// from `To:` / `Cc:` / `Bcc:`. Returns an error when `From:` is +/// missing or no recipient header carries at least one address. +pub fn extract_envelope(raw: &[u8]) -> Result<(String, Vec)> { + let parsed = MessageParser::default() + .parse(raw) + .ok_or_else(|| anyhow!("failed to parse outgoing message"))?; + + let mut from_emails = Vec::new(); + if let Some(header) = parsed.header("From").cloned() { + if let HeaderValue::Address(addr) = header { + collect_emails(addr, &mut from_emails); + } + } + let from = from_emails + .into_iter() + .next() + .ok_or_else(|| anyhow!("outgoing message is missing a `From:` header"))?; + + let mut to = Vec::new(); + for name in ["To", "Cc", "Bcc"] { + if let Some(header) = parsed.header(name).cloned() { + if let HeaderValue::Address(addr) = header { + collect_emails(addr, &mut to); + } + } + } + + if to.is_empty() { + bail!("outgoing message has no recipients (`To:` / `Cc:` / `Bcc:`)"); + } + + Ok((from, to)) +} + +fn collect_emails(addr: ParserAddress<'_>, out: &mut Vec) { + match addr { + ParserAddress::List(list) => { + for a in list { + if let Some(email) = a.address { + out.push(email.into_owned()); + } + } + } + ParserAddress::Group(groups) => { + for g in groups { + for a in g.addresses { + if let Some(email) = a.address { + out.push(email.into_owned()); + } + } + } + } + } +} diff --git a/src/shared/messages/send.rs b/src/shared/messages/send.rs index 063582ae..e2911ca2 100644 --- a/src/shared/messages/send.rs +++ b/src/shared/messages/send.rs @@ -4,12 +4,13 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::shared::client::EmailClient; +use crate::shared::{client::EmailClient, messages::output::extract_envelope}; /// Send a message via the active account. /// -/// Supported over JMAP. JMAP requires `identity-id` and -/// `drafts-mailbox-id` to be set on the account's `[jmap]` config block. +/// Routes through SMTP or JMAP depending on the account's configured +/// outgoing backend. The envelope sender is taken from the `From:` +/// header and recipients are collected from `To:` / `Cc:` / `Bcc:`. #[derive(Debug, Parser)] pub struct MessageSendCommand { /// The raw message, including headers and body. @@ -34,8 +35,10 @@ impl MessageSendCommand { .join("\r\n") }; - let opts = client.send_opts.clone(); - client.send_message(raw.into_bytes(), opts)?; + let raw = raw.into_bytes(); + let (from, to) = extract_envelope(&raw)?; + let to_refs: Vec<&str> = to.iter().map(String::as_str).collect(); + client.send_message(raw, &from, &to_refs)?; printer.out(Message::new("Message successfully sent")) } } diff --git a/src/smtp/client.rs b/src/smtp/client.rs index c6607fc0..61740929 100644 --- a/src/smtp/client.rs +++ b/src/smtp/client.rs @@ -1,4 +1,4 @@ -//! Himalaya wrapper around [`io_smtp::client::SmtpClient`] that +//! Himalaya wrapper around [`io_smtp::client::SmtpClientStd`] that //! bundles the merged [`Account`] alongside the live SMTP client. //! //! Built up front by the dispatch layer (`crate::cli`) via @@ -7,41 +7,40 @@ //! context needs to follow the stream. use std::{ + net::Ipv4Addr, ops::{Deref, DerefMut}, path::PathBuf, }; use anyhow::{anyhow, Result}; -use io_smtp::client::SmtpClient as Inner; +use io_smtp::{client::SmtpClientStd as Inner, rfc5321::types::ehlo_domain::EhloDomain}; use pimalaya_config::toml::TomlConfig; +use pimalaya_stream::{sasl::Sasl, std::stream::StreamStd, tls::Tls}; -use crate::{ - account::context::Account, cli::load_or_wizard, config::SmtpConfig, smtp::session::SmtpSession, -}; +use crate::{account::context::Account, cli::load_or_wizard, config::SmtpConfig}; pub struct SmtpClient { - inner: Inner, + inner: Inner, #[allow(dead_code)] pub account: Account, } impl SmtpClient { /// Opens the SMTP connection (TCP/TLS/STARTTLS, greeting, EHLO, - /// SASL) then wraps the resulting stream alongside `account`. + /// SASL) then wraps the resulting client alongside `account`. pub fn new(config: SmtpConfig, account: Account) -> Result { - let session = SmtpSession::new( - config.url, - config.tls.try_into()?, - config.starttls, - config.sasl.try_into()?, - )?; - let inner = Inner::new(session.stream); + let mut tls: Tls = config.tls.into(); + tls.rustls.alpn = vec!["smtp".into()]; + let sasl: Sasl = config.sasl.try_into()?; + let domain: EhloDomain<'static> = Ipv4Addr::new(127, 0, 0, 1).into(); + let inner = + Inner::::connect(&config.url, &tls, config.starttls, domain, Some(sasl))?; Ok(Self { inner, account }) } } impl Deref for SmtpClient { - type Target = Inner; + type Target = Inner; fn deref(&self) -> &Self::Target { &self.inner diff --git a/src/smtp/mod.rs b/src/smtp/mod.rs index 813e0c6d..517920ca 100644 --- a/src/smtp/mod.rs +++ b/src/smtp/mod.rs @@ -1,4 +1,3 @@ pub mod cli; pub mod client; pub mod message; -pub mod session; diff --git a/src/smtp/session.rs b/src/smtp/session.rs deleted file mode 100644 index 8177381f..00000000 --- a/src/smtp/session.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! Transitional SMTP session helper ported from `pimalaya-stream`. -//! -//! Will be replaced by `io_smtp::client::SmtpClient` once the -//! protocol-specific subcommands switch over. - -#[cfg(unix)] -use std::os::unix::net::UnixStream; -use std::{ - io::{Read, Write}, - net::{Ipv4Addr, TcpStream}, -}; - -use anyhow::{bail, Result}; -use io_smtp::{ - login::{SmtpLogin, SmtpLoginResult}, - rfc3207::starttls::{SmtpStartTls, SmtpStartTlsResult}, - rfc4616::plain::{SmtpPlain, SmtpPlainResult}, - rfc5321::{ - ehlo::{SmtpEhlo, SmtpEhloResult}, - greeting::{GetSmtpGreeting, GetSmtpGreetingResult}, - types::ehlo_domain::EhloDomain, - }, -}; -use log::info; -use pimalaya_stream::{ - sasl::{Sasl, SaslMechanism}, - std::{ - stream::Stream, - tls::{upgrade_tls, Tls}, - }, -}; -#[cfg(windows)] -use uds_windows::UnixStream; -use url::Url; - -const READ_BUFFER_SIZE: usize = 8 * 1024; - -#[derive(Debug)] -pub struct SmtpSession { - pub stream: Stream, -} - -fn drive_greeting(stream: &mut S) -> Result<()> { - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = GetSmtpGreeting::new(); - let mut arg: Option<&[u8]> = None; - - loop { - match coroutine.resume(arg.take()) { - GetSmtpGreetingResult::Ok { .. } => return Ok(()), - GetSmtpGreetingResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - GetSmtpGreetingResult::Err(err) => bail!(err), - } - } -} - -fn drive_ehlo(stream: &mut S, domain: EhloDomain<'_>) -> Result<()> { - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = SmtpEhlo::new(domain); - let mut arg: Option<&[u8]> = None; - - loop { - match coroutine.resume(arg.take()) { - SmtpEhloResult::Ok { .. } => return Ok(()), - SmtpEhloResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - SmtpEhloResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - SmtpEhloResult::Err(err) => bail!(err), - } - } -} - -fn drive_starttls(stream: &mut S) -> Result<()> { - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = SmtpStartTls::new(); - let mut arg: Option<&[u8]> = None; - - loop { - match coroutine.resume(arg.take()) { - SmtpStartTlsResult::WantsStartTls(_) => return Ok(()), - SmtpStartTlsResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - SmtpStartTlsResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - SmtpStartTlsResult::Err(err) => bail!(err), - } - } -} - -impl SmtpSession { - pub fn new(url: Url, tls: Tls, starttls: bool, mut sasl: Sasl) -> Result { - info!("connecting to SMTP server using {url}"); - - let host = url.host_str().unwrap_or("127.0.0.1"); - let domain: EhloDomain<'static> = Ipv4Addr::new(127, 0, 0, 1).into(); - - let mut stream = match url.scheme() { - scheme if scheme.eq_ignore_ascii_case("smtp") => { - let port = url.port().unwrap_or(25); - let mut tcp = TcpStream::connect((host, port))?; - - drive_greeting(&mut tcp)?; - drive_ehlo(&mut tcp, domain.clone())?; - - Stream::Tcp(tcp) - } - scheme if scheme.eq_ignore_ascii_case("smtps") => { - let default_port = if starttls { 587 } else { 465 }; - let port = url.port().unwrap_or(default_port); - let mut tcp = TcpStream::connect((host, port))?; - - if starttls { - drive_greeting(&mut tcp)?; - drive_ehlo(&mut tcp, domain.clone())?; - drive_starttls(&mut tcp)?; - } - - let mut stream = upgrade_tls(host, tcp, &tls, &[b"smtp"])?; - - if !starttls { - drive_greeting(&mut stream)?; - } - - drive_ehlo(&mut stream, domain.clone())?; - - stream - } - scheme if scheme.eq_ignore_ascii_case("unix") => { - let sock_path = url.path(); - let mut unix = UnixStream::connect(sock_path)?; - - drive_greeting(&mut unix)?; - drive_ehlo(&mut unix, domain.clone())?; - - Stream::Unix(unix) - } - scheme => { - bail!("Unknown scheme {scheme}, expected smtp, smtps or unix"); - } - }; - - let mechanism = sasl - .mechanism - .or(Some(SaslMechanism::Plain).filter(|_| sasl.plain.is_some())) - .or(Some(SaslMechanism::Login).filter(|_| sasl.login.is_some())); - - match mechanism { - None => bail!("no SASL mechanism configured"), - Some(SaslMechanism::Login) => { - let Some(auth) = sasl.login.take() else { - bail!("missing SASL LOGIN configuration"); - }; - - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = SmtpLogin::new(&auth.username, &auth.password, domain.clone()); - let mut arg: Option<&[u8]> = None; - - loop { - match coroutine.resume(arg.take()) { - SmtpLoginResult::Ok => break, - SmtpLoginResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - SmtpLoginResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - SmtpLoginResult::Err(err) => bail!(err), - } - } - } - Some(SaslMechanism::Plain) => { - let Some(auth) = sasl.plain.take() else { - bail!("missing SASL PLAIN configuration"); - }; - - let mut buf = [0u8; READ_BUFFER_SIZE]; - let mut coroutine = SmtpPlain::new(&auth.authcid, &auth.passwd, domain.clone()); - let mut arg: Option<&[u8]> = None; - - loop { - match coroutine.resume(arg.take()) { - SmtpPlainResult::Ok => break, - SmtpPlainResult::WantsRead => { - let n = stream.read(&mut buf)?; - arg = Some(&buf[..n]); - } - SmtpPlainResult::WantsWrite(bytes) => { - stream.write_all(&bytes)?; - arg = None; - } - SmtpPlainResult::Err(err) => bail!(err), - } - } - } - Some(SaslMechanism::Anonymous) => { - unimplemented!("ANONYMOUS SASL mechanism not yet implemented") - } - } - - Ok(Self { stream }) - } -} diff --git a/src/wizard.rs b/src/wizard.rs index 036f488c..5496ef91 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -8,9 +8,9 @@ //! //! 1. Confirm with the user. Exit if they decline. //! 2. Ask for an account name and email address. -//! 3. Run discovery sequentially — PACC first, then Mozilla -//! Autoconfig — with one spinner per method. Sub-step messages -//! track which URL is being probed. +//! 3. Try PACC, then Autoconfig ISP main / fallback / ISPDB (secure +//! variants only) in series. Each probe owns its own spinner; +//! first success wins. //! 4. If PACC returned a JMAP endpoint, ask the user whether to use //! it instead of IMAP+SMTP and run the matching protocol wizard(s). //! 5. Build a [`Config`], write it to `target`, return it. @@ -20,14 +20,10 @@ use std::{collections::HashMap, path::Path, process::exit, process::Command}; use anyhow::{anyhow, bail, Result}; use io_discovery::{ autoconfig::{ - client::DiscoveryAutoconfigClient, - coroutines::{dns_mx::mx_parent_domain, isp::DiscoveryIsp}, + client::DiscoveryAutoconfigClientStd, types::{Autoconfig, SecurityType, Server, ServerType}, }, - pacc::{ - client::{DiscoveryPaccClient, DiscoveryPaccClientError}, - types::PaccConfig, - }, + pacc::{client::DiscoveryPaccClientStd, types::PaccConfig}, }; use log::{debug, info}; use pimalaya_cli::{ @@ -46,6 +42,7 @@ use pimalaya_cli::{ }, }; use pimalaya_config::{command::shell, secret::Secret}; +use pimalaya_stream::tls::Tls; use url::Url; use crate::config::{ @@ -58,6 +55,15 @@ use crate::config::{ /// later. const DEFAULT_RESOLVER: &str = "tcp://1.1.1.1:53"; +/// Builds the [`Tls`] profile passed to the per-mechanism discovery +/// clients via `with_tls`. Discovery only speaks HTTPS to `_well-known` +/// endpoints, so `http/1.1` is the only ALPN protocol we offer. +fn discovery_tls() -> Tls { + let mut tls = Tls::default(); + tls.rustls.alpn = vec!["http/1.1".into()]; + tls +} + pub fn run_or_exit(target: &Path) -> Result { let prompt = format!( "No configuration found. Create one at {}?", @@ -94,68 +100,91 @@ pub fn run_or_exit(target: &Path) -> Result { Ok(config) } +#[derive(Default)] struct DiscoveryResult { + jmap: Option, imap: Option, smtp: Option, - jmap: Option, } -/// Drives PACC then Mozilla Autoconfig sequentially, each with its -/// own spinner. PACC values win on overlap; Autoconfig only fills -/// IMAP/SMTP fields PACC didn't yield. JMAP is PACC-only. +/// Tries PACC, then Autoconfig ISP main / fallback / ISPDB (secure +/// variants only) in series; each probe owns its own spinner and +/// reports its own success or failure line. First hit wins. The +/// returned `DiscoveryResult` is empty when every mechanism failed; +/// the caller falls back to pure manual entry in that case. fn discover(local_part: &str, domain: &str) -> DiscoveryResult { - let pacc = run_pacc_with_spinner(domain); - let autoconfig = run_autoconfig_with_spinner(local_part, domain); + if let Some(config) = run_pacc(domain) { + let (imap, smtp, jmap) = pacc_defaults(&config); + if imap.is_some() || smtp.is_some() || jmap.is_some() { + return DiscoveryResult { imap, smtp, jmap }; + } + } - let (pacc_imap, pacc_smtp, pacc_jmap) = pacc - .as_ref() - .map(pacc_defaults) - .unwrap_or((None, None, None)); - let (ac_imap, ac_smtp) = autoconfig + let (imap, smtp) = run_autoconfig(local_part, domain) .as_ref() .map(autoconfig_defaults) .unwrap_or((None, None)); DiscoveryResult { - imap: pacc_imap.or(ac_imap), - smtp: pacc_smtp.or(ac_smtp), - jmap: pacc_jmap, + imap, + smtp, + jmap: None, } } -fn run_pacc_with_spinner(domain: &str) -> Option { +fn discovery_resolver() -> Url { + DEFAULT_RESOLVER + .parse() + .expect("DEFAULT_RESOLVER must be a valid URL") +} + +fn run_pacc(domain: &str) -> Option { let spinner = Spinner::start(format!("Probing PACC for {domain}…")); + let mut client = DiscoveryPaccClientStd::new(discovery_resolver()).with_tls(discovery_tls()); - let resolver: Url = match DEFAULT_RESOLVER.parse() { - Ok(url) => url, - Err(err) => { - debug!("PACC: invalid default resolver `{DEFAULT_RESOLVER}`: {err}"); - spinner.failure(format!("PACC: invalid resolver `{DEFAULT_RESOLVER}`")); - return None; - } - }; - - spinner.set_message(format!( - "PACC: fetching .well-known config from ua-auto-config.{domain} and verifying digest…" - )); - - let mut client = DiscoveryPaccClient::new(resolver); match client.discover(domain) { Ok(config) => { spinner.success(pacc_summary(domain, &config)); Some(config) } - Err(DiscoveryPaccClientError::Discovery(err)) => { + Err(err) => { debug!("PACC discovery for {domain} failed: {err}"); spinner.failure(format!("PACC: no valid configuration for {domain}")); None } - Err(err) => { - debug!("PACC transport error for {domain}: {err}"); - spinner.failure(format!("PACC: endpoint unreachable for {domain}")); - None + } +} + +fn run_autoconfig(local_part: &str, domain: &str) -> Option { + let mut client = + DiscoveryAutoconfigClientStd::new(discovery_resolver()).with_tls(discovery_tls()); + + let attempts: [(&str, &dyn Fn(&mut DiscoveryAutoconfigClientStd) -> _); 3] = [ + ("Autoconfig ISP main URL", &|c| { + c.isp(local_part, domain, true) + }), + ("Autoconfig ISP fallback URL", &|c| { + c.isp_fallback(domain, true) + }), + ("Thunderbird ISPDB", &|c| c.ispdb(domain, true)), + ]; + + for (label, run) in attempts { + let spinner = Spinner::start(format!("Probing {label} for {domain}…")); + + match run(&mut client) { + Ok(config) => { + spinner.success(autoconfig_summary(domain, &config)); + return Some(config); + } + Err(err) => { + debug!("{label} for {domain} failed: {err}"); + spinner.failure(format!("{label}: not available for {domain}")); + } } } + + None } fn pacc_summary(domain: &str, config: &PaccConfig) -> String { @@ -177,92 +206,6 @@ fn pacc_summary(domain: &str, config: &PaccConfig) -> String { } } -/// Tries the Mozilla Autoconfig chain — direct ISP URLs, then -/// MX-derived parent domain ISP URLs. The TXT mailconf and SRV -/// fallbacks from the autoconfig CLI are skipped here; we keep the -/// wizard fast and let manual entry handle the long tail. The -/// spinner message is updated for every sub-attempt so the user sees -/// which URL is currently being probed. -fn run_autoconfig_with_spinner(local_part: &str, domain: &str) -> Option { - let spinner = Spinner::start(format!("Probing Mozilla Autoconfig for {domain}…")); - - let resolver: Url = match DEFAULT_RESOLVER.parse() { - Ok(url) => url, - Err(err) => { - debug!("Autoconfig: invalid default resolver `{DEFAULT_RESOLVER}`: {err}"); - spinner.failure(format!("Autoconfig: invalid resolver `{DEFAULT_RESOLVER}`")); - return None; - } - }; - - let mut client = DiscoveryAutoconfigClient::new(resolver); - - if let Some(ac) = try_isp_urls_with_spinner(&spinner, &mut client, local_part, domain) { - spinner.success(autoconfig_summary(domain, &ac)); - return Some(ac); - } - - spinner.set_message(format!("Autoconfig: looking up MX records for {domain}…")); - - let mx_parent = match client.mx(domain) { - Ok(records) => records - .first() - .map(|r| r.rdata.exchange.to_string()) - .and_then(|t| mx_parent_domain(&t)) - .filter(|d| d != domain), - Err(err) => { - debug!("Autoconfig MX lookup for {domain} failed: {err}"); - None - } - }; - - if let Some(parent) = mx_parent { - debug!("Autoconfig: re-trying ISPs against MX parent {parent}"); - if let Some(ac) = try_isp_urls_with_spinner(&spinner, &mut client, local_part, &parent) { - spinner.success(autoconfig_summary(domain, &ac)); - return Some(ac); - } - } - - spinner.failure(format!( - "Autoconfig: no provider configuration found for {domain}" - )); - None -} - -const ISP_LABELS: [&str; 5] = [ - "ISP main URL (HTTPS)", - "ISP main URL (HTTP)", - "ISP well-known URL (HTTPS)", - "ISP well-known URL (HTTP)", - "Thunderbird ISPDB", -]; - -fn try_isp_urls_with_spinner( - spinner: &Spinner, - client: &mut DiscoveryAutoconfigClient, - local_part: &str, - domain: &str, -) -> Option { - let urls = match DiscoveryIsp::all_urls(local_part, domain) { - Ok(urls) => urls, - Err(err) => { - debug!("Autoconfig: cannot build ISP URLs for {domain}: {err}"); - return None; - } - }; - - for (url, label) in urls.iter().zip(ISP_LABELS.iter()) { - spinner.set_message(format!("Autoconfig: trying {label} for {domain}…")); - match client.isp(url.clone()) { - Ok(ac) => return Some(ac), - Err(err) => debug!("Autoconfig ISP attempt at {url} failed: {err}"), - } - } - - None -} - fn autoconfig_summary(domain: &str, ac: &Autoconfig) -> String { let has_imap = ac .email_provider @@ -274,13 +217,17 @@ fn autoconfig_summary(domain: &str, ac: &Autoconfig) -> String { .outgoing_server .iter() .any(|s| matches!(s.r#type, ServerType::Smtp)); + let mut protos = Vec::with_capacity(2); + if has_imap { protos.push("IMAP"); } + if has_smtp { protos.push("SMTP"); } + if protos.is_empty() { format!("Autoconfig: configuration found for {domain} (no IMAP/SMTP fields)") } else {