Skip to main content

postvak_core/
account.rs

1//! Adding an account: the one writer of a new account into config.toml, for the CLI, MCP and the GUI alike.
2use std::time::{Duration, Instant};
3
4use anyhow::bail;
5
6use crate::config::{AccountConfig, Config, ConfigError, PasswordSource};
7use crate::credentials::{self, CredentialError, Secret};
8use crate::daemon::Client;
9use crate::paths::Paths;
10
11/// How often `await_started` asks the daemon again.
12const POLL: Duration = Duration::from_millis(100);
13
14#[derive(Debug, thiserror::Error)]
15pub enum AddError {
16    #[error(transparent)]
17    Config(#[from] ConfigError),
18    #[error(transparent)]
19    Credential(#[from] CredentialError),
20    #[error("account '{0}' keeps its password in the keyring, so a password is required")]
21    MissingSecret(String),
22}
23
24fn with_account(config: &Config, account: &AccountConfig) -> Config {
25    let mut candidate = config.clone();
26    match candidate
27        .accounts
28        .iter_mut()
29        .find(|a| a.name == account.name)
30    {
31        Some(existing) => *existing = account.clone(),
32        None => candidate.accounts.push(account.clone()),
33    }
34    candidate
35}
36
37/// Validates `config` with `account` replacing any account of the same name; touches neither disk nor `config`.
38pub fn check(config: &Config, account: &AccountConfig) -> Result<(), ConfigError> {
39    with_account(config, account).validate()
40}
41
42/// Validates the account, stores a keyring password, then saves config.toml; an account of the same name is replaced.
43/// The daemon watches config.toml and starts the account's sync itself, so a wrong login shows as that sync's error.
44pub fn add(
45    config: &mut Config,
46    paths: &Paths,
47    account: AccountConfig,
48    secret: Option<Secret>,
49) -> Result<(), AddError> {
50    check(config, &account)?;
51    if matches!(account.password, PasswordSource::Keyring { .. }) {
52        let secret = secret.ok_or_else(|| AddError::MissingSecret(account.name.clone()))?;
53        credentials::store(&account.name, &secret)?;
54    }
55    let candidate = with_account(config, &account);
56    candidate.save(&paths.config_file())?;
57    *config = candidate;
58    Ok(())
59}
60
61/// How long a front end waits for the daemon to run a new account; a restart waits for the old thread to end.
62pub const START_TIMEOUT: Duration = Duration::from_secs(10);
63
64/// Waits until the daemon lists `name` as running. Each status request makes the daemon apply a changed config.toml
65/// first, and an account restarting on a changed config is not listed until its new thread runs, so a command sent
66/// after this reaches the thread of the config just saved.
67pub fn await_started(client: &Client, name: &str, timeout: Duration) -> anyhow::Result<()> {
68    let deadline = Instant::now() + timeout;
69    loop {
70        if client.status()?.accounts.iter().any(|a| a.name == name) {
71            return Ok(());
72        }
73        if Instant::now() >= deadline {
74            bail!(
75                "the daemon did not pick up account '{name}' within {} s",
76                timeout.as_secs_f32()
77            );
78        }
79        std::thread::sleep(POLL);
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use std::time::Duration;
86
87    use super::*;
88    use crate::config::{AccountConfig, Config, PasswordSource};
89    use crate::credentials::{self, Secret};
90    use crate::daemon::Client;
91    use crate::daemon::test_support::{TestDaemon, options, recording_connector, write_config};
92    use crate::paths::Paths;
93
94    fn home() -> (tempfile::TempDir, Paths) {
95        let dir = tempfile::tempdir().unwrap();
96        let paths = Paths::under(dir.path());
97        std::fs::create_dir_all(&paths.config_dir).unwrap();
98        (dir, paths)
99    }
100
101    fn keyring_account(name: &str) -> AccountConfig {
102        AccountConfig::new(
103            name,
104            "imap.example.com",
105            "me@example.com",
106            PasswordSource::Keyring { keyring: true },
107        )
108    }
109
110    fn command_account(name: &str) -> AccountConfig {
111        AccountConfig::new(
112            name,
113            "imap.example.com",
114            "me@example.com",
115            PasswordSource::Command {
116                command: "printf x".into(),
117            },
118        )
119    }
120
121    #[test]
122    fn add_saves_a_command_account_without_a_secret() {
123        let (_dir, paths) = home();
124        let mut config = Config::default();
125        add(&mut config, &paths, command_account("work"), None).unwrap();
126        let saved = Config::load(&paths.config_file()).unwrap();
127        assert_eq!(saved.accounts.len(), 1);
128        assert_eq!(saved.accounts[0].name, "work");
129        assert_eq!(config.accounts, saved.accounts);
130    }
131
132    #[test]
133    fn add_stores_the_keyring_secret_then_saves() {
134        let (_dir, paths) = home();
135        let mut config = Config::default();
136        let account = keyring_account("home");
137        add(
138            &mut config,
139            &paths,
140            account.clone(),
141            Some(Secret::new("hunter2".into())),
142        )
143        .unwrap();
144        assert_eq!(credentials::resolve(&account).unwrap().expose(), "hunter2");
145        assert!(paths.config_file().exists());
146    }
147
148    #[test]
149    fn add_refuses_a_keyring_account_without_a_secret_and_writes_nothing() {
150        let (_dir, paths) = home();
151        let mut config = Config::default();
152        let err = add(&mut config, &paths, keyring_account("nosecret"), None).unwrap_err();
153        assert!(
154            matches!(err, AddError::MissingSecret(ref name) if name == "nosecret"),
155            "{err}"
156        );
157        assert!(!paths.config_file().exists());
158        assert!(config.accounts.is_empty());
159    }
160
161    #[test]
162    fn add_replaces_an_account_of_the_same_name() {
163        let (_dir, paths) = home();
164        let mut config = Config::default();
165        add(&mut config, &paths, command_account("work"), None).unwrap();
166        add(&mut config, &paths, command_account("home"), None).unwrap();
167        let mut changed = command_account("work");
168        changed.port = 1993;
169        add(&mut config, &paths, changed, None).unwrap();
170        let saved = Config::load(&paths.config_file()).unwrap();
171        assert_eq!(config.accounts, saved.accounts);
172        let names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
173        assert_eq!(names, ["work", "home"], "replaced in place");
174        assert_eq!(config.accounts[0].port, 1993);
175    }
176
177    #[test]
178    fn add_leaves_config_untouched_on_a_validation_error() {
179        let (_dir, paths) = home();
180        let mut config = Config::default();
181        add(&mut config, &paths, command_account("work"), None).unwrap();
182        let before = std::fs::read_to_string(paths.config_file()).unwrap();
183        let err = add(&mut config, &paths, command_account("my work"), None).unwrap_err();
184        assert!(
185            err.to_string().contains("may only contain letters, digits"),
186            "{err}"
187        );
188        assert_eq!(
189            std::fs::read_to_string(paths.config_file()).unwrap(),
190            before
191        );
192        assert_eq!(config.accounts.len(), 1);
193    }
194
195    #[test]
196    fn check_refuses_an_invalid_account_and_leaves_config_alone() {
197        let config = Config::default();
198        let err = check(&config, &command_account("my work")).unwrap_err();
199        assert!(
200            err.to_string().contains("may only contain letters, digits"),
201            "{err}"
202        );
203        assert!(check(&config, &command_account("work")).is_ok());
204        assert!(config.accounts.is_empty());
205    }
206
207    /// Port 1 cannot connect; any other port can.
208    fn port_one_fails() -> crate::engine::Connector {
209        std::sync::Arc::new(|account: &AccountConfig| {
210            if account.port == 1 {
211                return Err(crate::sync::SyncError::Io(std::io::Error::other(
212                    "connection refused",
213                )));
214            }
215            recording_connector()(account)
216        })
217    }
218
219    #[test]
220    fn adding_an_account_again_syncs_with_its_new_config() {
221        let home = tempfile::tempdir().unwrap();
222        let paths = Paths::under(home.path());
223        write_config(&paths);
224        let daemon = TestDaemon::serve(home, paths.clone(), options(port_one_fails(), None));
225        let client = Client::connect_or_start(&daemon.paths).unwrap();
226        let mut config = Config::load(&paths.config_file()).unwrap();
227        let mut account = command_account("late");
228        account.port = 1;
229        add(&mut config, &paths, account.clone(), None).unwrap();
230        await_started(&client, "late", Duration::from_secs(10)).unwrap();
231        let first = client.request("late", crate::sync::Command::SyncNow);
232        assert!(first.is_err(), "{first:?}");
233
234        account.port = 2;
235        add(&mut config, &paths, account, None).unwrap();
236        await_started(&client, "late", Duration::from_secs(10)).unwrap();
237        let second = client.request("late", crate::sync::Command::SyncNow);
238        assert!(
239            matches!(second, Ok(crate::sync::Event::Synced { .. })),
240            "{second:?}"
241        );
242    }
243
244    #[test]
245    fn await_started_waits_for_the_daemon_to_pick_up_the_account() {
246        let home = tempfile::tempdir().unwrap();
247        let paths = Paths::under(home.path());
248        write_config(&paths);
249        let daemon = TestDaemon::serve(home, paths.clone(), options(recording_connector(), None));
250        let client = Client::connect_or_start(&daemon.paths).unwrap();
251        let mut config = Config::load(&paths.config_file()).unwrap();
252        add(&mut config, &paths, command_account("late"), None).unwrap();
253        await_started(&client, "late", Duration::from_secs(10)).unwrap();
254        assert!(
255            client
256                .status()
257                .unwrap()
258                .accounts
259                .iter()
260                .any(|a| a.name == "late")
261        );
262        let err = await_started(&client, "never", Duration::from_millis(300)).unwrap_err();
263        assert!(err.to_string().contains("never"), "{err}");
264    }
265}