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 gives an access token instead of a
10//! 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        return sign_in_with_tokens(&server.login, || connect(server), |fresh| oauth.token_async(&server.login, fresh), oauth).await;
58    }
59    let client = connect(server).await?;
60    within(COMMAND, client.login(&server.login, password)).await?.map_err(|(e, _)| login_error(e, &server.host))
61}
62
63/// AUTHENTICATE XOAUTH2 on connections `connect` makes, with the tokens
64/// `token` gives: the one kept first; refused, a fresh one, once (the one kept
65/// may have ended early: a password changed, the access withdrawn); refused
66/// again, the access must be given again.
67pub(crate) async fn sign_in_with_tokens<T, C, CF, K, KF>(login: &str, connect: C, token: K, oauth: OAuth) -> Result<Session<T>, SyncError>
68where
69    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + std::fmt::Debug + Send,
70    C: Fn() -> CF,
71    CF: Future<Output = Result<Client<T>, SyncError>>,
72    K: Fn(bool) -> KF,
73    KF: Future<Output = Result<String, SyncError>>,
74{
75    let mut refusal = String::new();
76    for fresh in [false, true] {
77        let token = token(fresh).await?;
78        let client = connect().await?;
79        let mut sasl = ImapXoauth2::new(login, &token);
80        match within(COMMAND, client.authenticate("XOAUTH2", &mut sasl)).await? {
81            Ok(session) => return Ok(session),
82            Err((async_imap::error::Error::No(m) | async_imap::error::Error::Bad(m), _)) => refusal = sasl.refusal.clone().unwrap_or_else(|| m.trim().to_string()),
83            Err((other, _)) => return Err(SyncError::Network(other.to_string())),
84        }
85    }
86    Err(oauth.refused(&refusal))
87}
88
89/// Connects and encrypts, up to the server's greeting.
90async fn connect(server: &Server) -> Result<Client<TlsStream<TcpStream>>, SyncError> {
91    let tcp = within(CONNECT, TcpStream::connect((server.host.as_str(), server.port))).await?.map_err(network)?;
92    let tcp = match server.security {
93        Security::Tls => tcp,
94        Security::Starttls => starttls(tcp).await?,
95    };
96    let name = ServerName::try_from(server.host.clone()).map_err(|e| SyncError::Network(e.to_string()))?;
97    let tls = within(CONNECT, TlsConnector::from(tls_config()).connect(name, tcp)).await?.map_err(tls_error)?;
98    let mut client = Client::new(tls);
99    if server.security == Security::Tls {
100        // The server speaks first (RFC 9051 §7.1); after STARTTLS it already has.
101        let greeting = within(CONNECT, client.read_response()).await?.map_err(network)?;
102        greeting.ok_or_else(|| SyncError::Network("closed before greeting".into()))?;
103    }
104    Ok(client)
105}
106
107/// Upgrades a plain connection before anything else is said (RFC 9051 §6.2.1):
108/// the greeting, STARTTLS, its OK. Read a byte at a time, so nothing meant for
109/// TLS is swallowed.
110async fn starttls(mut tcp: TcpStream) -> Result<TcpStream, SyncError> {
111    let greeting = within(CONNECT, read_line(&mut tcp)).await??;
112    if !greeting.starts_with("* OK") {
113        return Err(SyncError::Server(greeting));
114    }
115    tcp.write_all(b"s1 STARTTLS\r\n").await.map_err(network)?;
116    loop {
117        let line = within(CONNECT, read_line(&mut tcp)).await??;
118        if line.starts_with("s1 OK") {
119            return Ok(tcp);
120        }
121        if line.starts_with("s1 ") {
122            return Err(SyncError::Server(line));
123        }
124    }
125}
126
127async fn read_line(tcp: &mut TcpStream) -> Result<String, SyncError> {
128    let mut line = Vec::new();
129    loop {
130        let byte = tcp.read_u8().await.map_err(network)?;
131        if byte == b'\n' {
132            return Ok(String::from_utf8_lossy(&line).trim_end().to_string());
133        }
134        line.push(byte);
135        if line.len() > 8192 {
136            return Err(SyncError::Server("greeting too long".into()));
137        }
138    }
139}
140
141pub(crate) fn tls_config() -> Arc<rustls::ClientConfig> {
142    #[cfg(feature = "insecure-test-tls")]
143    if std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some() {
144        return insecure::config();
145    }
146    static CONFIG: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
147    CONFIG
148        .get_or_init(|| {
149            let mut roots = rustls::RootCertStore::empty();
150            let (added, _) = roots.add_parsable_certificates(rustls_native_certs::load_native_certs().certs);
151            if added == 0 {
152                // A system without a certificate store, such as a bare container.
153                roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
154            }
155            let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
156                .with_safe_default_protocol_versions()
157                .expect("ring supports TLS 1.2 and 1.3")
158                .with_root_certificates(roots)
159                .with_no_client_auth();
160            Arc::new(config)
161        })
162        .clone()
163}
164
165/// Waits at most `limit`; a server that does not answer is a network problem.
166pub async fn within<F: Future>(limit: Duration, future: F) -> Result<F::Output, SyncError> {
167    timeout(limit, future).await.map_err(|_| SyncError::Network(format!("no answer within {} s", limit.as_secs())))
168}
169
170fn network(e: std::io::Error) -> SyncError {
171    SyncError::Network(e.to_string())
172}
173
174/// A certificate problem is told apart from a network one: it may be an attack.
175fn tls_error(e: std::io::Error) -> SyncError {
176    match e.get_ref().and_then(|inner| inner.downcast_ref::<rustls::Error>()) {
177        Some(tls) => SyncError::Tls(tls.to_string()),
178        None => network(e),
179    }
180}
181
182/// A refused login; "application-specific password required" (Gmail) gets its own
183/// sentence, since the usual password will never work there. So does any
184/// password Google's server refuses: it takes app passwords only.
185fn login_error(e: async_imap::error::Error, host: &str) -> SyncError {
186    match e {
187        async_imap::error::Error::No(m) | async_imap::error::Error::Bad(m) => {
188            let detail = m.trim().to_string();
189            let lower = detail.to_ascii_lowercase();
190            if lower.contains("application-specific password") || lower.contains("app password") || host.eq_ignore_ascii_case(crate::discover::GOOGLE_IMAP) {
191                SyncError::AppPassword(detail)
192            } else {
193                SyncError::Login(detail)
194            }
195        }
196        other => SyncError::Network(other.to_string()),
197    }
198}
199
200/// Any other IMAP error.
201pub fn server(e: async_imap::error::Error) -> SyncError {
202    match e {
203        async_imap::error::Error::No(m) | async_imap::error::Error::Bad(m) => SyncError::Server(m.trim().to_string()),
204        other => SyncError::Network(other.to_string()),
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn tells_app_passwords_apart() {
214        let gmail = async_imap::error::Error::No("[ALERT] Application-specific password required: https://support.google.com/accounts/answer/185833 (Failure)".into());
215        assert!(matches!(login_error(gmail, "mail.example.org"), SyncError::AppPassword(_)));
216        let wrong = async_imap::error::Error::No("[AUTHENTICATIONFAILED] Authentication failed.".into());
217        assert!(matches!(login_error(wrong, "mail.example.org"), SyncError::Login(_)));
218        // Google refusing a password: an app password, made at Google, is the way.
219        let google = async_imap::error::Error::No("[AUTHENTICATIONFAILED] Invalid credentials (Failure)".into());
220        assert!(matches!(login_error(google, "imap.gmail.com"), SyncError::AppPassword(_)));
221    }
222
223    /// Reaches real servers, so it runs only when asked: `cargo test -- --ignored`.
224    /// It stops at the greeting: no login is ever attempted.
225    #[test]
226    #[ignore]
227    fn reaches_real_servers_up_to_the_greeting() {
228        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
229        for (host, port, security) in [("imap.gmail.com", 993, Security::Tls), ("mail.ecloud.global", 993, Security::Tls), ("outlook.office365.com", 143, Security::Starttls)] {
230            let server = Server { host: host.into(), port, security, login: String::new(), oauth: None };
231            let client = runtime.block_on(connect(&server));
232            assert!(client.is_ok(), "{host}:{port}: {:?}", client.err());
233        }
234        let wrong_name = Server { host: "wrong.host.badssl.com".into(), port: 443, security: Security::Tls, login: String::new(), oauth: None };
235        assert!(matches!(runtime.block_on(connect(&wrong_name)), Err(SyncError::Tls(_))));
236    }
237}
238
239/// For tests against a local server with a self-signed certificate only: built
240/// with the `insecure-test-tls` feature, never in Sioul itself.
241#[cfg(feature = "insecure-test-tls")]
242mod insecure {
243    use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
244    use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
245    use rustls::{DigitallySignedStruct, SignatureScheme};
246    use std::sync::Arc;
247
248    #[derive(Debug)]
249    struct AnyCertificate;
250
251    impl ServerCertVerifier for AnyCertificate {
252        fn verify_server_cert(&self, _: &CertificateDer, _: &[CertificateDer], _: &ServerName, _: &[u8], _: UnixTime) -> Result<ServerCertVerified, rustls::Error> {
253            Ok(ServerCertVerified::assertion())
254        }
255        fn verify_tls12_signature(&self, _: &[u8], _: &CertificateDer, _: &DigitallySignedStruct) -> Result<HandshakeSignatureValid, rustls::Error> {
256            Ok(HandshakeSignatureValid::assertion())
257        }
258        fn verify_tls13_signature(&self, _: &[u8], _: &CertificateDer, _: &DigitallySignedStruct) -> Result<HandshakeSignatureValid, rustls::Error> {
259            Ok(HandshakeSignatureValid::assertion())
260        }
261        fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
262            rustls::crypto::ring::default_provider().signature_verification_algorithms.supported_schemes()
263        }
264    }
265
266    pub(super) fn config() -> Arc<rustls::ClientConfig> {
267        let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
268            .with_safe_default_protocol_versions()
269            .expect("ring supports TLS 1.2 and 1.3")
270            .dangerous()
271            .with_custom_certificate_verifier(Arc::new(AnyCertificate))
272            .with_no_client_auth();
273        Arc::new(config)
274    }
275}