Skip to main content

pimalaya_cli/wizard/
jmap.rs

1//! Interactive JMAP account setup wizard.
2
3use secrecy::SecretString;
4
5use crate::prompt::{self, PromptResult};
6
7/// JMAP account settings collected by the wizard.
8#[derive(Clone, Debug)]
9pub struct WizardJmapConfig {
10    /// The JMAP server, a bare authority or a full session URL.
11    pub server: String,
12    /// The authentication method and its secret.
13    pub auth: JmapAuth,
14}
15
16/// JMAP authentication method.
17#[derive(Clone, Debug)]
18pub enum JmapAuth {
19    /// Basic authentication with a login and a password secret.
20    Basic {
21        /// The login sent during authentication.
22        login: String,
23        /// The password secret.
24        secret: JmapSecret,
25    },
26    /// Bearer authentication with an OAuth access token secret.
27    Bearer {
28        /// The access token secret.
29        secret: JmapSecret,
30    },
31}
32
33/// Source of a JMAP secret (password or token).
34#[derive(Clone, Debug)]
35pub enum JmapSecret {
36    /// The secret stored in plaintext in the configuration.
37    Raw(SecretString),
38    /// A shell command whose output is the secret.
39    Command(String),
40}
41
42const BASIC: &str = "Basic (username + password)";
43const BEARER: &str = "Bearer (OAuth access token)";
44const AUTHS: [&str; 2] = [BASIC, BEARER];
45
46const CMD: &str = "Use a shell command to retrieve my secret (recommended)";
47const RAW: &str = "Save secret in the configuration file (plaintext, NOT recommended)";
48const SECRETS: [&str; 2] = [CMD, RAW];
49
50/// Runs the interactive JMAP account wizard, returning the collected
51/// settings.
52pub fn run(
53    account_name: impl AsRef<str>,
54    local_part: impl AsRef<str>,
55    domain: impl AsRef<str>,
56    defaults: Option<&WizardJmapConfig>,
57) -> PromptResult<WizardJmapConfig> {
58    let account_name = account_name.as_ref();
59    let local_part = local_part.as_ref();
60    let domain = domain.as_ref();
61
62    let default_server = defaults
63        .map(|c| c.server.clone())
64        .unwrap_or_else(|| domain.to_string());
65
66    let server = prompt::text(
67        "JMAP server (bare authority or full URL):",
68        Some(default_server.as_str()),
69    )?;
70
71    let default_strategy = match defaults.map(|c| &c.auth) {
72        Some(JmapAuth::Basic { .. }) => Some(BASIC),
73        Some(JmapAuth::Bearer { .. }) => Some(BEARER),
74        None => None,
75    };
76
77    let strategy = prompt::item("JMAP authentication strategy:", AUTHS, default_strategy)?;
78
79    let auth = match strategy {
80        BASIC => {
81            let default_login = defaults
82                .and_then(|c| match &c.auth {
83                    JmapAuth::Basic { login, .. } if !login.is_empty() => Some(login.clone()),
84                    _ => None,
85                })
86                .unwrap_or_else(|| format!("{local_part}@{domain}"));
87
88            let login = prompt::text("JMAP login:", Some(default_login.as_str()))?;
89            let secret = prompt_secret(account_name, "password")?;
90
91            JmapAuth::Basic { login, secret }
92        }
93        BEARER => {
94            let secret = prompt_secret(account_name, "token")?;
95            JmapAuth::Bearer { secret }
96        }
97        _ => unreachable!(),
98    };
99
100    Ok(WizardJmapConfig { server, auth })
101}
102
103fn prompt_secret(account_name: &str, label: &str) -> PromptResult<JmapSecret> {
104    let strategy = prompt::item("JMAP secret strategy:", SECRETS, None)?;
105
106    match strategy {
107        CMD => {
108            let default_cmd = default_secret_cmd(account_name);
109            let cmd = prompt::text("Shell command:", Some(default_cmd.as_str()))?;
110            Ok(JmapSecret::Command(cmd))
111        }
112        RAW => {
113            let secret =
114                prompt::password(format!("JMAP {label}:"), format!("Confirm JMAP {label}:"))?;
115            Ok(JmapSecret::Raw(secret))
116        }
117        _ => unreachable!(),
118    }
119}
120
121fn default_secret_cmd(account_name: &str) -> String {
122    if cfg!(target_os = "macos") {
123        format!(
124            "security find-generic-password \
125	     -a '{account_name}' \
126	     -s 'himalaya-{account_name}-jmap' \
127	     -w"
128        )
129    } else if cfg!(target_os = "linux") {
130        format!("secret-tool lookup account {account_name} service himalaya-jmap")
131    } else {
132        String::new()
133    }
134}