mirror of
https://github.com/pimalaya/himalaya.git
synced 2026-06-15 11:27:53 +08:00
clean part 3
This commit is contained in:
Generated
+53
-34
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -5,4 +5,3 @@ pub mod flag;
|
||||
pub mod id;
|
||||
pub mod mailbox;
|
||||
pub mod message;
|
||||
pub mod session;
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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,4 +1,5 @@
|
||||
mod account;
|
||||
mod backend;
|
||||
mod cli;
|
||||
mod config;
|
||||
#[cfg(feature = "imap")]
|
||||
|
||||
+68
-104
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,4 +1,3 @@
|
||||
pub mod cli;
|
||||
pub mod client;
|
||||
pub mod message;
|
||||
pub mod session;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user