himalaya 2.2.1

CLI to manage emails
//! # IMAP and SMTP wizard
//!
//! Configures an account against the IMAP and submission endpoints
//! discovery pinned.
//!
//! The SASL mechanism is picked, its credentials prompted and the IMAP
//! connection tested. With a submission endpoint too, the wizard asks
//! whether SMTP shares them, and runs the prompts again when it does not,
//! the two sides being free to advertise different mechanisms.
//!
//! No SMTP host is ever invented: with nothing discovered the account is
//! IMAP-only and SMTP is added by hand.

use anyhow::{Result, bail};
use io_pim_discovery::compose::config::DiscoverySecurity;
use io_sasl::mechanism::SaslMechanism;
use io_smtp::client::SmtpClientStd;
use pimalaya_cli::{prompt, spinner::Spinner};
use pimalaya_config::secret::SecretResolver;

use crate::{
    account::check,
    config::{
        ImapConfig, SaslAnonymousConfig, SaslConfig, SaslLoginConfig, SaslOauthbearerConfig,
        SaslPlainConfig, SaslScramSha256Config, SaslXoauth2Config, SmtpConfig,
    },
    wizard::{
        search::{AuthCaps, Discovered, DiscoveredKind, TcpEndpoint},
        secret,
    },
};

// NOTE: the mechanisms split by credential kind, a password family and
// a token family, ANONYMOUS carrying neither.
const PLAIN: &str = "PLAIN (username + password)";
const LOGIN: &str = "LOGIN (username + password)";
const SCRAM_SHA_256: &str = "SCRAM-SHA-256 (username + password)";
const ANONYMOUS: &str = "ANONYMOUS (no credentials)";
const OAUTHBEARER: &str = "OAUTHBEARER (username + API token)";
const XOAUTH2: &str = "XOAUTH2 (username + API token)";

/// Configures IMAP and SMTP from a discovered entry.
///
/// The IMAP mechanism and credentials are prompted and the connection
/// tested, then SMTP is asked whether it reuses them, configured with a
/// distinct SASL when it does not, and tested last.
///
/// Both connections are validated here, so the caller skips the final
/// account test.
pub fn configure_discovered(
    account_name: &str,
    email: &str,
    discovered: &Discovered,
) -> Result<(ImapConfig, Option<SmtpConfig>)> {
    let DiscoveredKind::ImapSmtp { imap, smtp } = &discovered.kind else {
        bail!("Expected an IMAP + SMTP configuration");
    };

    let login_hint = discovered.login_default(email);

    // NOTE: probing is what narrows the menu to the mechanisms the server
    // advertises, a failure falling back to what discovery said.
    let probed = probe_imap_mechanisms(
        &endpoint_server(imap),
        imap.security == DiscoverySecurity::Starttls,
    );
    let imap_sasl = prompt_sasl(
        account_name,
        login_hint.as_deref(),
        discovered.auth,
        probed.as_deref(),
    )?;
    let imap = imap_config(imap, imap_sasl.clone());

    // NOTE: one resolver for both tests, so answering yes to the question
    // below costs one credential read rather than two.
    let mut resolver = SecretResolver::new();
    test_connection("IMAP", || check::connect_imap(&imap, &mut resolver))?;

    // NOTE: SMTP advertises its mechanisms over EHLO rather than through
    // the IMAP capability probe, so its menu stays keyed on discovery.
    let smtp = match smtp {
        Some(endpoint) => {
            let smtp_sasl = if prompt::bool("Use the same credentials for SMTP?", true)? {
                imap_sasl
            } else {
                prompt_sasl(account_name, login_hint.as_deref(), discovered.auth, None)?
            };
            let smtp = smtp_config(endpoint, smtp_sasl);
            test_connection("SMTP", || check::connect_smtp(&smtp, &mut resolver))?;
            Some(smtp)
        }
        None => None,
    };

    Ok((imap, smtp))
}

/// Runs a connection `test` behind a labelled spinner, surfacing a
/// failure as the wizard's error (like the final account test) so a bad
/// credential stops here instead of yielding a config that cannot
/// connect.
fn test_connection(label: &str, test: impl FnOnce() -> Result<()>) -> Result<()> {
    let spinner = Spinner::start(format!("Testing {label} connection"));

    if let Err(err) = test() {
        spinner.failure(format!("{label} connection failed"));
        return Err(err);
    }

    spinner.success(format!("{label} connection succeeded"));
    Ok(())
}

