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