Skip to main content

sioul_app/
gmail.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Google's mail (Gmail, Google Workspace) signed in on Google's page, with
5//! a key of your own: Google gives its mail's scope, a restricted one, to no
6//! key of Sioul's yet. Google's page opens in the system's browser, its
7//! answer comes back to this device (a loopback port, as for the calendars),
8//! then IMAP and SMTP are given an access token (SASL XOAUTH2, sioul-sync's
9//! `sasl.rs`). The other way, an app password, is the usual mail form
10//! (`backend::add_mail`), with Google's words. docs/google.md, "Mail".
11
12use crate::backend::{QtThread, Shared, config_path, load_config, say, show, start_watcher, tr, want_password};
13use crate::pim::google_sentence;
14use cxx_qt_lib::QString;
15use sioul_core::config::{self, Account, AccountKind, Security};
16use sioul_sync::discover::{GOOGLE_IMAP, GOOGLE_SMTP};
17use sioul_sync::google::{self, Purpose};
18use std::sync::Arc;
19use std::sync::atomic::Ordering;
20
21/// The key to sign in with: the one given, else yours kept on this device
22/// (the mail's, else the calendars'). Sioul's own cannot read mail: said so.
23fn key(address: &str, client_id: &str, client_secret: &str) -> Result<(String, String), String> {
24    if !client_id.trim().is_empty() {
25        return Ok((client_id.trim().to_string(), client_secret.trim().to_string()));
26    }
27    google::own_key(address).ok_or_else(|| if google::built_in().is_some() { tr().text("google-mail-built-in", None) } else { say("google-mail-no-key", &[("address", address.to_string())]) })
28}
29
30/// Whether a key of your own is kept on this device for an address: the
31/// form then signs in with it, no ID or secret asked.
32pub(crate) fn has_own_key(address: &str) -> bool {
33    google::own_key(address.trim()).is_some()
34}
35
36/// Adds an address's Google mail, signed in on Google's page, or signs its
37/// account in again (`again`: its id). Google's page opens in the browser;
38/// the account's server is tried with the access given before anything is
39/// kept. On a thread; the form says how it went.
40pub(crate) fn add_google_mail(qt: &QtThread, shared: &Arc<Shared>, address: String, client_id: String, client_secret: String, again: Option<String>) {
41    let (qt, shared) = (qt.clone(), Arc::clone(shared));
42    shared.google_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, signed in with Google already.
48            let existing = match &again {
49                Some(id) => Some(config.every_account().find(|a| &a.id == id && a.kind == AccountKind::Imap && a.auth.as_deref() == Some("google")).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 (client_id, client_secret) = key(&address, &client_id, &client_secret)?;
58            let account = existing.clone().unwrap_or_else(|| {
59                let mut account = Account::imap(&config.free_id(&address), &address, GOOGLE_IMAP, 993, Security::Tls, None);
60                account.auth = Some("google".into());
61                account.smtp_host = Some(GOOGLE_SMTP.into());
62                account.smtp_port = Some(465);
63                account.smtp_security = Security::Tls;
64                account
65            });
66            let id = account.id.clone();
67            let sign_in = google::begin_for(Purpose::Mail, &client_id, &address).map_err(|e| google_sentence(&e, &id))?;
68            let url = sign_in.url.clone();
69            let _ = qt.queue(move |mut sioul| sioul.as_mut().open_url(QString::from(&url)));
70            google::finish(sign_in, &client_id, &client_secret, &address, &shared.google_stop).map_err(|e| google_sentence(&e, &id))?;
71            // The server tried with the access just given, before anything is kept.
72            if let Err(e) = sioul_sync::test(&account, "") {
73                if existing.is_none() {
74                    // Nothing kept: the access goes back, unless the calendars share its key.
75                    let _ = google::revoke_for(Purpose::Mail, &address);
76                }
77                return Err(e.sentence(tr(), &id));
78            }
79            if existing.is_none() {
80                config::add_imap_account(&config_path(), &account)?;
81                config::set_smtp(&config_path(), &id, GOOGLE_SMTP, 465, Security::Tls)?;
82            }
83            Ok(account)
84        })();
85        let line = outcome.as_ref().ok().map(|a| match &again {
86            Some(_) => say("account-signed-in-again", &[("account", a.id.clone())]),
87            None => say("account-added", &[("id", a.id.clone()), ("path", a.maildir_path().display().to_string())]),
88        });
89        if let Ok(account) = outcome.as_ref() {
90            want_password(&shared, &account.id, None);
91            if let Ok(mut statuses) = shared.statuses.lock() {
92                statuses.remove(&account.id);
93            }
94            // A watcher waiting after a refusal tries again at once; none, one starts.
95            start_watcher(&qt, &shared, account.clone());
96        }
97        let error = outcome.err();
98        let _ = qt.queue(move |mut sioul| {
99            sioul.as_mut().set_form_busy(false);
100            match (line, error) {
101                (Some(line), _) => {
102                    sioul.as_mut().set_found(QString::default());
103                    sioul.as_mut().set_status(QString::from(&line));
104                    sioul.as_mut().account_added();
105                }
106                (None, Some(e)) => sioul.as_mut().set_form_error(QString::from(&e)),
107                (None, None) => {}
108            }
109        });
110        show(&qt, &shared);
111    });
112}