/// Prompts the SASL mechanism, then its credentials.
///
/// A live capability probe narrows the menu to what it saw, most
/// preferred first and LOGIN last. Without one the full list keyed on the
/// advertised capabilities is offered, so a failed probe leaves nobody
/// stuck.
fn prompt_sasl(
    account_name: &str,
    login_hint: Option<&str>,
    caps: AuthCaps,
    probed: Option<&[SaslMechanism]>,
) -> Result<SaslConfig> {
    let mechanism = prompt_mechanism(caps, probed)?;
    build_sasl(mechanism, account_name, login_hint, caps)
}

/// Prompts the authentication mechanism: the probed list when the server
/// advertised one, otherwise the full fallback list. A single candidate
/// is selected without prompting.
fn prompt_mechanism(caps: AuthCaps, probed: Option<&[SaslMechanism]>) -> Result<SaslMechanism> {
    let mechanisms = match probed {
        Some(mechanisms) if !mechanisms.is_empty() => mechanisms.to_vec(),
        _ => fallback_mechanisms(caps),
    };

    let labels: Vec<&str> = mechanisms.iter().map(mechanism_label).collect();
    let label = if labels.len() == 1 {
        labels[0]
    } else {
        prompt::item("SASL mechanism:", labels, None)?
    };

    Ok(mechanisms
        .into_iter()
        .find(|m| mechanism_label(m) == label)
        .expect("chosen label matches a mechanism"))
}

/// Prompts the credentials for `mechanism` and builds its SASL config.
/// ANONYMOUS carries no login; every other mechanism needs one, plus a
/// password (basic family) or an API token (OAuth family).
fn build_sasl(
    mechanism: SaslMechanism,
    account_name: &str,
    login_hint: Option<&str>,
    caps: AuthCaps,
) -> Result<SaslConfig> {
    if let SaslMechanism::Anonymous = mechanism {
        let message = prompt::text("ANONYMOUS message (optional):", None::<&str>)?;
        let message = Some(message).filter(|m| !m.trim().is_empty());
        return Ok(SaslConfig::Anonymous(SaslAnonymousConfig { message }));
    }

    let login = prompt::text("Login:", login_hint)?;

    Ok(match mechanism {
        SaslMechanism::Plain => {
            let passwd = secret::configure_password("Password", account_name)?;
            SaslConfig::Plain(SaslPlainConfig {
                authzid: None,
                authcid: login,
                passwd,
            })
        }
        SaslMechanism::Login => {
            let password = secret::configure_password("Password", account_name)?;
            SaslConfig::Login(SaslLoginConfig {
                username: login,
                password,
            })
        }
        SaslMechanism::ScramSha256 => {
            let password = secret::configure_password("Password", account_name)?;
            SaslConfig::ScramSha256(SaslScramSha256Config {
                username: login,
                password,
            })
        }
        SaslMechanism::OAuthBearer => {
            let token =
                secret::configure_token("API token", account_name, caps.oauth || !caps.any())?;
            SaslConfig::Oauthbearer(SaslOauthbearerConfig {
                username: login,
                token,
            })
        }
        SaslMechanism::XOAuth2 => {
            let token =
                secret::configure_token("API token", account_name, caps.oauth || !caps.any())?;
            SaslConfig::Xoauth2(SaslXoauth2Config {
                username: login,
                token,
            })
        }
        SaslMechanism::Anonymous => unreachable!("handled above"),
        // NOTE: io-sasl knows more mechanisms than the schema expresses,
        // and the menu offers the six above alone, so this arm is
        // unreachable through it and bails rather than panics.
        other => bail!("Unsupported SASL mechanism `{}`", mechanism_name(&other)),
    })
}

/// The menu label for a mechanism, split by the credential it needs.
fn mechanism_label(mechanism: &SaslMechanism) -> &'static str {
    match mechanism {
        SaslMechanism::ScramSha256 => SCRAM_SHA_256,
        SaslMechanism::Plain => PLAIN,
        SaslMechanism::OAuthBearer => OAUTHBEARER,
        SaslMechanism::XOAuth2 => XOAUTH2,
        SaslMechanism::Anonymous => ANONYMOUS,
        SaslMechanism::Login => LOGIN,
        other => mechanism_name(other),
    }
}

