clean part 3

This commit is contained in:
Clément DOUIN
2026-05-19 01:01:44 +02:00
parent cd27969e14
commit 5500b02cfc
25 changed files with 567 additions and 1125 deletions
Generated
+53 -34
View File
@@ -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",
]
+13 -16
View File
@@ -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"
+34 -18
View File
@@ -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::<StreamStd>::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::<StreamStd>::connect(
&smtp_config.url,
&tls,
smtp_config.starttls,
smtp_config.sasl.clone().try_into()?,
domain,
Some(sasl),
)?;
Ok(())
})();
+2 -2
View File
@@ -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),
+72
View File
@@ -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<Self, Self::Err> {
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"),
}
}
}
+38 -89
View File
@@ -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<Config> {
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<Self, Self::Err> {
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<Option<Config>>` → `Config`
/// adaptation so call sites stay readable.
pub(crate) fn load_or_wizard(paths: &[PathBuf]) -> Result<Config> {
match Config::from_paths_or_default(paths)? {
Some(config) => Ok(config),
None => wizard::run_or_exit(&Config::target_path(paths)?),
}
}
+47 -55
View File
@@ -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<TlsConfig> for Tls {
type Error = SecretError;
fn try_from(config: TlsConfig) -> Result<Self, Self::Error> {
Ok(Tls {
impl From<TlsConfig> 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<TlsConfig> 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<SaslConfig> for Sasl {
type Error = SecretError;
type Error = anyhow::Error;
fn try_from(config: SaslConfig) -> Result<Self, Self::Error> {
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<Self> {
// 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<JmapAuthConfig> for crate::jmap::session::JmapAuth {
type Error = pimalaya_config::secret::SecretError;
fn try_from(config: JmapAuthConfig) -> Result<Self, Self::Error> {
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()?,
}),
}
}
}
+11 -16
View File
@@ -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<StreamStd>,
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<Self> {
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::<StreamStd>::connect(&config.url, &tls, config.starttls, Some(sasl))?;
Ok(Self { inner, account })
}
}
impl Deref for ImapClient {
type Target = Inner;
type Target = Inner<StreamStd>;
fn deref(&self) -> &Self::Target {
&self.inner
-1
View File
@@ -5,4 +5,3 @@ pub mod flag;
pub mod id;
pub mod mailbox;
pub mod message;
pub mod session;
-238
View File
@@ -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<S: Read + Write>(
stream: &mut S,
context: ImapContext,
) -> Result<ImapContext> {
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<S: Read + Write>(stream: &mut S, context: ImapContext) -> Result<ImapContext> {
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<S: Read + Write>(stream: &mut S, context: ImapContext) -> Result<ImapContext> {
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<Self> {
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 })
}
}
+48 -9
View File
@@ -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<Self> {
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<Url> {
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<SecretString> {
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())
}
}
}
+7 -15
View File
@@ -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)?
};
+10 -14
View File
@@ -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<u8> = 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
-1
View File
@@ -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;
-142
View File
@@ -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<JmapAuth> 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<Stream> {
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<Self> {
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,
})
}
}
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+1
View File
@@ -1,4 +1,5 @@
mod account;
mod backend;
mod cli;
mod config;
#[cfg(feature = "imap")]
+68 -104
View File
@@ -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_<protocol>` 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<Self> {
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::<StreamStd>::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<Self> {
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<Self> {
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<Self> {
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
+61 -3
View File
@@ -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<String>)> {
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<String>) {
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());
}
}
}
}
}
}
+8 -5
View File
@@ -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"))
}
}
+14 -15
View File
@@ -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<StreamStd>,
#[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<Self> {
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::<StreamStd>::connect(&config.url, &tls, config.starttls, domain, Some(sasl))?;
Ok(Self { inner, account })
}
}
impl Deref for SmtpClient {
type Target = Inner;
type Target = Inner<StreamStd>;
fn deref(&self) -> &Self::Target {
&self.inner
-1
View File
@@ -1,4 +1,3 @@
pub mod cli;
pub mod client;
pub mod message;
pub mod session;
-216
View File
@@ -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<S: Read + Write>(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<S: Read + Write>(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<S: Read + Write>(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<Self> {
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 })
}
}
+76 -129
View File
@@ -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<Config> {
let prompt = format!(
"No configuration found. Create one at {}?",
@@ -94,68 +100,91 @@ pub fn run_or_exit(target: &Path) -> Result<Config> {
Ok(config)
}
#[derive(Default)]
struct DiscoveryResult {
jmap: Option<WizardJmapConfig>,
imap: Option<WizardImapConfig>,
smtp: Option<WizardSmtpConfig>,
jmap: Option<WizardJmapConfig>,
}
/// 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<PaccConfig> {
fn discovery_resolver() -> Url {
DEFAULT_RESOLVER
.parse()
.expect("DEFAULT_RESOLVER must be a valid URL")
}
fn run_pacc(domain: &str) -> Option<PaccConfig> {
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<Autoconfig> {
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<Autoconfig> {
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<Autoconfig> {
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 {