Skip to main content

sioul/
accounts.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Mail accounts and sync, from the terminal: `sioul account …`, `sioul sync`, `sioul watch`.
5//!
6//! Passwords are typed at a prompt that does not echo them, and go to the
7//! system keyring; they never reach the configuration file or the screen.
8//! Microsoft's mail takes no password: its address is signed in on
9//! Microsoft's page, whose link is printed here, and the access goes to the
10//! keyring (docs/microsoft.md).
11
12use crate::{Session, load_store, print_right_now};
13use clap::{Subcommand, ValueEnum};
14use jiff::Zoned;
15use sioul_core::config::{self, Account, AccountKind, Config, DEFAULT_SYNC_DAYS, Priority, Security};
16use sioul_core::porch::{self, KnownSenders, Triaged};
17use sioul_core::view;
18use sioul_sync::discover::MicrosoftKind;
19use sioul_sync::{Control, Found, FoundBy, Learned, Report, SyncError, microsoft, notify, secret};
20use std::path::PathBuf;
21use std::sync::atomic::AtomicBool;
22use std::sync::{Arc, mpsc};
23
24#[derive(Subcommand)]
25pub(crate) enum AccountCommand {
26    /// Finds the server from the address, asks for the password, tests it, adds the account and fetches its mail.
27    Add {
28        address: String,
29        /// A short name for the account (default: from the domain).
30        #[arg(long)]
31        id: Option<String>,
32        /// The IMAP server, when it cannot be found or you want another.
33        #[arg(long)]
34        host: Option<String>,
35        #[arg(long)]
36        port: Option<u16>,
37        #[arg(long, value_enum)]
38        security: Option<SecurityArg>,
39        /// The login, when it is not the address.
40        #[arg(long)]
41        username: Option<String>,
42        /// Days of mail the first sync brings (default 14).
43        #[arg(long)]
44        days: Option<u32>,
45        /// `microsoft`: Microsoft's sign-in, whatever discovery finds (a
46        /// Microsoft 365 address whose mail first goes through another service).
47        #[arg(long, value_enum)]
48        auth: Option<AuthArg>,
49        /// Microsoft: the Application (client) ID of an application of your own; no secret.
50        #[arg(long)]
51        client_id: Option<String>,
52        /// Microsoft: its Directory (tenant) ID, when it is registered for one organisation alone.
53        #[arg(long)]
54        tenant: Option<String>,
55    },
56    /// Signs a Microsoft account in again on Microsoft's page (one added with a password before: switched to it).
57    SignIn {
58        id: String,
59        /// The Application (client) ID of an application of your own; no secret.
60        #[arg(long)]
61        client_id: Option<String>,
62        /// Its Directory (tenant) ID, when it is registered for one organisation alone.
63        #[arg(long)]
64        tenant: Option<String>,
65    },
66    /// A web-only mailbox (Proton without Bridge, a bank): it opens in your browser.
67    Portal { id: String, url: String },
68    /// Every account, its server, and whether its password is in the keyring.
69    List,
70    /// Logs in and opens the inbox, read-only.
71    Test { id: String },
72    /// Replaces the password kept in the keyring (after changing it, or a new app password).
73    Password { id: String },
74    /// Removes the account and its password. Its mail stays on disk; a
75    /// Microsoft account's is deleted unless you say otherwise (asked).
76    Remove {
77        id: String,
78        /// A Microsoft account: keep the mail Sioul keeps on this device, without asking.
79        #[arg(long, conflicts_with = "delete_mail")]
80        keep_mail: bool,
81        /// A Microsoft account: delete the mail Sioul keeps on this device, without asking.
82        #[arg(long)]
83        delete_mail: bool,
84    },
85    /// Ranks an account's mail against the others': above, average (the default) or below.
86    /// Below, its mail skips the screener and waits folded at the bottom of the Porch.
87    Priority {
88        id: String,
89        #[arg(value_enum)]
90        priority: PriorityArg,
91    },
92}
93
94#[derive(Clone, Copy, ValueEnum)]
95pub(crate) enum PriorityArg {
96    Above,
97    Average,
98    Below,
99}
100
101/// How an account signs in, when not with a password.
102#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
103pub(crate) enum AuthArg {
104    Microsoft,
105}
106
107#[derive(Clone, Copy, ValueEnum)]
108pub(crate) enum SecurityArg {
109    Tls,
110    Starttls,
111}
112
113impl From<SecurityArg> for Security {
114    fn from(value: SecurityArg) -> Security {
115        match value {
116            SecurityArg::Tls => Security::Tls,
117            SecurityArg::Starttls => Security::Starttls,
118        }
119    }
120}
121
122pub(crate) fn run(s: &Session, command: AccountCommand) -> Result<(), String> {
123    match command {
124        AccountCommand::Add { address, id, host, port, security, username, days, auth, client_id, tenant } => {
125            let wanted = Wanted { host, port, security: security.map(Security::from), username, days, microsoft: auth == Some(AuthArg::Microsoft), client_id, tenant };
126            add(s, &address, id, wanted)
127        }
128        AccountCommand::SignIn { id, client_id, tenant } => sign_in_again(s, &id, client_id.as_deref().unwrap_or(""), tenant.as_deref().unwrap_or("")),
129        AccountCommand::Portal { id, url } => portal(s, &id, &url),
130        AccountCommand::List => list(s),
131        AccountCommand::Test { id } => test(s, &id),
132        AccountCommand::Password { id } => password(s, &id),
133        AccountCommand::Remove { id, keep_mail, delete_mail } => remove(s, &id, (keep_mail || delete_mail).then_some(delete_mail)),
134        AccountCommand::Priority { id, priority } => {
135            let priority = match priority {
136                PriorityArg::Above => Priority::Above,
137                PriorityArg::Average => Priority::Average,
138                PriorityArg::Below => Priority::Below,
139            };
140            set_priority(s, &id, priority)
141        }
142    }
143}
144
145/// What was asked on the command line, over what discovery finds.
146struct Wanted {
147    host: Option<String>,
148    port: Option<u16>,
149    security: Option<Security>,
150    username: Option<String>,
151    days: Option<u32>,
152    /// Microsoft's sign-in, whatever discovery finds (`--auth microsoft`).
153    microsoft: bool,
154    /// Microsoft: an application of your own, and its directory.
155    client_id: Option<String>,
156    tenant: Option<String>,
157}
158
159fn add(s: &Session, address: &str, id: Option<String>, wanted: Wanted) -> Result<(), String> {
160    let address = address.trim().to_ascii_lowercase();
161    // As in the window: the address's mail only, switched off or not; its calendars may be here already.
162    let mail = |a: &&Account| a.address.as_deref() == Some(address.as_str()) && matches!(a.kind, AccountKind::Imap | AccountKind::Jmap);
163    if let Some(existing) = s.config.every_account().find(mail) {
164        return Err(s.say("account-address-exists", &[("address", address.clone()), ("id", existing.id.clone())]));
165    }
166    let id = id.unwrap_or_else(|| s.config.free_id(&address));
167    if s.config.every_account().any(|a| a.id == id) {
168        return Err(s.say("account-exists", &[("id", id)]));
169    }
170    let found = match &wanted.host {
171        // Given by hand: port 0 means "the usual one for the encryption", chosen below.
172        Some(host) => Found { host: host.clone(), port: 0, security: Security::Tls, username: address.clone(), by: sioul_sync::FoundBy::Provider, smtp: None },
173        None => {
174            let found = sioul_sync::discover(&address).map_err(|e| e.sentence(&s.tr, &id))?;
175            println!("{}", s.say("account-found", &[("server", server_line(s, &found.host, found.port, found.security)), ("by", s.tr.text(found.by.message_id(), None))]));
176            found
177        }
178    };
179    // Microsoft's mail: its own sign-in, never a password (none works there).
180    let microsoft = match found.by {
181        FoundBy::Microsoft(kind) => Some(kind),
182        _ if wanted.microsoft || microsoft::is_imap_host(&found.host) => Some(MicrosoftKind::Unknown),
183        _ => None,
184    };
185    if let Some(kind) = microsoft {
186        let mut account = Account::imap(&id, &address, microsoft::IMAP_HOST, 993, Security::Tls, None);
187        account.sync_days = wanted.days;
188        let account = sign_in_with_microsoft(s, account, kind, wanted.client_id.as_deref().unwrap_or(""), wanted.tenant.as_deref().unwrap_or(""))?;
189        config::add_imap_account(&s.config_path, &account)?;
190        config::set_sign_in(&s.config_path, &account)?;
191        println!("{}", s.say("account-added", &[("id", id.clone()), ("path", account.maildir_path().display().to_string())]));
192        println!("{}", s.tr.text("ui-syncing", None));
193        let report = sioul_sync::sync(&account, "").map_err(|e| e.sentence(&s.tr, &id))?;
194        say_report(s, &account, &report);
195        learn_provider(s, &account, true);
196        return Ok(());
197    }
198    let security = wanted.security.unwrap_or(found.security);
199    let port = wanted.port.or((found.port != 0).then_some(found.port)).unwrap_or(if security == Security::Tls { 993 } else { 143 });
200    let login = wanted.username.clone().unwrap_or(found.username);
201    let mut account = Account::imap(&id, &address, &found.host, port, security, Some(&login));
202    account.sync_days = wanted.days;
203    if wanted.host.is_some() {
204        println!("{}", server_line(s, &found.host, port, security));
205    }
206    if account.username.is_some() {
207        println!("{}", s.say("account-login", &[("login", crate::one_line(&login))]));
208    }
209    // Google's mail (Gmail, Google Workspace): an app password, never the account's own.
210    let google = found.by == sioul_sync::FoundBy::Google || found.host.eq_ignore_ascii_case(sioul_sync::discover::GOOGLE_IMAP);
211    let hint = if google { "account-gmail-hint" } else { "account-app-password-hint" };
212    println!("{}", s.tr.text(hint, None));
213    let password = sioul_sync::tidy_password(&found.host, &prompt_password(s, &address)?);
214    let messages = sioul_sync::test(&account, &password).map_err(|e| e.sentence(&s.tr, &id))?;
215    let mut connected = s.tr.counted(messages as usize);
216    connected.set("account", id.clone());
217    println!("{}", s.tr.text("account-connected", Some(&connected)));
218    secret::save(&account, &password).map_err(|e| e.sentence(&s.tr, &id))?;
219    config::add_imap_account(&s.config_path, &account)?;
220    println!("{}", s.say("account-added", &[("id", id.clone()), ("path", account.maildir_path().display().to_string())]));
221    println!("{}", s.tr.text("ui-syncing", None));
222    let report = sioul_sync::sync(&account, &password).map_err(|e| e.sentence(&s.tr, &id))?;
223    say_report(s, &account, &report);
224    learn_provider(s, &account, true);
225    Ok(())
226}
227
228/// Microsoft's page, from the terminal: its link printed, to open in a
229/// browser on this computer (its answer comes back here, to a loopback
230/// port); Sioul waits five minutes, Ctrl+C stops. The mailbox is tried with
231/// the access given before anything is kept (`microsoft::sign_in`); when
232/// only the mailbox of the login Microsoft's page signed in opened, not the
233/// address's, Sioul asks first, No by default (and No without a terminal).
234/// A login another of your accounts signs in as is refused. A work account
235/// refused for want of its organisation's approval gets what its
236/// administrator does, to forward.
237fn sign_in_with_microsoft(s: &Session, account: Account, kind: MicrosoftKind, client_id: &str, tenant: &str) -> Result<Account, String> {
238    let address = account.address.clone().unwrap_or_default();
239    let label = address.clone();
240    let login = account.login().unwrap_or(&address).to_string();
241    let client = microsoft::app_for(&login, client_id).map_err(|e| e.sentence(&s.tr, &label))?.ok_or_else(|| s.tr.text("microsoft-cli-no-app", None))?;
242    let authority = microsoft::authority_for(&login, kind, tenant).map_err(|e| e.sentence(&s.tr, &label))?;
243    println!("{}", s.tr.text("microsoft-cli-open", None));
244    let stop = AtomicBool::new(false);
245    // The logins your other Microsoft accounts sign in as: never shared.
246    let taken: Vec<String> = s.config.every_account().filter(|a| a.is_microsoft() && a.id != account.id).filter_map(|a| a.login().map(|l| l.trim().to_lowercase())).collect();
247    let signed = microsoft::sign_in(account, &client, &authority, &taken, |url| println!("\n{url}\n"), &stop).and_then(|signed| {
248        // Only the mailbox of the login Microsoft's page signed in opened: kept on your Yes alone.
249        let yes = match signed.other_login.as_deref() {
250            // Microsoft's words on one line: they never drive the terminal.
251            Some(other) => ask_no(s, &s.say("microsoft-other-login-question", &[("address", crate::one_line(&address)), ("login", crate::one_line(other))])).unwrap_or(false),
252            None => true,
253        };
254        signed.answer(yes)
255    });
256    match signed {
257        Ok((account, grant)) => {
258            let kind = if grant.personal { "account-signed-in-microsoft-personal" } else { "account-signed-in-microsoft-work" };
259            println!("{}", s.say("microsoft-cli-signed-in", &[("account", label), ("how", s.tr.text(kind, None))]));
260            Ok(account)
261        }
262        Err(e) => {
263            let said = e.sentence(&s.tr, &label);
264            let organisation = matches!(&e, SyncError::Login(d) if d == microsoft::ADMIN) || matches!(&e, SyncError::Said(id, _) if *id == microsoft::DECLINED_WORK);
265            if organisation {
266                let domain = address.rsplit('@').next().unwrap_or("");
267                return Err(format!("{said}\n\n{}", s.say("microsoft-admin-steps", &[("link", microsoft::admin_consent_url(&client, domain))])));
268            }
269            Err(said)
270        }
271    }
272}
273
274/// `sioul account sign-in <id>`: a Microsoft account signed in again (its
275/// access ended, or another application of yours), or one added with a
276/// password at Microsoft's server switched to Microsoft's sign-in.
277fn sign_in_again(s: &Session, id: &str, client_id: &str, tenant: &str) -> Result<(), String> {
278    let account = s.config.every_account().find(|a| a.id == id && a.kind == AccountKind::Imap).ok_or_else(|| s.say("account-unknown", &[("id", id.to_string())]))?.clone();
279    let at_microsoft = account.host.as_deref().is_some_and(microsoft::is_imap_host);
280    if !account.is_microsoft() && !at_microsoft {
281        return Err(s.say("microsoft-cli-not-microsoft", &[("id", id.to_string())]));
282    }
283    let kind = if account.is_microsoft() && !account.is_microsoft_work() { MicrosoftKind::Personal } else if account.is_microsoft() { MicrosoftKind::Work } else { MicrosoftKind::Unknown };
284    let was_password = !account.is_microsoft();
285    let old = account.clone();
286    let account = sign_in_with_microsoft(s, account, kind, client_id, tenant)?;
287    config::set_sign_in(&s.config_path, &account)?;
288    // A password kept before, which Microsoft no longer took: gone from the keyring.
289    if was_password {
290        let _ = secret::forget(&old);
291    }
292    println!("{}", s.say("microsoft-cli-again", &[("account", id.to_string())]));
293    Ok(())
294}
295
296fn server_line(s: &Session, host: &str, port: u16, security: Security) -> String {
297    s.say(
298        "account-server",
299        &[("host", crate::one_line(host)), ("port", port.to_string()), ("security", s.tr.text(&format!("security-{}", security.as_str()), None))],
300    )
301}
302
303pub(crate) fn prompt_password(s: &Session, address: &str) -> Result<String, String> {
304    // Tests against a local server type nothing (never in Sioul's builds).
305    #[cfg(feature = "insecure-test-tls")]
306    if let Ok(password) = std::env::var("SIOUL_TEST_PASSWORD") {
307        return Ok(password);
308    }
309    let prompt = s.say("account-password-prompt", &[("address", address.to_string())]);
310    let password = rpassword::prompt_password(format!("{prompt} ")).map_err(|e| e.to_string())?;
311    Ok(password.trim_end_matches(['\r', '\n']).to_string())
312}
313
314fn portal(s: &Session, id: &str, url: &str) -> Result<(), String> {
315    // A portal is a site now: its id among the sites, its address encrypted (docs/sites.md).
316    if s.config.site(id).is_some() || s.config.account(id).is_some() {
317        return Err(s.say("account-exists", &[("id", id.to_string())]));
318    }
319    if !url.starts_with("https://") {
320        return Err(s.tr.text("site-https-only", None));
321    }
322    config::add_portal(&s.config_path, id, url)?;
323    println!("{}", s.say("account-portal-added", &[("id", id.to_string()), ("url", url.to_string())]));
324    Ok(())
325}
326
327fn list(s: &Session) -> Result<(), String> {
328    if s.config.accounts.is_empty() {
329        println!("{}", s.tr.text("sync-nothing", None));
330        return Ok(());
331    }
332    for (account, shown) in s.config.accounts.iter().zip(view::accounts(&s.config, &s.tr, &Default::default(), &Default::default())) {
333        let mut parts = vec![account.id.clone()];
334        parts.extend(account.address.clone());
335        if account.kind != AccountKind::Portal {
336            parts.push(secret_said(s, account, secret::password(account)));
337        }
338        if account.kind == AccountKind::Imap {
339            parts.push(s.tr.text(&format!("priority-{}", shown.priority), None));
340        }
341        println!("{}", crate::one_line(&parts.join(" · ")));
342        // "Server: …" in English, "Serveur : …" in French; a row without a label goes on with the one above.
343        let colon = if s.tr.language() == "fr" { "\u{202f}:" } else { ":" };
344        let mut indent = 4;
345        for row in shown.rows {
346            if row.label.is_empty() {
347                println!("{:indent$}{}", "", crate::one_line(&row.value));
348            } else {
349                println!("    {}{colon} {}", row.label, crate::one_line(&row.value));
350                indent = 4 + row.label.chars().count() + colon.chars().count() + 1;
351            }
352        }
353    }
354    Ok(())
355}
356
357/// What `accounts list` says of an account's secret, from what the keyring
358/// gave (`secret::password`): its password there, Google's sign-in (whose
359/// access the keyring keeps, no password), none yet, or what went wrong.
360fn secret_said(s: &Session, account: &Account, found: Result<String, SyncError>) -> String {
361    match found {
362        Ok(_) if account.is_microsoft() => s.tr.text("account-signed-in-microsoft", None),
363        Ok(_) if sioul_sync::sasl::OAuth::of(account).is_some() => s.tr.text("account-signed-in-google", None),
364        Ok(_) => s.tr.text("account-has-password", None),
365        Err(SyncError::NoPassword) => s.say("account-no-password", &[("id", account.id.clone())]),
366        Err(e) => e.sentence(&s.tr, &account.id),
367    }
368}
369
370fn mail_account<'a>(s: &'a Session, id: &str) -> Result<&'a Account, String> {
371    s.config.account(id).filter(|a| a.syncs()).ok_or_else(|| s.say("account-unknown", &[("id", id.to_string())]))
372}
373
374fn test(s: &Session, id: &str) -> Result<(), String> {
375    let account = mail_account(s, id)?;
376    let messages = secret::password(account).and_then(|p| sioul_sync::test(account, &p)).map_err(|e| e.sentence(&s.tr, id))?;
377    println!("{}", s.tr.text("account-connected", Some(&s.tr.counted(messages as usize))));
378    Ok(())
379}
380
381fn password(s: &Session, id: &str) -> Result<(), String> {
382    let account = mail_account(s, id)?;
383    // Microsoft takes no password, app passwords included: its own sign-in instead.
384    if account.is_microsoft() || account.host.as_deref().is_some_and(microsoft::is_imap_host) {
385        return Err(s.say("microsoft-cli-no-password", &[("id", id.to_string())]));
386    }
387    let typed = prompt_password(s, account.address.as_deref().unwrap_or(id))?;
388    let password = sioul_sync::tidy_password(account.host.as_deref().unwrap_or(""), &typed);
389    let messages = sioul_sync::test(account, &password).map_err(|e| e.sentence(&s.tr, id))?;
390    println!("{}", s.tr.text("account-connected", Some(&s.tr.counted(messages as usize))));
391    secret::save(account, &password).map_err(|e| e.sentence(&s.tr, id))?;
392    println!("{}", s.tr.text("account-password-saved", None));
393    Ok(())
394}
395
396fn set_priority(s: &Session, id: &str, priority: Priority) -> Result<(), String> {
397    if s.config.account(id).is_none() {
398        return Err(s.say("account-unknown", &[("id", id.to_string())]));
399    }
400    config::set_priority(&s.config_path, id, priority)?;
401    let label = s.tr.text(&format!("priority-{}", priority.as_str()), None);
402    println!("{}", s.say("ui-priority-set", &[("id", id.to_string()), ("priority", label)]));
403    Ok(())
404}
405
406/// `delete_mail`: a Microsoft account's mail kept on this device deleted
407/// (Some(true)) or kept (Some(false)); None: asked, Yes by default (as
408/// Microsoft's terms ask an app to delete what it keeps of an account you
409/// leave). Other accounts' mail stays, as before.
410fn remove(s: &Session, id: &str, delete_mail: Option<bool>) -> Result<(), String> {
411    // Switched off, an account can be removed all the same.
412    let account = s.config.every_account().find(|a| a.id == id).ok_or_else(|| s.say("account-unknown", &[("id", id.to_string())]))?;
413    // The keyring keeps one password per login and server: another account on the same keeps it.
414    let shared_password = s.config.every_account().any(|a| a.id != id && a.host == account.host && a.login() == account.login());
415    if account.is_microsoft() {
416        // Another process's watcher of this account (the window, `sioul watch`) is not stopped from here: said before anything is deleted.
417        if delete_mail != Some(false) {
418            println!("{}", s.tr.text("microsoft-cli-remove-close", None));
419        }
420        let delete = delete_mail.map_or_else(|| ask_yes(s, &s.tr.text("ui-remove-microsoft-mail", None)), Ok)?;
421        // Nothing to give back to Microsoft: forgotten here, the consent withdrawn on Microsoft's page.
422        let page = microsoft::forget(account.login().unwrap_or(id), !account.is_microsoft_work()).map_err(|e| e.sentence(&s.tr, id))?;
423        config::remove_account(&s.config_path, id)?;
424        if delete {
425            sioul_core::maildir::forget_account(account)?;
426            sioul_learn::Dirs::standard().forget_account(&account.id).map_err(|e| e.to_string())?;
427            println!("{}", s.say("account-removed-microsoft-mail", &[("id", id.to_string()), ("page", page.to_string())]));
428        } else {
429            println!("{}", s.say("account-removed-microsoft", &[("id", id.to_string()), ("path", account.maildir_path().display().to_string()), ("page", page.to_string())]));
430        }
431        return Ok(());
432    }
433    if account.auth.as_deref() == Some("google") {
434        // Google's access goes back to it, as the window does: the calendars' or the mail's, each its own.
435        let purpose = if account.is_dav() { sioul_sync::google::Purpose::Pim } else { sioul_sync::google::Purpose::Mail };
436        sioul_sync::google::revoke_for(purpose, account.address.as_deref().unwrap_or("")).map_err(|e| e.sentence(&s.tr, id))?;
437    } else if (account.syncs() || account.is_dav()) && !shared_password {
438        secret::forget(account).map_err(|e| e.sentence(&s.tr, id))?;
439    }
440    config::remove_account(&s.config_path, id)?;
441    println!("{}", s.say("account-removed", &[("id", id.to_string()), ("path", account.maildir_path().display().to_string())]));
442    Ok(())
443}
444
445/// A question answered Yes or No at the terminal, No unless the answer
446/// begins with Yes's letter ("y", or the French "o"); without a terminal to
447/// ask at, No.
448fn ask_no(s: &Session, question: &str) -> Result<bool, String> {
449    use std::io::{BufRead, IsTerminal, Write};
450    if !std::io::stdin().is_terminal() {
451        return Ok(false);
452    }
453    print!("{question} {} ", s.tr.text("cli-no-yes", None));
454    std::io::stdout().flush().map_err(|e| e.to_string())?;
455    let mut line = String::new();
456    std::io::stdin().lock().read_line(&mut line).map_err(|e| e.to_string())?;
457    Ok(said_yes(&line))
458}
459
460/// Whether an answer at the terminal is Yes: it begins with "y" (yes) or "o" (oui).
461fn said_yes(line: &str) -> bool {
462    let answer = line.trim().to_lowercase();
463    answer.starts_with('y') || answer.starts_with('o')
464}
465
466/// A question answered Yes or No at the terminal, Yes when Enter alone is
467/// pressed; without a terminal to ask at, Yes.
468fn ask_yes(s: &Session, question: &str) -> Result<bool, String> {
469    use std::io::{BufRead, IsTerminal, Write};
470    if !std::io::stdin().is_terminal() {
471        return Ok(true);
472    }
473    print!("{question} {} ", s.tr.text("cli-yes-no", None));
474    std::io::stdout().flush().map_err(|e| e.to_string())?;
475    let mut line = String::new();
476    std::io::stdin().lock().read_line(&mut line).map_err(|e| e.to_string())?;
477    let answer = line.trim().to_lowercase();
478    Ok(!(answer.starts_with('n')))
479}
480
481/// "example: two new messages.", or the first sync's "example: 212 messages from the last 14 days."
482fn say_report(s: &Session, account: &Account, report: &Report) {
483    let mut args = s.tr.counted(report.new.len());
484    args.set("account", account.id.clone());
485    args.set("days", account.sync_days.unwrap_or(DEFAULT_SYNC_DAYS));
486    println!("{}", s.tr.text(if report.first { "sync-first" } else { "sync-new" }, Some(&args)));
487}
488
489/// Learns which authserv-id the provider writes, while the account has none.
490/// `say_failure` tells when nothing could be learned (once, at the first sync).
491fn learn_provider(s: &Session, account: &Account, say_failure: bool) {
492    match sioul_sync::learn_provider(&s.config_path, account) {
493        Ok(Learned::Id(id)) => println!("{}", s.say("sync-learned", &[("account", account.id.clone()), ("id", crate::one_line(&id))])),
494        Ok(Learned::Nothing) if say_failure => println!("{}", s.say("sync-not-learned", &[("account", account.id.clone())])),
495        Ok(_) => {}
496        Err(e) => eprintln!("{}", crate::plain_lines(&e.to_string())),
497    }
498}
499
500/// Every account Sioul fetches, or the one asked for.
501fn syncing<'a>(config: &'a Config, only: Option<&str>) -> Vec<&'a Account> {
502    config.accounts.iter().filter(|a| a.syncs() && only.is_none_or(|id| a.id == id)).collect()
503}
504
505pub(crate) fn sync_command(s: &Session, only: Option<&str>) -> Result<(), String> {
506    let accounts = syncing(&s.config, only);
507    if accounts.is_empty() {
508        return Err(match only {
509            Some(id) => s.say("account-unknown", &[("id", id.to_string())]),
510            None => s.tr.text("sync-nothing", None),
511        });
512    }
513    let mut arrived = Vec::new();
514    for account in accounts {
515        match secret::password(account).and_then(|p| sioul_sync::sync(account, &p)) {
516            Ok(report) => {
517                say_report(s, account, &report);
518                learn_provider(s, account, report.first);
519                if !report.first {
520                    arrived.extend(report.new);
521                }
522            }
523            Err(e) => eprintln!("{}", crate::plain_lines(&e.sentence(&s.tr, &account.id))),
524        }
525    }
526    // The configuration may have learned provider ids: read it again before judging.
527    let config = Config::load(&s.config_path).unwrap_or_default();
528    println!();
529    print_right_now(s, &triage_files(&config, &arrived));
530    Ok(())
531}
532
533/// Judges freshly fetched files as the Porch would.
534fn triage_files(config: &Config, files: &[PathBuf]) -> Vec<Triaged> {
535    let store = load_store(config);
536    let known = KnownSenders::load(&config.known_senders_path());
537    let senders = porch::Senders::load(config);
538    porch::judge(files, &config.mail_sources(), store.as_ref(), &known, &senders, Zoned::now().timestamp().as_second())
539}
540
541/// Your mail filters on `account`'s server after a fetch (`new`: its inbox's
542/// arrivals, maybe none), as every device that fetches runs them
543/// (docs/client.md, "Filters"): what they did, and what they could not do,
544/// each in a line. Nothing `sioul watch` tells waits for them: it tells codes
545/// alone, which no filter touches.
546fn filter_on_server(s: &Session, config: &Config, account: &Account, new: &[PathBuf]) {
547    let Ok(password) = secret::password(account) else { return };
548    let done = sioul_sync::filters::after_fetch(config, account, &password, new, false);
549    let time = Zoned::now().strftime("%H:%M").to_string();
550    if !done.acted.is_empty() {
551        let mut args = s.tr.counted(done.acted.len());
552        args.set("account", account.id.clone());
553        let line = s.tr.text("filter-cli-acted", Some(&args));
554        println!("{}", s.say("watch-line", &[("time", time.clone()), ("line", line)]));
555    }
556    if let Some(why) = done.said(&s.tr, account.address.as_deref().unwrap_or(&account.id)) {
557        println!("{}", s.say("watch-line", &[("time", time), ("line", why)]));
558    }
559}
560
561pub(crate) fn watch_command(s: &Session) -> Result<(), String> {
562    let accounts: Vec<Account> = syncing(&s.config, None).into_iter().cloned().collect();
563    if accounts.is_empty() {
564        return Err(s.tr.text("sync-nothing", None));
565    }
566    let names = accounts.iter().map(|a| a.id.clone()).collect::<Vec<_>>().join(", ");
567    println!("{}", s.say("watch-started", &[("accounts", names)]));
568    // The spam filter's training by itself, asked each minute as the window asks it, silently.
569    crate::spam::by_itself::every_minute(s);
570    // Never stopped here: Ctrl+C ends the process, and the server keeps nothing open.
571    let control = Arc::new(Control::default());
572    let (sender, receiver) = mpsc::channel::<(String, Result<Report, SyncError>)>();
573    for account in accounts {
574        let password = match secret::password(&account) {
575            Ok(password) => password,
576            Err(e) => {
577                eprintln!("{}", crate::plain_lines(&e.sentence(&s.tr, &account.id)));
578                continue;
579            }
580        };
581        let (sender, control) = (sender.clone(), Arc::clone(&control));
582        std::thread::spawn(move || {
583            let id = account.id.clone();
584            sioul_sync::watch(&account, &password, &control, |result| {
585                let _ = sender.send((id.clone(), result));
586            });
587        });
588    }
589    drop(sender);
590    for (id, result) in receiver {
591        let time = Zoned::now().strftime("%H:%M").to_string();
592        match result {
593            Ok(report) if report.new.is_empty() => {
594                // Nothing new, but a filter's failure to try again, or another device's mark to look at again.
595                let config = Config::load(&s.config_path).unwrap_or_default();
596                if let Some(account) = config.account(&id).filter(|a| !report.first && sioul_sync::filters::due(a)) {
597                    filter_on_server(s, &config, account, &[]);
598                }
599            }
600            Ok(report) => {
601                let config = Config::load(&s.config_path).unwrap_or_default();
602                if let Some(account) = config.account(&id) {
603                    learn_provider(s, account, report.first);
604                }
605                let mut args = s.tr.counted(report.new.len());
606                args.set("account", id.clone());
607                let line = s.tr.text("sync-new", Some(&args));
608                println!("{}", s.say("watch-line", &[("time", time), ("line", line)]));
609                if report.first {
610                    continue;
611                }
612                let config = Config::load(&s.config_path).unwrap_or_default();
613                if let Some(account) = config.account(&id) {
614                    filter_on_server(s, &config, account, &report.new);
615                }
616                let arrived = triage_files(&config, &report.new);
617                print_right_now(s, &arrived);
618                // As the matrix of what reaches you says (as usual, at once at any
619                // time; "On the Porch only" a choice while you sleep), with its
620                // layers read from their files: a code not told waits on the Porch.
621                use sioul_core::attention::{self, Attention, Kind, Level, Row, Slots};
622                let now = Zoned::now();
623                let stamp = now.timestamp().as_second();
624                let overrides = sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path());
625                let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &overrides, &sioul_core::quiet::Blocks::read_now(&now), &now);
626                let moment = attention::Now::of(&mode).layers(Slots::load(&Slots::default_path()).at(&now), attention::dnd_from_files(&config, stamp));
627                if Attention::of(&config).level(Row::Own(Kind::Codes), &moment) != Level::Now {
628                    continue;
629                }
630                for code in view::codes(&arrived, &s.tr) {
631                    if let Err(e) = notify::code(&code.title, &code.body(), None) {
632                        eprintln!("{}", crate::plain_lines(&e));
633                    }
634                }
635            }
636            Err(e) => eprintln!("{}", crate::plain_lines(&s.say("watch-line", &[("time", time), ("line", e.sentence(&s.tr, &id))]))),
637        }
638    }
639    Ok(())
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use sioul_core::i18n::Translator;
646
647    /// `accounts list` says how each account signs in: a password, or Google's sign-in.
648    #[test]
649    fn a_google_account_has_no_password() {
650        let s = Session { config: Config::default(), config_path: PathBuf::new(), tr: Translator::new("en") };
651        let account = |auth: Option<&str>| Account { auth: auth.map(str::to_string), ..Account::imap("me", "me@example.org", "imap.example.org", 993, Security::Tls, None) };
652        // What the keyring gives a Google account is a mark of its sign-in, never a password.
653        assert_eq!(secret_said(&s, &account(Some("google")), Ok("google:2026-10-09".into())), "signed in with Google (the access in the keyring)");
654        assert_eq!(secret_said(&s, &account(None), Ok("hunter2".into())), "password in the keyring");
655        assert_eq!(secret_said(&s, &account(None), Err(SyncError::NoPassword)), "no password yet: `sioul account password me`");
656    }
657
658    /// `accounts list` says a Microsoft account signs in with Microsoft;
659    /// `account password` refuses one at Microsoft's server, saying what to do.
660    #[test]
661    fn a_microsoft_account_has_no_password() {
662        let s = Session { config: Config::default(), config_path: PathBuf::new(), tr: Translator::new("en") };
663        let mut account = Account::imap("me", "me@outlook.com", "outlook.office365.com", 993, Security::Tls, None);
664        account.auth = Some("microsoft".into());
665        assert_eq!(secret_said(&s, &account, Ok("microsoft:1760000000".into())), "signed in with Microsoft (the access in the keyring)");
666        let config: Config = toml::from_str("[[account]]\nid = \"me\"\nkind = \"imap\"\naddress = \"me@outlook.com\"\nhost = \"outlook.office365.com\"\nauth = \"microsoft\"\n").unwrap();
667        let s = Session { config, config_path: PathBuf::new(), tr: Translator::new("en") };
668        let refused = password(&s, "me").unwrap_err();
669        assert!(refused.contains("sioul account sign-in me"), "{refused}");
670    }
671
672    /// The review of 15 October 2026, L3: the mailbox of another login than
673    /// the address is kept on Yes alone, at the terminal too: Enter alone,
674    /// or anything but Yes, is No, and so is no terminal.
675    #[test]
676    fn another_logins_mailbox_is_no_by_default() {
677        use std::io::IsTerminal;
678        assert!(said_yes("y\n") && said_yes("Yes") && said_yes("oui\n") && said_yes(" O "));
679        assert!(!said_yes("\n") && !said_yes("n") && !said_yes("non") && !said_yes("") && !said_yes("maybe"));
680        let s = Session { config: Config::default(), config_path: PathBuf::new(), tr: Translator::new("fr") };
681        assert_eq!(s.tr.text("cli-no-yes", None), "[o/N]");
682        if !std::io::stdin().is_terminal() {
683            assert_eq!(ask_no(&s, "?"), Ok(false));
684        }
685    }
686}