Skip to main content

sioul_sync/
imap.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! An encrypted, logged-in IMAP session.
5//!
6//! TLS is rustls with the ring provider, checked against the system's
7//! certificates (Mozilla's list when a system has none). STARTTLS is done here,
8//! by hand, before the IMAP client starts: the login never travels in clear.
9//! An account signed in with Google or Microsoft gives an access token
10//! instead of a password (SASL XOAUTH2, `sasl.rs`).
11
12use crate::SyncError;
13use crate::sasl::{ImapXoauth2, OAuth};
14use async_imap::{Client, Session};
15use rustls::pki_types::ServerName;
16use sioul_core::config::{Account, Security};
17use std::sync::{Arc, OnceLock};
18use std::time::Duration;
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21use tokio::time::timeout;
22use tokio_rustls::TlsConnector;
23use tokio_rustls::client::TlsStream;
24
25pub type Imap = Session<TlsStream<TcpStream>>;
26
27pub(crate) const CONNECT: Duration = Duration::from_secs(20);
28pub const COMMAND: Duration = Duration::from_secs(60);
29
30/// Where to connect, and as whom.
31#[derive(Debug, Clone)]
32pub struct Server {
33    pub host: String,
34    pub port: u16,
35    pub security: Security,
36    pub login: String,
37    /// Signed in with an access token rather than a password.
38    pub oauth: Option<OAuth>,
39}
40
41impl Server {
42    pub fn of(account: &Account) -> Result<Server, SyncError> {
43        Ok(Server {
44            host: account.host.clone().ok_or(SyncError::NoServer)?,
45            port: account.port_or_default(),
46            security: account.security,
47            login: account.login().ok_or(SyncError::NoServer)?.to_string(),
48            oauth: OAuth::of(account),
49        })
50    }
51}
52
53/// Connects, encrypts and logs in: with the password, or, for an account
54/// signed in with OAuth, with an access token (the password is then unused).
55pub async fn open(server: &Server, password: &str) -> Result<Imap, SyncError> {
56    if let Some(oauth) = server.oauth {
57        // Microsoft's token to Microsoft's server alone, before any is asked.
58        oauth.may_reach(&server.host, false)?;
59        return sign_in_with_tokens(&server.login, || connect(server), |fresh| oauth.token_async(&server.login, fresh), oauth).await;
60    }
61    let client = connect(server).await?;
62    within(COMMAND, client.login(&server.login, password)).await?.map_err(|(e, _)| login_error(e, &server.host))
63}
64
65/// AUTHENTICATE XOAUTH2 on connections `connect` makes, with the tokens
66/// `token` gives: the one kept first; refused, a fresh one, once (the one kept
67/// may have ended early: a password changed, the access withdrawn); refused
68/// again, the access must be given again. A refusal no token changes
69/// (Microsoft's mailbox closed to IMAP) is said at once (`OAuth::not_connected`).
70pub(crate) async fn sign_in_with_tokens<T, C, CF, K, KF>(login: &str, connect: C, token: K, oauth: OAuth) -> Result<Session<T>, SyncError>
71where
72    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + std::fmt::Debug + Send,
73    C: Fn() -> CF,
74    CF: Future<Output = Result<Client<T>, SyncError>>,
75    K: Fn(bool) -> KF,
76    KF: Future<Output = Result<String, SyncError>>,
77{
78    let mut refusal = String::new();
79    for fresh in [false, true] {
80        let token = token(fresh).await?;
81        let client = connect().await?;
82        let mut sasl = ImapXoauth2::new(login, &token);
83        match within(COMMAND, client.authenticate("XOAUTH2", &mut sasl)).await? {
84            Ok(session) => return Ok(session),
85            Err((async_imap::error::Error::No(m) | async_imap::error::Error::Bad(m), _)) => {
86                refusal = sasl.refusal.clone().unwrap_or_else(|| server_words(&m));
87                if let Some(said) = oauth.not_connected(&refusal) {
88                    return Err(said);
89                }
90            }
91            Err((other, _)) => return Err(SyncError::Network(other.to_string())),
92        }
93    }
94    Err(oauth.refused(&refusal))
95}
96
97/// The words of a server's NO or BAD, as async-imap gives them
98/// (`code: None, info: Some("…")`): the text inside, else all of it.
99fn server_words(said: &str) -> String {
100    said.split_once("info: Some(\"")
101        .and_then(|(_, rest)| rest.rsplit_once("\")"))
102        .map_or_else(|| said.trim().to_string(), |(words, _)| words.replace("\\\"", "\"").trim().to_string())
103}
104
105/// Connects and encrypts, up to the server's greeting.
106async fn connect(server: &Server) -> Result<Client<TlsStream<TcpStream>>, SyncError> {
107    let tcp = within(CONNECT, TcpStream::connect((server.host.as_str(), server.port))).await?.map_err(network)?;
108    let tcp = match server.security {
109        Security::Tls => tcp,
110        Security::Starttls => starttls(tcp).await?,
111    };
112    let name = ServerName::try_from(server.host.clone()).map_err(|e| SyncError::Network(e.to_string()))?;
113    let tls = within(CONNECT, TlsConnector::from(tls_config()).connect(name, tcp)).await?.map_err(tls_error)?;
114    let mut client = Client::new(tls);
115    if server.security == Security::Tls {
116        // The server speaks first (RFC 9051 §7.1); after STARTTLS it already has.
117        let greeting = within(CONNECT, client.read_response()).await?.map_err(network)?;
118        greeting.ok_or_else(|| SyncError::Network("closed before greeting".into()))?;
119    }
120    Ok(client)
121}
122
123/// Upgrades a plain connection before anything else is said (RFC 9051 §6.2.1):
124/// the greeting, STARTTLS, its OK. Read a byte at a time, so nothing meant for
125/// TLS is swallowed.
126async fn starttls(mut tcp: TcpStream) -> Result<TcpStream, SyncError> {
127    let greeting = within(CONNECT, read_line(&mut tcp)).await??;
128    if !greeting.starts_with("* OK") {
129        return Err(SyncError::Server(greeting));
130    }
131    tcp.write_all(b"s1 STARTTLS\r\n").await.map_err(network)?;
132    loop {
133        let line = within(CONNECT, read_line(&mut tcp)).await??;
134        if line.starts_with("s1 OK") {
135            return Ok(tcp);
136        }
137        if line.starts_with("s1 ") {
138            return Err(SyncError::Server(line));
139        }
140    }
141}
142
143async fn read_line(tcp: &mut TcpStream) -> Result<String, SyncError> {
144    let mut line = Vec::new();
145    loop {
146        let byte = tcp.read_u8().await.map_err(network)?;
147        if byte == b'\n' {
148            return Ok(String::from_utf8_lossy(&line).trim_end().to_string());
149        }
150        line.push(byte);
151        if line.len() > 8192 {
152            return Err(SyncError::Server("greeting too long".into()));
153        }
154    }
155}
156
157pub(crate) fn tls_config() -> Arc<rustls::ClientConfig> {
158    #[cfg(feature = "insecure-test-tls")]
159    if std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some() {
160        return insecure::config();
161    }
162    static CONFIG: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
163    CONFIG
164        .get_or_init(|| {
165            let mut roots = rustls::RootCertStore::empty();
166            let (added, _) = roots.add_parsable_certificates(rustls_native_certs::load_native_certs().certs);
167            if added == 0 {
168                // A system without a certificate store, such as a bare container.
169                roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
170            }
171            let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
172                .with_safe_default_protocol_versions()
173                .expect("ring supports TLS 1.2 and 1.3")
174                .with_root_certificates(roots)
175                .with_no_client_auth();
176            Arc::new(config)
177        })
178        .clone()
179}
180
181/// Waits at most `limit`; a server that does not answer is a network problem.
182pub async fn within<F: Future>(limit: Duration, future: F) -> Result<F::Output, SyncError> {
183    timeout(limit, future).await.map_err(|_| SyncError::Network(format!("no answer within {} s", limit.as_secs())))
184}
185
186fn network(e: std::io::Error) -> SyncError {
187    SyncError::Network(e.to_string())
188}
189
190/// A certificate problem is told apart from a network one: it may be an attack.
191fn tls_error(e: std::io::Error) -> SyncError {
192    match e.get_ref().and_then(|inner| inner.downcast_ref::<rustls::Error>()) {
193        Some(tls) => SyncError::Tls(tls.to_string()),
194        None => network(e),
195    }
196}
197
198/// A refused login; "application-specific password required" (Gmail) gets its own
199/// sentence, since the usual password will never work there. So does any
200/// password Google's server refuses: it takes app passwords only. Microsoft's
201/// takes no password at all, app passwords included: its own sign-in instead.
202fn login_error(e: async_imap::error::Error, host: &str) -> SyncError {
203    match e {
204        async_imap::error::Error::No(m) | async_imap::error::Error::Bad(m) => {
205            let detail = m.trim().to_string();
206            let lower = detail.to_ascii_lowercase();
207            if crate::microsoft::is_imap_host(host) {
208                SyncError::Login(crate::microsoft::PASSWORD.into())
209            } else if lower.contains("application-specific password") || lower.contains("app password") || host.eq_ignore_ascii_case(crate::discover::GOOGLE_IMAP) {
210                SyncError::AppPassword(detail)
211            } else {
212                SyncError::Login(detail)
213            }
214        }
215        other => SyncError::Network(other.to_string()),
216    }
217}
218
219/// Any other IMAP error.
220pub fn server(e: async_imap::error::Error) -> SyncError {
221    match e {
222        async_imap::error::Error::No(m) | async_imap::error::Error::Bad(m) => SyncError::Server(m.trim().to_string()),
223        other => SyncError::Network(other.to_string()),
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn tells_app_passwords_apart() {
233        let gmail = async_imap::error::Error::No("[ALERT] Application-specific password required: https://support.google.com/accounts/answer/185833 (Failure)".into());
234        assert!(matches!(login_error(gmail, "mail.example.org"), SyncError::AppPassword(_)));
235        let wrong = async_imap::error::Error::No("[AUTHENTICATIONFAILED] Authentication failed.".into());
236        assert!(matches!(login_error(wrong, "mail.example.org"), SyncError::Login(_)));
237        // Google refusing a password: an app password, made at Google, is the way.
238        let google = async_imap::error::Error::No("[AUTHENTICATIONFAILED] Invalid credentials (Failure)".into());
239        assert!(matches!(login_error(google, "imap.gmail.com"), SyncError::AppPassword(_)));
240        assert_eq!(server_words("code: None, info: Some(\"User is authenticated but not connected.\")"), "User is authenticated but not connected.");
241        assert_eq!(server_words(" AUTHENTICATE failed. "), "AUTHENTICATE failed.");
242        // Microsoft refusing a password: its own sign-in is the only way.
243        let microsoft = async_imap::error::Error::No("LOGIN failed.".into());
244        assert_eq!(login_error(microsoft, "outlook.office365.com"), SyncError::Login(crate::microsoft::PASSWORD.into()));
245    }
246
247    /// Microsoft's token goes to Microsoft's IMAP server alone: a
248    /// configuration naming another is refused before any token is asked.
249    #[test]
250    fn a_microsoft_token_reaches_no_other_server() {
251        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
252        let elsewhere = Server { host: "imap.example.org".into(), port: 993, security: Security::Tls, login: "you@example.org".into(), oauth: Some(OAuth::Microsoft { personal: true }) };
253        assert_eq!(runtime.block_on(open(&elsewhere, "")).err(), Some(SyncError::Said(crate::microsoft::OTHER_HOST, "imap.example.org".into())));
254    }
255
256    /// Reaches real servers, so it runs only when asked: `cargo test -- --ignored`.
257    /// It stops at the greeting: no login is ever attempted.
258    #[test]
259    #[ignore]
260    fn reaches_real_servers_up_to_the_greeting() {
261        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
262        for (host, port, security) in [("imap.gmail.com", 993, Security::Tls), ("mail.ecloud.global", 993, Security::Tls), ("outlook.office365.com", 143, Security::Starttls)] {
263            let server = Server { host: host.into(), port, security, login: String::new(), oauth: None };
264            let client = runtime.block_on(connect(&server));
265            assert!(client.is_ok(), "{host}:{port}: {:?}", client.err());
266        }
267        let wrong_name = Server { host: "wrong.host.badssl.com".into(), port: 443, security: Security::Tls, login: String::new(), oauth: None };
268        assert!(matches!(runtime.block_on(connect(&wrong_name)), Err(SyncError::Tls(_))));
269    }
270}
271
272/// For tests against a local server with a self-signed certificate only: built
273/// with the `insecure-test-tls` feature, never in Sioul itself.
274#[cfg(feature = "insecure-test-tls")]
275mod insecure {
276    use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
277    use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
278    use rustls::{DigitallySignedStruct, SignatureScheme};
279    use std::sync::Arc;
280
281    #[derive(Debug)]
282    struct AnyCertificate;
283
284    impl ServerCertVerifier for AnyCertificate {
285        fn verify_server_cert(&self, _: &CertificateDer, _: &[CertificateDer], _: &ServerName, _: &[u8], _: UnixTime) -> Result<ServerCertVerified, rustls::Error> {
286            Ok(ServerCertVerified::assertion())
287        }
288        fn verify_tls12_signature(&self, _: &[u8], _: &CertificateDer, _: &DigitallySignedStruct) -> Result<HandshakeSignatureValid, rustls::Error> {
289            Ok(HandshakeSignatureValid::assertion())
290        }
291        fn verify_tls13_signature(&self, _: &[u8], _: &CertificateDer, _: &DigitallySignedStruct) -> Result<HandshakeSignatureValid, rustls::Error> {
292            Ok(HandshakeSignatureValid::assertion())
293        }
294        fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
295            rustls::crypto::ring::default_provider().signature_verification_algorithms.supported_schemes()
296        }
297    }
298
299    pub(super) fn config() -> Arc<rustls::ClientConfig> {
300        let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
301            .with_safe_default_protocol_versions()
302            .expect("ring supports TLS 1.2 and 1.3")
303            .dangerous()
304            .with_custom_certificate_verifier(Arc::new(AnyCertificate))
305            .with_no_client_auth();
306        Arc::new(config)
307    }
308}