Skip to main content

sioul_app/
outlook.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Microsoft's mail (Outlook.com, Hotmail, Live, MSN; Microsoft 365 for work
5//! or school) signed in on Microsoft's page: Microsoft takes no password and
6//! no app password from mail programs any more. Microsoft's page opens in the
7//! system's browser, its answer comes back to this device (a loopback port,
8//! as for Google), then IMAP and SMTP are given an access token (SASL
9//! XOAUTH2, sioul-sync's `sasl.rs`). The mailbox is tried before anything is
10//! kept (`microsoft::sign_in`). docs/microsoft.md.
11
12use crate::backend::{QtThread, Shared, config_path, load_config, say, show, start_watcher, tell, tr, want_password};
13use cxx_qt_lib::QString;
14use sioul_core::config::{self, Account, AccountKind, Security};
15use sioul_sync::discover::MicrosoftKind;
16use sioul_sync::{Control, SyncError, microsoft, secret};
17use std::sync::Arc;
18use std::sync::atomic::Ordering;
19use std::time::{Duration, Instant};
20
21/// How long the question about another login's mailbox waits for your
22/// answer: as long as Microsoft's page waits.
23const ANSWER_WITHIN: Duration = Duration::from_secs(300);
24/// How long a removal waits for the account's watcher to end before
25/// deleting its mail all the same: a fetch round, stopped, ends well before.
26const WATCHER_ENDS_WITHIN: Duration = Duration::from_secs(30 * 60);
27
28/// Whether an account is Microsoft's mail: signed in with Microsoft, or
29/// added before with a password at Microsoft's server (which no longer takes it).
30fn at_microsoft(account: &Account) -> bool {
31    account.kind == AccountKind::Imap && (account.is_microsoft() || account.host.as_deref().is_some_and(microsoft::is_imap_host))
32}
33
34/// Adds an address's Microsoft mail, signed in on Microsoft's page, or signs
35/// its account in again (`again`: its id; an account added with a password
36/// at Microsoft's server is switched to Microsoft's sign-in, its password
37/// forgotten). `kind`: what discovery found ("personal", "work", "unknown");
38/// `client_id` and `tenant`: an application of your own, else the one kept
39/// for this address, else Sioul's. On a thread; the form says how it went.
40pub(crate) fn add_microsoft_mail(qt: &QtThread, shared: &Arc<Shared>, address: String, kind: String, client_id: String, tenant: String, again: Option<String>) {
41    let (qt, shared) = (qt.clone(), Arc::clone(shared));
42    shared.oauth_stop.store(false, Ordering::Relaxed);
43    std::thread::spawn(move || {
44        let address = address.trim().to_ascii_lowercase();
45        let config = load_config();
46        let outcome = (|| -> Result<Account, String> {
47            // Signed in again: the account as it is.
48            let existing = match &again {
49                Some(id) => Some(config.every_account().find(|a| &a.id == id && at_microsoft(a)).cloned().ok_or_else(|| say("account-unknown", &[("id", id.clone())]))?),
50                None => None,
51            };
52            if existing.is_none()
53                && let Some(have) = config.every_account().find(|a| a.address.as_deref() == Some(address.as_str()) && matches!(a.kind, AccountKind::Imap | AccountKind::Jmap))
54            {
55                return Err(say("account-address-exists", &[("address", address.clone()), ("id", have.id.clone())]));
56            }
57            let account = existing.clone().unwrap_or_else(|| Account::imap(&config.free_id(&address), &address, microsoft::IMAP_HOST, 993, Security::Tls, None));
58            let login = account.login().unwrap_or(&address).to_string();
59            // Its kind: what its sign-in found before, else what discovery found.
60            let kind = match &existing {
61                Some(a) if a.is_microsoft() && !a.is_microsoft_work() => MicrosoftKind::Personal,
62                Some(a) if a.is_microsoft() => MicrosoftKind::Work,
63                _ => MicrosoftKind::parse(&kind),
64            };
65            let label = address.clone();
66            let app = microsoft::app_for(&login, &client_id).map_err(|e| e.sentence(tr(), &label))?.ok_or_else(|| tr().text("microsoft-no-app", None))?;
67            let authority = microsoft::authority_for(&login, kind, &tenant).map_err(|e| e.sentence(tr(), &label))?;
68            let opener = qt.clone();
69            let show_page = move |url: &str| {
70                let url = url.to_string();
71                let _ = opener.queue(move |mut sioul| sioul.as_mut().open_url(QString::from(&url)));
72            };
73            // The logins your other Microsoft accounts sign in as: never shared (`microsoft::sign_in`).
74            let taken: Vec<String> = config.every_account().filter(|a| a.is_microsoft() && Some(&a.id) != again.as_ref()).filter_map(|a| a.login().map(|l| l.trim().to_lowercase())).collect();
75            let signed = microsoft::sign_in(account, &app, &authority, &taken, show_page, &shared.oauth_stop).map_err(|e| e.sentence(tr(), &label))?;
76            // Only the mailbox of the login Microsoft's page signed in opened: kept on your Yes alone.
77            let yes = signed.other_login.as_deref().is_none_or(|other| asked(&qt, &shared, &address, other));
78            let (account, _) = signed.answer(yes).map_err(|e| e.sentence(tr(), &label))?;
79            match &existing {
80                None => {
81                    config::add_imap_account(&config_path(), &account)?;
82                    config::set_sign_in(&config_path(), &account)?;
83                }
84                Some(before) => {
85                    config::set_sign_in(&config_path(), &account)?;
86                    // A password kept before, which Microsoft no longer took: gone from the keyring.
87                    if !before.is_microsoft() {
88                        let _ = secret::forget(before);
89                    }
90                }
91            }
92            Ok(account)
93        })();
94        let line = outcome.as_ref().ok().map(|a| match &again {
95            Some(_) => say("account-signed-in-again-microsoft", &[("account", a.id.clone())]),
96            None => say("account-added", &[("id", a.id.clone()), ("path", a.maildir_path().display().to_string())]),
97        });
98        if let Ok(account) = outcome.as_ref() {
99            want_password(&shared, &account.id, None);
100            if let Ok(mut statuses) = shared.statuses.lock() {
101                statuses.remove(&account.id);
102            }
103            // Its watcher knew the account as it was (a password, another login): another starts.
104            if let Some(control) = shared.watchers.lock().ok().and_then(|mut w| w.remove(&account.id)) {
105                control.stop();
106            }
107            start_watcher(&qt, &shared, account.clone());
108        }
109        let error = outcome.err();
110        let _ = qt.queue(move |mut sioul| {
111            sioul.as_mut().set_form_busy(false);
112            match (line, error) {
113                (Some(line), _) => {
114                    sioul.as_mut().set_found(QString::default());
115                    sioul.as_mut().set_status(QString::from(&line));
116                    sioul.as_mut().account_added();
117                }
118                (None, Some(e)) => sioul.as_mut().set_form_error(QString::from(&e)),
119                (None, None) => {}
120            }
121        });
122        show(&qt, &shared);
123    });
124}
125
126/// Asks, in the form, whether the mailbox of `other`, the login Microsoft's
127/// page signed in, serves `address`, and waits for the answer: Yes or No,
128/// and No on Cancel or after five minutes without one.
129fn asked(qt: &QtThread, shared: &Shared, address: &str, other: &str) -> bool {
130    let (answer, answered) = std::sync::mpsc::channel();
131    match shared.microsoft_answer.lock() {
132        Ok(mut waiting) => *waiting = Some(answer),
133        Err(_) => return false,
134    }
135    let question = say("microsoft-other-login-question", &[("address", address.to_string()), ("login", other.to_string())]);
136    let _ = qt.queue(move |mut sioul| sioul.as_mut().microsoft_question(QString::from(&question)));
137    let until = Instant::now() + ANSWER_WITHIN;
138    let yes = loop {
139        match answered.recv_timeout(Duration::from_millis(200)) {
140            Ok(yes) => break yes,
141            Err(std::sync::mpsc::RecvTimeoutError::Timeout) if !shared.oauth_stop.load(Ordering::Relaxed) && Instant::now() < until => {}
142            Err(_) => break false,
143        }
144    };
145    if let Ok(mut waiting) = shared.microsoft_answer.lock() {
146        *waiting = None;
147    }
148    yes
149}
150
151/// Whether an application of your own is kept on this device for an
152/// address: the form then signs in with it, no id asked.
153pub(crate) fn has_own_app(address: &str) -> bool {
154    microsoft::own_app(address.trim()).is_some()
155}
156
157/// What an organisation's administrator does, once, for Sioul to read a
158/// work or school mailbox, with the link that approves the application in
159/// use for the organisation: yours kept on this device, else Sioul's, and
160/// the directory kept from an earlier sign-in, else the address's domain.
161/// Reads the keyring: off the window's thread (discovery's). Empty when
162/// there is no application to approve yet.
163pub(crate) fn admin_text(address: &str) -> String {
164    let address = address.trim().to_ascii_lowercase();
165    let Ok(Some(app)) = microsoft::app_for(&address, "") else { return String::new() };
166    let tenant = microsoft::grant(&address).filter(|g| !g.personal).map(|g| g.tenant).unwrap_or_else(|| address.rsplit('@').next().unwrap_or("").to_string());
167    say("microsoft-admin-steps", &[("link", microsoft::admin_consent_url(&app, &tenant))])
168}
169
170/// The same for an application id typed in the form, and the address's
171/// domain: no keyring, for the window's thread. Empty while the id typed is
172/// not one.
173pub(crate) fn admin_text_for(address: &str, client_id: &str) -> String {
174    let client_id = client_id.trim();
175    if !microsoft::is_app_id(client_id) {
176        return String::new();
177    }
178    let domain = address.trim().to_ascii_lowercase().rsplit('@').next().unwrap_or("").to_string();
179    say("microsoft-admin-steps", &[("link", microsoft::admin_consent_url(client_id, &domain))])
180}
181
182/// A Microsoft account removed, off the window's thread (the keyring, and
183/// a mail folder that may be large): its access forgotten here (Microsoft
184/// offers no way to give it back), the account out of the configuration,
185/// and, when you said so, the mail this device keeps for it (its folder, the
186/// AI's answers, the spam filter's corpus), once its watcher (`watcher`,
187/// stopped) has ended, so that no fetch writes there again; no watcher
188/// starts for it meanwhile (`Shared::removing`). The status line says it
189/// when done, with Microsoft's page where you withdraw the consent.
190pub(crate) fn remove(qt: &QtThread, shared: &Arc<Shared>, account: Account, delete_mail: bool, watcher: Option<Arc<Control>>) {
191    if let Ok(mut removing) = shared.removing.lock() {
192        removing.insert(account.id.clone());
193    }
194    let (qt, shared) = (qt.clone(), Arc::clone(shared));
195    std::thread::spawn(move || {
196        let line = removed(&account, delete_mail, watcher.as_deref()).unwrap_or_else(|e| e);
197        if let Ok(mut removing) = shared.removing.lock() {
198            removing.remove(&account.id);
199        }
200        tell(&qt, &shared, line);
201        show(&qt, &shared);
202    });
203}
204
205/// `remove`'s work, on its thread: the line that says it.
206fn removed(account: &Account, delete_mail: bool, watcher: Option<&Control>) -> Result<String, String> {
207    let id = account.id.clone();
208    let page = microsoft::forget(account.login().unwrap_or(&id), !account.is_microsoft_work()).map_err(|e: SyncError| e.sentence(tr(), &id))?;
209    config::remove_account(&config_path(), &id)?;
210    if !delete_mail {
211        return Ok(say("account-removed-microsoft", &[("id", id), ("path", account.maildir_path().display().to_string()), ("page", page.to_string())]));
212    }
213    delete_after(watcher, WATCHER_ENDS_WITHIN, || {
214        sioul_core::maildir::forget_account(account)?;
215        #[cfg(not(target_os = "android"))]
216        sioul_learn::Dirs::standard().forget_account(&account.id).map_err(|e| e.to_string())?;
217        Ok(())
218    })?;
219    Ok(say("account-removed-microsoft-mail", &[("id", id), ("page", page.to_string())]))
220}
221
222/// Deletes (`delete`) once the watcher, stopped, has ended (at most
223/// `within`): a fetch it had under way would write the folder again.
224fn delete_after(watcher: Option<&Control>, within: Duration, delete: impl FnOnce() -> Result<(), String>) -> Result<(), String> {
225    if let Some(watcher) = watcher {
226        watcher.wait_ended(within);
227    }
228    delete()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use std::sync::Mutex;
235
236    /// The review of 15 October 2026, L5: a removed account's mail is
237    /// deleted once its watcher has ended, never while a fetch it had under
238    /// way may still write there; without a watcher, at once.
239    #[test]
240    fn mail_is_deleted_after_its_watcher_ends() {
241        let control = Control::default();
242        control.stop();
243        let order = Mutex::new(Vec::new());
244        std::thread::scope(|s| {
245            // A fetch under way when it was stopped: it writes twice more, then ends.
246            s.spawn(|| {
247                for _ in 0..2 {
248                    std::thread::sleep(Duration::from_millis(100));
249                    order.lock().unwrap().push("written");
250                }
251                control.end();
252            });
253            delete_after(Some(&control), Duration::from_secs(10), || {
254                order.lock().unwrap().push("deleted");
255                Ok(())
256            })
257            .unwrap();
258        });
259        assert_eq!(*order.lock().unwrap(), ["written", "written", "deleted"]);
260        let mut done = false;
261        delete_after(None, Duration::ZERO, || {
262            done = true;
263            Ok(())
264        })
265        .unwrap();
266        assert!(done, "no watcher: at once");
267    }
268}