/// The registered SASL name of a mechanism the wizard does not offer,
/// used to name it in a menu label or an error.
fn mechanism_name(mechanism: &SaslMechanism) -> &'static str {
    match mechanism {
        SaslMechanism::CramMd5 => "CRAM-MD5",
        SaslMechanism::External => "EXTERNAL",
        SaslMechanism::Gssapi => "GSSAPI",
        SaslMechanism::Gs2Krb5 => "GS2-KRB5",
        SaslMechanism::Gs2Krb5Plus => "GS2-KRB5-PLUS",
        SaslMechanism::ScramSha1 => "SCRAM-SHA-1",
        SaslMechanism::ScramSha1Plus => "SCRAM-SHA-1-PLUS",
        SaslMechanism::ScramSha256Plus => "SCRAM-SHA-256-PLUS",
        SaslMechanism::ScramSha512 => "SCRAM-SHA-512",
        SaslMechanism::ScramSha512Plus => "SCRAM-SHA-512-PLUS",
        SaslMechanism::Anonymous => ANONYMOUS,
        SaslMechanism::Login => LOGIN,
        SaslMechanism::Plain => PLAIN,
        SaslMechanism::OAuthBearer => OAUTHBEARER,
        SaslMechanism::XOAuth2 => XOAUTH2,
        SaslMechanism::ScramSha256 => SCRAM_SHA_256,
    }
}

/// The mechanisms offered when no live probe is available, keyed on what
/// discovery advertised (every family when nothing was): most preferred
/// first, LOGIN last, token mechanisms only when a token or OAuth grant
/// was advertised.
fn fallback_mechanisms(caps: AuthCaps) -> Vec<SaslMechanism> {
    let mut mechanisms = Vec::new();

    if caps.basic || !caps.any() {
        mechanisms.extend([SaslMechanism::ScramSha256, SaslMechanism::Plain]);
    }
    if caps.token() || !caps.any() {
        mechanisms.extend([SaslMechanism::OAuthBearer, SaslMechanism::XOAuth2]);
    }
    if caps.basic || !caps.any() {
        mechanisms.extend([SaslMechanism::Anonymous, SaslMechanism::Login]);
    }

    mechanisms
}

/// Probes the IMAP server for the mechanisms it advertises, returning
/// `None` (offer the full list) when the probe fails or advertises
/// nothing usable. The error is logged, never surfaced: the wizard falls
/// back rather than stopping.
fn probe_imap_mechanisms(server: &str, starttls: bool) -> Option<Vec<SaslMechanism>> {
    match check::probe_imap_mechanisms(server, starttls) {
        Ok(mechanisms) if !mechanisms.is_empty() => Some(mechanisms),
        Ok(_) => None,
        Err(err) => {
            log::warn!("could not probe IMAP capabilities, offering all mechanisms: {err:#}");
            None
        }
    }
}

/// The `scheme://host:port` string for a discovered endpoint, matching
/// how [`imap_config`] builds the server URL.
fn endpoint_server(endpoint: &TcpEndpoint) -> String {
    let scheme = if endpoint.security == DiscoverySecurity::Tls {
        "imaps"
    } else {
        "imap"
    };

    format!("{scheme}://{}:{}", endpoint.host, endpoint.port)
}

fn imap_config(endpoint: &TcpEndpoint, sasl: SaslConfig) -> ImapConfig {
    ImapConfig {
        server: endpoint_server(endpoint),
        tls: Default::default(),
        proxy: None,
        starttls: endpoint.security == DiscoverySecurity::Starttls,
        alpn: io_imap::client::default_alpn(),
        sasl: Some(sasl),
        sasl_ir: None,
        id: Default::default(),
        sort: Default::default(),
    }
}

fn smtp_config(endpoint: &TcpEndpoint, sasl: SaslConfig) -> SmtpConfig {
    let scheme = if endpoint.security == DiscoverySecurity::Tls {
        "smtps"
    } else {
        "smtp"
    };

    SmtpConfig {
        server: format!("{scheme}://{}:{}", endpoint.host, endpoint.port),
        tls: Default::default(),
        proxy: None,
        starttls: endpoint.security == DiscoverySecurity::Starttls,
        alpn: SmtpClientStd::default_alpn(),
        sasl: Some(sasl),
    }
}