From e377aede15cc8ab10e4f95132685b50a2465d9e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Mon, 1 Jun 2026 20:47:41 +0200 Subject: [PATCH] feat: make alpn customizable per protocol Refs: #670 --- Cargo.lock | 18 +++++++++--------- config.sample.toml | 15 +++++++++++++++ src/account/check.rs | 14 +++++--------- src/config.rs | 38 ++++++++++++++++++++++++++++++++------ src/imap/client.rs | 5 ++--- src/jmap/client.rs | 4 +--- src/jmap/email/export.rs | 8 +++++--- src/jmap/email/import.rs | 8 +++++--- src/shared/client.rs | 16 +++++----------- src/smtp/client.rs | 5 ++--- src/wizard/account.rs | 3 +++ 11 files changed, 84 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73ef8273..5d3cbc0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,7 +1078,7 @@ dependencies = [ [[package]] name = "io-email" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-email#5d01da7acb98d8394077d40780f1cded639f4574" +source = "git+https://github.com/pimalaya/io-email#050738cbaf9e177683c7e4eeeb8b2ef145c4abe2" dependencies = [ "chrono", "chumsky", @@ -1103,7 +1103,7 @@ dependencies = [ [[package]] name = "io-http" version = "0.0.3" -source = "git+https://github.com/pimalaya/io-http#73b264b85209d86de48afed25957ab582b54c272" +source = "git+https://github.com/pimalaya/io-http#23f3668558059c2db3603b1afdda5ce0f840e46a" dependencies = [ "anyhow", "base64", @@ -1119,7 +1119,7 @@ dependencies = [ [[package]] name = "io-imap" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-imap#518da423e551684fa8202a9cc4fede99f1be3f23" +source = "git+https://github.com/pimalaya/io-imap#5c915ae5536e5e5ac48f3284c1827111a71939f7" dependencies = [ "anyhow", "base64", @@ -1134,7 +1134,7 @@ dependencies = [ [[package]] name = "io-jmap" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-jmap#2463ecb104d0ff24b8c3ba2f4eaa09562ce5a1cd" +source = "git+https://github.com/pimalaya/io-jmap#18103bdbf883798b48566f6d45e4dfed8458d0e6" dependencies = [ "anyhow", "io-http", @@ -1170,7 +1170,7 @@ dependencies = [ [[package]] name = "io-smtp" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-smtp#3b8cf0d806c23339a46a540e159de3d4cd9898f3" +source = "git+https://github.com/pimalaya/io-smtp#8ade022516b83358d66772b74bd42216763b5ec3" dependencies = [ "anyhow", "base64", @@ -1664,7 +1664,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pimalaya-cli" version = "0.0.1" -source = "git+https://github.com/pimalaya/cli#313c2e999ea9157fa42c84a6ae867451d8308114" +source = "git+https://github.com/pimalaya/cli#64cd78d702ed9251021f4ed8f549ac262b0e6985" dependencies = [ "anyhow", "clap", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "pimalaya-stream" version = "0.0.1" -source = "git+https://github.com/pimalaya/stream#6449136364c9ef81248e3b4ab6491dd154293481" +source = "git+https://github.com/pimalaya/stream#152911be92647ae47c2052c1bf1270f02eefbdd1" dependencies = [ "anyhow", "log", @@ -2484,9 +2484,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" diff --git a/config.sample.toml b/config.sample.toml index c5d14cba..dad06709 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -165,6 +165,11 @@ imap.server = "example.com" # Enable STARTTLS (only valid when the server resolves to `imap://`). #imap.starttls = false +# ALPN protocol identifiers offered during the TLS handshake (rustls only; +# native-tls ignores it). Defaults to ["imap"]; set to [] to skip ALPN. +#imap.alpn = ["imap"] +#imap.alpn = [] + # Pick exactly one SASL mechanism among `anonymous`, `login`, `plain`, # `oauthbearer`, `xoauth2`, `scram-sha-256`. Omit the whole `imap.sasl` table # to skip authentication entirely (no `AUTHENTICATE` command sent). @@ -231,6 +236,11 @@ imap.sasl.plain.passwd.raw = "***" #jmap.tls.rustls.crypto = "ring" #jmap.tls.cert = "/path/to/custom/cert.pem" +# ALPN protocol identifiers offered during the TLS handshake. Defaults to +# ["http/1.1"] (JMAP rides on HTTP/1.1); set to [] to skip ALPN. +#jmap.alpn = ["http/1.1"] +#jmap.alpn = [] + # Pick exactly one of `header`, `bearer`, `basic`. # Raw `Authorization` header value, used verbatim. @@ -289,6 +299,11 @@ smtp.server = "example.com" # Enable STARTTLS (only valid when the server resolves to `smtp://`). #smtp.starttls = false +# ALPN protocol identifiers offered during the TLS handshake. Defaults to +# ["smtp"]; set to [] to skip ALPN. +#smtp.alpn = ["smtp"] +#smtp.alpn = [] + # Pick exactly one SASL mechanism among `anonymous`, `login`, `plain`, # `oauthbearer`, `xoauth2`, `scram-sha-256`. Omit the whole `smtp.sasl` table # to skip authentication entirely. diff --git a/src/account/check.rs b/src/account/check.rs index e7b51706..f54c76c8 100644 --- a/src/account/check.rs +++ b/src/account/check.rs @@ -114,13 +114,12 @@ fn check_imap( imap_config: crate::config::ImapConfig, ) -> BackendCheck { use io_imap::client::ImapClientStd; - use pimalaya_stream::{sasl::Sasl, tls::Tls}; + use pimalaya_stream::sasl::Sasl; use crate::imap::id::resolve_auto_id_params; let result = (|| -> Result<()> { - let mut tls: Tls = imap_config.tls.clone().into(); - tls.rustls.alpn = vec!["imap".into()]; + let tls = imap_config.tls.clone().into_tls(imap_config.alpn.clone()); let sasl: Option = imap_config.sasl.clone().map(Sasl::try_from).transpose()?; let auto_id = resolve_auto_id_params(&imap_config.id)?; let server = crate::imap::client::parse_imap_server(&imap_config.server)?; @@ -138,13 +137,11 @@ fn check_jmap( jmap_config: crate::config::JmapConfig, ) -> BackendCheck { use io_jmap::client::JmapClientStd; - use pimalaya_stream::tls::Tls; use crate::jmap::client::{jmap_http_auth, parse_server_url}; let result = (|| -> Result<()> { - let mut tls: Tls = jmap_config.tls.clone().into(); - tls.rustls.alpn = vec!["http/1.1".into()]; + let tls = jmap_config.tls.clone().into_tls(jmap_config.alpn.clone()); 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)?; @@ -183,11 +180,10 @@ fn check_smtp( use std::net::Ipv4Addr; use io_smtp::{client::SmtpClientStd, rfc5321::types::ehlo_domain::EhloDomain}; - use pimalaya_stream::{sasl::Sasl, tls::Tls}; + use pimalaya_stream::sasl::Sasl; let result = (|| -> Result<()> { - let mut tls: Tls = smtp_config.tls.clone().into(); - tls.rustls.alpn = vec!["smtp".into()]; + let tls = smtp_config.tls.clone().into_tls(smtp_config.alpn.clone()); let sasl: Option = smtp_config.sasl.clone().map(Sasl::try_from).transpose()?; let domain: EhloDomain<'static> = Ipv4Addr::new(127, 0, 0, 1).into(); let server = crate::smtp::client::parse_smtp_server(&smtp_config.server)?; diff --git a/src/config.rs b/src/config.rs index 8eaa86a3..509890c2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -331,6 +331,13 @@ pub struct ImapConfig { #[serde(default)] pub starttls: bool, + /// ALPN protocol identifiers offered during the TLS handshake. + /// Defaults to `["imap"]` (RFC 7595, IANA registry). Set to `[]` + /// to skip ALPN negotiation entirely. Only relevant for the + /// rustls provider; `native-tls` ignores ALPN. + #[serde(default = "io_imap::client::default_alpn")] + pub alpn: Vec, + /// Optional SASL credentials. When omitted, the connection skips /// authentication entirely (no `AUTHENTICATE` command is sent); /// to advertise the ANONYMOUS mechanism explicitly, set @@ -397,6 +404,13 @@ pub struct SmtpConfig { #[serde(default)] pub starttls: bool, + /// ALPN protocol identifiers offered during the TLS handshake. + /// Defaults to `["smtp"]` (RFC 7595, IANA registry). Set to `[]` + /// to skip ALPN negotiation entirely. Only relevant for the + /// rustls provider; `native-tls` ignores ALPN. + #[serde(default = "io_smtp::client::default_alpn")] + pub alpn: Vec, + /// Optional SASL credentials. See [`ImapConfig::sasl`]. pub sasl: Option, } @@ -434,21 +448,26 @@ pub enum RustlsCryptoConfig { Ring, } -impl From for Tls { - fn from(config: TlsConfig) -> Self { +impl TlsConfig { + /// Builds the runtime [`Tls`] handle the connect helpers expect. + /// `alpn` is the protocol-level ALPN list (e.g. `["imap"]`, + /// `["smtp"]`, `["http/1.1"]`); pass an empty vec to skip ALPN. + /// The TOML schema never exposes `tls.rustls.alpn` directly: the + /// per-protocol `*.alpn` field is folded in here. + pub fn into_tls(self, alpn: Vec) -> Tls { Tls { - provider: config.provider.map(|config| match config { + provider: self.provider.map(|p| match p { TlsProviderConfig::Rustls => TlsProvider::Rustls, TlsProviderConfig::NativeTls => TlsProvider::NativeTls, }), rustls: Rustls { - crypto: config.rustls.crypto.map(|config| match config { + crypto: self.rustls.crypto.map(|c| match c { RustlsCryptoConfig::Aws => RustlsCrypto::Aws, RustlsCryptoConfig::Ring => RustlsCrypto::Ring, }), - alpn: Vec::new(), + alpn, }, - cert: config.cert, + cert: self.cert, } } } @@ -590,6 +609,13 @@ pub struct JmapConfig { #[serde(default)] pub tls: TlsConfig, + /// ALPN protocol identifiers offered during the TLS handshake. + /// Defaults to `["http/1.1"]` (JMAP rides on HTTP/1.1). Set to + /// `[]` to skip ALPN negotiation entirely. Only relevant for the + /// rustls provider; `native-tls` ignores ALPN. + #[serde(default = "io_jmap::client::default_alpn")] + pub alpn: Vec, + /// Authentication configuration. pub auth: JmapAuthConfig, diff --git a/src/imap/client.rs b/src/imap/client.rs index 7a73dc71..3615b0a5 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -30,7 +30,7 @@ use std::{ use anyhow::{Result, anyhow}; use io_imap::client::ImapClientStd as Inner; use pimalaya_config::toml::TomlConfig; -use pimalaya_stream::{sasl::Sasl, tls::Tls}; +use pimalaya_stream::sasl::Sasl; use url::Url; use crate::{ @@ -48,8 +48,7 @@ impl ImapClient { /// discarded; IMAP-specific subcommands that need it should call /// [`Inner::capability`] explicitly. pub fn new(config: ImapConfig) -> Result { - let mut tls: Tls = config.tls.into(); - tls.rustls.alpn = vec!["imap".into()]; + let tls = config.tls.into_tls(config.alpn); let sasl: Option = config.sasl.map(Sasl::try_from).transpose()?; let auto_id = resolve_auto_id_params(&config.id)?; let server = parse_imap_server(&config.server)?; diff --git a/src/jmap/client.rs b/src/jmap/client.rs index b76d62a4..e73a5a60 100644 --- a/src/jmap/client.rs +++ b/src/jmap/client.rs @@ -31,7 +31,6 @@ use anyhow::{Result, anyhow}; use base64::{Engine, prelude::BASE64_STANDARD}; use io_jmap::client::JmapClientStd as Inner; use pimalaya_config::toml::TomlConfig; -use pimalaya_stream::tls::Tls; use secrecy::{ExposeSecret, SecretString}; use url::Url; @@ -54,8 +53,7 @@ impl JmapClient { /// Establishes the JMAP session (TLS, `/.well-known/jmap` /// discovery). pub fn new(config: JmapConfig) -> Result { - let mut tls: Tls = config.tls.clone().into(); - tls.rustls.alpn = vec!["http/1.1".into()]; + let tls = config.tls.clone().into_tls(config.alpn.clone()); let http_auth = jmap_http_auth(config.auth.clone())?; let url = parse_server_url(&config.server)?; diff --git a/src/jmap/email/export.rs b/src/jmap/email/export.rs index 789eeacb..8ab377f2 100644 --- a/src/jmap/email/export.rs +++ b/src/jmap/email/export.rs @@ -22,7 +22,6 @@ use io_jmap::{ rfc8621::{capabilities::MAIL, email::EmailProperty}, }; use pimalaya_cli::printer::{Message, Printer}; -use pimalaya_stream::tls::Tls; use url::Url; use crate::jmap::client::{JmapClient, jmap_http_auth}; @@ -68,8 +67,11 @@ impl JmapEmailExportCommand { let data = if same_authority(&api_url, &download_url) { client.blob_download(&download_url)? } else { - let mut tls: Tls = client.config.tls.clone().into(); - tls.rustls.alpn = vec!["http/1.1".into()]; + let tls = client + .config + .tls + .clone() + .into_tls(client.config.alpn.clone()); let http_auth = jmap_http_auth(client.config.auth.clone())?; let mut download_client = JmapClientStd::connect(&download_url, &tls, http_auth)?; download_client.blob_download(&download_url)? diff --git a/src/jmap/email/import.rs b/src/jmap/email/import.rs index f3f5f56f..e21c2d81 100644 --- a/src/jmap/email/import.rs +++ b/src/jmap/email/import.rs @@ -24,7 +24,6 @@ use io_jmap::{ rfc8621::{capabilities::MAIL, email::EmailImport}, }; use pimalaya_cli::printer::{Message, Printer}; -use pimalaya_stream::tls::Tls; use url::Url; use crate::{ @@ -84,8 +83,11 @@ impl JmapEmailImportCommand { .blob_upload(&upload_url, "message/rfc822", data)? .blob_id } else { - let mut tls: Tls = client.config.tls.clone().into(); - tls.rustls.alpn = vec!["http/1.1".into()]; + let tls = client + .config + .tls + .clone() + .into_tls(client.config.alpn.clone()); let http_auth = jmap_http_auth(client.config.auth.clone())?; let mut upload_client = JmapClientStd::connect(&upload_url, &tls, http_auth)?; upload_client diff --git a/src/shared/client.rs b/src/shared/client.rs index 4cb89775..df4f0bea 100644 --- a/src/shared/client.rs +++ b/src/shared/client.rs @@ -60,12 +60,9 @@ impl EmailClient { #[cfg(feature = "jmap")] if backend.allows_jmap() { if let Some(jmap_config) = account_config.jmap.take() { - use pimalaya_stream::tls::Tls; - use crate::jmap::client::{jmap_http_auth, parse_server_url}; - let mut tls: Tls = jmap_config.tls.clone().into(); - tls.rustls.alpn = vec!["http/1.1".into()]; + let tls = jmap_config.tls.clone().into_tls(jmap_config.alpn.clone()); let http_auth = jmap_http_auth(jmap_config.auth.clone())?; let url = parse_server_url(&jmap_config.server)?; inner = inner.connect_jmap(&url, &tls, http_auth)?; @@ -76,12 +73,11 @@ impl EmailClient { if backend.allows_imap() { if let Some(imap_config) = account_config.imap.take() { use io_email::imap::client::ImapClientStd; - use pimalaya_stream::{sasl::Sasl, tls::Tls}; + use pimalaya_stream::sasl::Sasl; use crate::imap::id::resolve_auto_id_params; - let mut tls: Tls = imap_config.tls.into(); - tls.rustls.alpn = vec!["imap".into()]; + let tls = imap_config.tls.into_tls(imap_config.alpn); let sasl: Option = imap_config.sasl.map(Sasl::try_from).transpose()?; let auto_id = resolve_auto_id_params(&imap_config.id)?; let server = crate::imap::client::parse_imap_server(&imap_config.server)?; @@ -98,7 +94,6 @@ impl EmailClient { let client = MaildirClient::new(maildir_config.root.to_string_lossy().into_owned()); inner = inner.with_maildir(client); - configured = true; } } @@ -128,10 +123,9 @@ impl EmailClient { use io_email::smtp::client::SmtpClientStd; use io_smtp::rfc5321::types::ehlo_domain::EhloDomain; - use pimalaya_stream::{sasl::Sasl, tls::Tls}; + use pimalaya_stream::sasl::Sasl; - let mut tls: Tls = smtp_config.tls.into(); - tls.rustls.alpn = vec!["smtp".into()]; + let tls = smtp_config.tls.into_tls(smtp_config.alpn); let sasl: Option = smtp_config.sasl.map(Sasl::try_from).transpose()?; let domain: EhloDomain<'static> = Ipv4Addr::new(127, 0, 0, 1).into(); let server = crate::smtp::client::parse_smtp_server(&smtp_config.server)?; diff --git a/src/smtp/client.rs b/src/smtp/client.rs index 0a26e2fa..17969906 100644 --- a/src/smtp/client.rs +++ b/src/smtp/client.rs @@ -32,7 +32,7 @@ use std::{ use anyhow::{Result, anyhow}; use io_smtp::{client::SmtpClientStd as Inner, rfc5321::types::ehlo_domain::EhloDomain}; use pimalaya_config::toml::TomlConfig; -use pimalaya_stream::{sasl::Sasl, tls::Tls}; +use pimalaya_stream::sasl::Sasl; use url::Url; use crate::{account::context::Account, cli::load_or_wizard, config::SmtpConfig}; @@ -45,8 +45,7 @@ impl SmtpClient { /// Opens the SMTP connection (TCP/TLS/STARTTLS, greeting, EHLO, /// SASL). pub fn new(config: SmtpConfig) -> Result { - let mut tls: Tls = config.tls.into(); - tls.rustls.alpn = vec!["smtp".into()]; + let tls = config.tls.into_tls(config.alpn); let sasl: Option = config.sasl.map(Sasl::try_from).transpose()?; let domain: EhloDomain<'static> = Ipv4Addr::new(127, 0, 0, 1).into(); let server = parse_smtp_server(&config.server)?; diff --git a/src/wizard/account.rs b/src/wizard/account.rs index e6ee4d2c..6bbff2dc 100644 --- a/src/wizard/account.rs +++ b/src/wizard/account.rs @@ -48,6 +48,7 @@ pub fn imap_to_config(w: WizardImapConfig) -> Result { server, tls: Default::default(), starttls, + alpn: io_imap::client::default_alpn(), sasl, id: Default::default(), }) @@ -66,6 +67,7 @@ pub fn smtp_to_config(w: WizardSmtpConfig) -> Result { server, tls: Default::default(), starttls, + alpn: io_smtp::client::default_alpn(), sasl, }) } @@ -84,6 +86,7 @@ pub fn jmap_to_config(w: WizardJmapConfig) -> Result { Ok(JmapConfig { server: w.server, tls: Default::default(), + alpn: io_jmap::client::default_alpn(), auth, identity_id: None, drafts_mailbox_id: None,