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}