Skip to main content

sioul_sync/
lib.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Sioul's side that talks to the world: mail servers, calendars and
5//! contacts, the keyring, other services (Google, GitHub, Bitwarden), and the
6//! sharing between your devices.
7//!
8//! Fetching changes nothing on the server: folders are opened with EXAMINE
9//! and messages fetched with BODY.PEEK, so read flags, folders and mail stay
10//! as your other apps left them (RFC 9051 §6.3.3 and §6.4.5). The server is
11//! written to only when you act ([`mailbox::act`], [`send`]). Connections are
12//! encrypted from the first byte or upgraded by STARTTLS before the login;
13//! there is no plain option.
14//!
15//! What to do with what comes is decided in
16//! [sioul-core](../sioul_core/index.html): this crate brings what servers hold
17//! into the plain files the core reads (a Maildir, vdir folders), and takes
18//! back to them what you did. The window ([sioul-app](../sioul_app/index.html))
19//! and the command line ([sioul](../sioul/index.html)) call it, and so does
20//! the spam filter's training ([sioul-learn](../sioul_learn/index.html)) for
21//! the mail it learns from.
22//!
23//! The functions that reach a server block until it answers: those that speak
24//! IMAP make a small `tokio` runtime of their own and wait for it, those that
25//! speak HTTP use `ureq`, which blocks. The window calls them off its own
26//! thread.
27//!
28//! # Where to start reading
29//!
30//! - **Fetching mail**: [`fetch::sync`] fetches what is new since the last
31//!   time; [`fetch::watch`] keeps the inbox open (IDLE) and fetches what
32//!   arrives, until stopped.
33//! - **Acting and sending**: [`mailbox::act`] (a message marked read, flagged,
34//!   moved, put in the trash, on the server), [`send::send`].
35//! - **What went wrong**: [`SyncError`], said in your language by
36//!   [`SyncError::sentence`].
37//! - **Contacts and calendars**: [`dav::sync`], both ways.
38//! - **Sharing between devices**: [`share::exchange`], one exchange with the
39//!   shared folder.
40//!
41//! Names such as docs/database.md are the design notes in the repository's
42//! `docs/` folder. The website shows them too, under the same name:
43//! <https://sioul.aurelienpierre.com/dev/database.html>.
44//!
45//! # Modules, by theme
46//!
47//! ## Mail
48//!
49//! - [`discover`](mod@discover): finding an account's servers from its address, as Thunderbird does.
50//! - [`scout`]: what a server offers for one address: mail, calendars and contacts, a Nextcloud's apps.
51//! - [`imap`]: an encrypted, logged-in IMAP session.
52//! - [`sasl`]: signing in to IMAP and SMTP with an access token instead of a password (XOAUTH2).
53//! - [`microsoft`]: Microsoft's mail (Outlook.com, Hotmail, Live, MSN, Microsoft 365), signed in with OAuth 2.0.
54//! - `loopback` (not public): the door your browser comes back to after a sign-in on Google's or Microsoft's page.
55//! - [`fetch`]: new mail fetched read-only into the account's Maildir, once or as it arrives.
56//! - [`mailbox`]: an account's folders, and what you do to its messages on the server.
57//! - [`search`]: searching the mail a server holds and this device does not; a message found there brought here.
58//! - [`send`]: sending through SMTP submission, then a copy into Sent.
59//! - [`verify`]: Sioul's own checks of who sent a message: SPF, DKIM, DMARC, ARC, reverse DNS.
60//! - [`unsubscribe`]: leaving a mailing list in one click, the request itself (RFC 8058).
61//! - [`keys`]: looking up someone's OpenPGP key, only when you ask.
62//! - [`securitykey`]: your security key's certificate, looked up when you ask; GnuPG asked to let the key go.
63//! - [`antivirus`]: checking a file before it opens.
64//! - [`shield_ai`]: the AI reading of a shielded address, when you allow it.
65//! - [`notify`]: the desktop's notifications: a verified code, a reminder, new mail, the time running.
66//!
67//! ## Contacts, calendars and other services
68//!
69//! - [`dav`]: contacts and calendars on a server (CardDAV, CalDAV), synced both ways with the folders on disk.
70//! - [`google`]: Google's calendars and contacts, signed in with OAuth 2.0.
71//! - [`google_tasks`]: Google Tasks, over its REST API.
72//! - [`github`]: GitHub's REST API, read only: the issues and pull requests that are yours.
73//! - [`bitwarden`]: a Bitwarden vault, read only: logins and their one-time codes, to fill a site's form.
74//! - [`favicon`]: a site's own icon, asked of the site itself.
75//! - [`geocode`]: where an address is, asked of OpenStreetMap's geocoder.
76//! - [`weather`]: the weather, asked of Open-Meteo.
77//! - [`ocr`]: a scan's text, read by the system's own programs.
78//!
79//! ## Passwords
80//!
81//! - [`secret`]: passwords in the system keyring, never in a file.
82//! - `android`, built for Android only (so not shown here): Android's KeyStore and the
83//!   network's DNS servers, reached through Java.
84//!
85//! ## Sharing between your devices
86//!
87//! - [`share`]: what only this device keeps, shared with your other devices through a folder your sync app carries.
88//! - [`blobs`]: notes and papers in the shared folder, sealed, one file at a time.
89//! - [`history`]: what a shared file held before another device's change, kept on this device.
90//! - [`devices`]: each device says how it is: when it started, closed, last read and wrote.
91//! - [`lease`]: one computer at a time for a part of the work: reminding doses, numbering invoices.
92//! - [`remote`]: the shared folder read from its server too, when the sync app is late.
93//! - [`loginflow`]: signing in to a Nextcloud through its own page in the browser, for a device joining the others.
94//!
95//! ## The computer
96//!
97//! - [`disk`]: the room left on a disk.
98//! - [`dnd`]: the desktop's do-not-disturb during Sioul's pauses (Linux and the BSDs).
99//! - [`power`]: on mains power or not, saving power or not, idle or not, a metered connection or not: for the spam filter's training by itself.
100
101// The test feature accepts any server certificate: never in a build made for use.
102#[cfg(all(feature = "insecure-test-tls", not(debug_assertions)))]
103compile_error!("insecure-test-tls accepts any certificate: for test builds only, never with --release");
104
105#[cfg(target_os = "android")]
106pub mod android;
107pub mod antivirus;
108pub mod bitwarden;
109pub mod blobs;
110#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
111pub mod bus;
112#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
113pub mod colord;
114pub mod dav;
115pub mod devices;
116pub mod discover;
117pub mod favicon;
118pub mod disk;
119// The desktop's do-not-disturb during Sioul's pauses (Plasma's inhibition, dconf's changes).
120#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
121pub mod dnd;
122pub mod fetch;
123pub mod filters;
124pub mod geocode;
125pub mod github;
126pub mod google;
127pub mod google_tasks;
128pub mod history;
129// The encrypted, logged-in IMAP session: also the spam filter's corpus (sioul-learn) on desktops.
130pub mod imap;
131pub mod keys;
132pub mod lease;
133// Signing in to a Nextcloud through its own page in the browser (Login Flow v2): a new device joins through it.
134pub mod loginflow;
135// The door a browser comes back to after Google's or Microsoft's sign-in page (RFC 8252).
136mod loopback;
137pub mod mailbox;
138// Microsoft's mail, signed in with OAuth 2.0 (docs/microsoft.md).
139pub mod microsoft;
140pub mod notify;
141pub mod ocr;
142// What the computer says of itself (power, saving, idle, a metered connection): the spam filter's training by itself.
143pub mod power;
144// The sharing folder fetched from its server too, beside the sync app (docs/database.md).
145pub mod remote;
146// Signing in to IMAP and SMTP with an access token (XOAUTH2): Google's mail, Microsoft's.
147pub mod sasl;
148pub mod scout;
149pub mod search;
150pub mod secret;
151pub mod securitykey;
152pub mod send;
153pub mod share;
154// Each wait for the network bounded, on ureq's transport (`ureq::unversioned`, no semver): this module alone.
155mod stalls;
156pub mod trigger;
157// texts: their lines sealed at rest (SMS phase b, docs/texts.md).
158pub mod textseal;
159pub mod shield_ai;
160pub mod unsubscribe;
161pub mod verify;
162pub mod weather;
163
164pub use discover::{Found, FoundBy, Smtp, discover, discover_smtp};
165pub use fetch::{Control, Report, inbox, sync, test, watch};
166
167use sioul_core::config::{self, Account, Config};
168use sioul_core::i18n::{self, Translator};
169use sioul_core::{maildir, trust};
170use std::path::Path;
171
172/// Where Google makes app passwords: Gmail refuses the usual password over IMAP.
173pub const GMAIL_APP_PASSWORDS: &str = "https://myaccount.google.com/apppasswords";
174
175/// Sioul's words for what this crate says itself (a hint, a page in your
176/// browser): the configuration's language, else the session's, as the window.
177pub(crate) fn translator() -> Translator {
178    // Tests never read the configuration of whoever runs them.
179    let configured = if cfg!(test) { None } else { Config::load(&config::default_path()).ok().and_then(|c| c.language) };
180    Translator::new(&configured.unwrap_or_else(i18n::system_language))
181}
182
183/// Another program (a scanner, a reader of scans), run unseen: on Windows,
184/// without a console window flashing over Sioul's (CREATE_NO_WINDOW).
185pub(crate) fn command(program: impl AsRef<std::ffi::OsStr>) -> std::process::Command {
186    let command = std::process::Command::new(program);
187    #[cfg(windows)]
188    let command = {
189        use std::os::windows::process::CommandExt;
190        let mut command = command;
191        command.creation_flags(0x0800_0000);
192        command
193    };
194    command
195}
196
197/// Whether an address is Gmail's.
198pub fn is_gmail(address: &str) -> bool {
199    let address = address.trim().to_ascii_lowercase();
200    address.ends_with("@gmail.com") || address.ends_with("@googlemail.com")
201}
202
203/// The password as the server wants it. Google shows app passwords as four
204/// groups of four letters ("abcd efgh ijkl mnop"); the spaces are not part of it.
205pub fn tidy_password(host: &str, typed: &str) -> String {
206    let groups: Vec<&str> = typed.split_whitespace().collect();
207    let lengths: Vec<usize> = groups.iter().map(|g| g.len()).collect();
208    let shaped = lengths == [16] || lengths == [4, 4, 4, 4];
209    let letters = groups.iter().all(|g| g.chars().all(|c| c.is_ascii_alphabetic()));
210    if host.eq_ignore_ascii_case("imap.gmail.com") && shaped && letters { groups.concat() } else { typed.to_string() }
211}
212
213/// What learning the provider's authserv-id gave.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum Learned {
216    /// The account already trusts an id.
217    Known,
218    /// Learned and written into the configuration.
219    Id(String),
220    /// Enough mail, but no id holds a majority: the provider records no checks.
221    Nothing,
222    /// Too little mail to tell yet.
223    TooEarly,
224}
225
226/// Learns which authserv-id the provider writes, from the account's mail, and
227/// records it in the configuration (see `trust::learn_provider_id`).
228pub fn learn_provider(config_path: &Path, account: &Account) -> Result<Learned, String> {
229    if !account.trusted_authserv_ids.is_empty() {
230        return Ok(Learned::Known);
231    }
232    let cards = maildir::read_messages(&account.maildir_path());
233    match trust::learn_provider_id(cards.iter().map(|c| &c.headers)) {
234        Some(id) => {
235            config::set_trusted_ids(config_path, &account.id, std::slice::from_ref(&id))?;
236            Ok(Learned::Id(id))
237        }
238        None if cards.len() >= 20 => Ok(Learned::Nothing),
239        None => Ok(Learned::TooEarly),
240    }
241}
242
243/// The sentences of `SyncError::Said`: what Sioul found itself, said in your
244/// language, never in English inside a translated sentence.
245pub mod said {
246    /// A folder made without a name.
247    pub const FOLDER_NAME: &str = "sync-said-folder-name";
248    /// No folder of that name on this account (the name follows).
249    pub const NO_FOLDER: &str = "sync-said-no-folder";
250    /// A folder Sioul keeps no copy of (the name follows).
251    pub const NOT_KEPT: &str = "sync-said-not-kept";
252    /// The inbox, Sent, Drafts, the trash, the junk or the archive: never deleted (the name follows).
253    pub const PURPOSE: &str = "sync-said-purpose";
254    /// A file that no fetch of Sioul's wrote.
255    pub const NOT_FETCHED: &str = "sync-said-not-fetched";
256    /// A file in no folder Sioul knows for the account.
257    pub const UNKNOWN_FOLDER: &str = "sync-said-unknown-folder";
258    /// The folder's UIDVALIDITY changed since the message was fetched.
259    pub const RENUMBERED: &str = "sync-said-renumbered";
260    /// The message is gone from the server.
261    pub const GONE: &str = "sync-said-gone";
262    /// A message seen on the server only, to be brought here before this act.
263    pub const OPEN_FIRST: &str = "sync-said-open-first";
264}
265
266/// What went wrong, in the categories the interface says in your language.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub enum SyncError {
269    /// The text given is not an address.
270    BadAddress,
271    /// No settings found for this domain.
272    NotFound(String),
273    /// The account has no server in the configuration (a portal, or no `host`).
274    NoServer,
275    /// No password in the keyring for this account yet.
276    NoPassword,
277    /// The system keyring did not answer.
278    Keyring(String),
279    /// The server could not be reached, or the connection dropped.
280    Network(String),
281    /// The server's certificate could not be checked: nothing was sent.
282    Tls(String),
283    /// The server refused the login.
284    Login(String),
285    /// The server wants an app password, not the account's usual one (Gmail, iCloud…).
286    AppPassword(String),
287    /// The server refused a command.
288    Server(String),
289    /// The mail or the sync state could not be written.
290    Disk(String),
291    /// The sending server refused the message.
292    Refused(String),
293    /// Sent, but its copy could not be filed in Sent.
294    NotFiled(String),
295    /// The message could not be written (no recipient, an attachment gone): said as is.
296    Message(String),
297    /// A server's answer named an address on another server (a calendar, an
298    /// item, the account's principal): it was not asked, and the login was
299    /// not sent there.
300    Elsewhere(String),
301    /// What Sioul found itself, never a server's words: its sentence in your
302    /// language (`said::RENUMBERED`, "sync-said-renumbered"), and what it
303    /// names, if anything (a folder's name). Matched as `Server` was, which
304    /// held these in English before (the review of 5 October 2026).
305    Said(&'static str, String),
306}
307
308impl SyncError {
309    /// The message that says it ("sync-error-network").
310    /// What went wrong, as a code and never its detail (no address, no
311    /// name): for the phone's log ("sync-error-login").
312    pub fn code(&self) -> &'static str {
313        self.message_id()
314    }
315
316    fn message_id(&self) -> &'static str {
317        match self {
318            SyncError::BadAddress => "sync-error-address",
319            SyncError::NotFound(_) => "sync-error-not-found",
320            SyncError::NoServer => "sync-error-no-server",
321            SyncError::NoPassword => "sync-error-no-password",
322            SyncError::Keyring(_) => "sync-error-keyring",
323            SyncError::Network(_) => "sync-error-network",
324            SyncError::Tls(_) => "sync-error-tls",
325            SyncError::Login(d) if d == google::SIGN_IN_AGAIN => "sync-error-google-again",
326            SyncError::Login(d) if d == google::IN_TESTING => "sync-error-google-testing",
327            SyncError::Login(d) if d == github::TOKEN_REFUSED => "sync-error-github-token",
328            // Microsoft's refusals, each with what to do (microsoft.rs).
329            SyncError::Login(d) if microsoft::message_of(d).is_some() => microsoft::message_of(d).unwrap_or("sync-error-login"),
330            SyncError::Login(_) => "sync-error-login",
331            SyncError::AppPassword(_) => "sync-error-app-password",
332            SyncError::Server(_) => "sync-error-server",
333            SyncError::Disk(_) => "sync-error-disk",
334            SyncError::Refused(_) => "send-error-refused",
335            SyncError::NotFiled(_) => "send-error-not-filed",
336            SyncError::Message(_) => "send-error-message",
337            SyncError::Elsewhere(_) => "sync-error-elsewhere",
338            SyncError::Said(id, _) => id,
339        }
340    }
341
342    /// The technical detail, shown after the sentence.
343    pub fn detail(&self) -> &str {
344        match self {
345            SyncError::BadAddress | SyncError::NoServer | SyncError::NoPassword => "",
346            SyncError::NotFound(d)
347            | SyncError::Keyring(d)
348            | SyncError::Network(d)
349            | SyncError::Tls(d)
350            | SyncError::Login(d)
351            | SyncError::AppPassword(d)
352            | SyncError::Server(d)
353            | SyncError::Disk(d)
354            | SyncError::Refused(d)
355            | SyncError::NotFiled(d)
356            | SyncError::Message(d)
357            | SyncError::Elsewhere(d)
358            | SyncError::Said(_, d) => d,
359        }
360    }
361
362    /// Whether trying again later can help: a refused password does not get
363    /// better by retrying, and retries can lock the account.
364    pub fn is_lasting(&self) -> bool {
365        matches!(
366            self,
367            SyncError::Login(_) | SyncError::AppPassword(_) | SyncError::Tls(_) | SyncError::NoServer | SyncError::NoPassword | SyncError::BadAddress
368        )
369    }
370
371    /// Whether a password, given again, can help: none kept here yet, or the
372    /// server refused the one kept (or wants an app password).
373    pub fn wants_password(&self) -> bool {
374        matches!(self, SyncError::NoPassword | SyncError::Login(_) | SyncError::AppPassword(_))
375    }
376
377    /// The sentence, in your language: "home: the server cannot be reached (timed out)."
378    pub fn sentence(&self, tr: &Translator, account: &str) -> String {
379        let mut args = i18n::args();
380        args.set("account", account.to_string());
381        args.set("detail", self.detail().to_string());
382        tr.text(self.message_id(), Some(&args))
383    }
384}
385
386impl std::fmt::Display for SyncError {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        write!(f, "{}: {}", self.message_id(), self.detail())
389    }
390}
391
392impl std::error::Error for SyncError {}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn google_app_passwords_lose_their_spaces() {
400        assert_eq!(tidy_password("imap.gmail.com", "abcd efgh ijkl mnop"), "abcdefghijklmnop");
401        assert_eq!(tidy_password("imap.gmail.com", " abcdefghijklmnop "), "abcdefghijklmnop");
402        // Anything else is kept as typed, spaces included.
403        assert_eq!(tidy_password("imap.gmail.com", "my long pass phrase"), "my long pass phrase");
404        assert_eq!(tidy_password("mail.example.org", "abcd efgh ijkl mnop"), "abcd efgh ijkl mnop");
405        assert!(is_gmail("Someone@GMail.com") && !is_gmail("someone@example.org"));
406    }
407
408    /// What Sioul found itself is said in your language, never as English
409    /// inside a translated sentence (the review of 5 October 2026): "the
410    /// server renumbered this folder" was the detail of "le serveur a refusé
411    /// une commande (…)".
412    #[test]
413    fn what_sioul_found_itself_is_said_in_your_language() {
414        let fr = Translator::new("fr");
415        let renumbered = SyncError::Said(said::RENUMBERED, String::new()).sentence(&fr, "travail");
416        assert!(renumbered.starts_with("travail") && renumbered.contains("renuméroté") && !renumbered.contains("renumbered"), "{renumbered}");
417        let folder = SyncError::Said(said::NO_FOLDER, "Banque".into()).sentence(&fr, "travail");
418        assert!(folder.contains("«\u{202f}Banque\u{202f}»"), "{folder}");
419        let en = Translator::new("en");
420        assert_eq!(SyncError::Said(said::GONE, String::new()).sentence(&en, "work"), "work: this message is no longer on the server.");
421        // Never a lasting error: trying again later may help.
422        assert!(!SyncError::Said(said::RENUMBERED, String::new()).is_lasting());
423    }
424}