Skip to main content

sioul_sync/
discover.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Finding an account's IMAP server from its address, the way Thunderbird does
5//! (<https://wiki.mozilla.org/Thunderbird:Autoconfiguration>):
6//!
7//! 1. the provider's own settings, `autoconfig.<domain>` (cPanel hosts and
8//!    Nextcloud providers publish them), then `<domain>/.well-known/autoconfig`;
9//! 2. Thunderbird's list of providers (ISPDB), which knows Gmail and the big ones;
10//! 3. a guess, `imap.<domain>` then `mail.<domain>` on port 993, for you to check.
11//!
12//! Only HTTPS: a settings file fetched in clear could send your password to
13//! someone else's server. The ISPDB is told the domain, never the address.
14//!
15//! Google's mail is known before any of that: gmail.com and googlemail.com,
16//! and any domain whose mail Google receives (Google Workspace: its MX is
17//! smtp.google.com, or aspmx.l.google.com and its kin), asked of the system's
18//! DNS. Google takes no account password there, only an app password or its
19//! own sign-in, so the form must know it (docs/google.md, "Mail").
20
21use crate::SyncError;
22use sioul_core::config::Security;
23use std::net::{TcpStream, ToSocketAddrs};
24use std::time::Duration;
25
26/// Google's mail servers, for Gmail and Google Workspace alike.
27pub const GOOGLE_IMAP: &str = "imap.gmail.com";
28pub const GOOGLE_SMTP: &str = "smtp.gmail.com";
29
30/// IMAP settings found for an address, with the SMTP server when the settings name one.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Found {
33    pub host: String,
34    pub port: u16,
35    pub security: Security,
36    pub username: String,
37    pub by: FoundBy,
38    pub smtp: Option<Smtp>,
39}
40
41/// An SMTP submission server.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Smtp {
44    pub host: String,
45    pub port: u16,
46    pub security: Security,
47}
48
49/// Where the settings came from, said to you before you type a password.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum FoundBy {
52    Provider,
53    Ispdb,
54    Guess,
55    /// Google keeps this address's mail: Gmail, or a domain whose MX is Google's.
56    Google,
57}
58
59impl FoundBy {
60    /// The message naming it ("account-by-provider").
61    pub fn message_id(self) -> &'static str {
62        match self {
63            FoundBy::Provider => "account-by-provider",
64            FoundBy::Ispdb => "account-by-ispdb",
65            FoundBy::Guess => "account-by-guess",
66            FoundBy::Google => "account-by-google",
67        }
68    }
69}
70
71const HTTP: Duration = Duration::from_secs(10);
72const PROBE: Duration = Duration::from_secs(5);
73
74/// The IMAP settings for an address.
75pub fn discover(address: &str) -> Result<Found, SyncError> {
76    discover_with(address, mx_hosts)
77}
78
79/// `discover`, the domain's MX asked of `mx` (the system's DNS, or a stand-in in tests).
80pub(crate) fn discover_with(address: &str, mx: impl Fn(&str) -> Option<Vec<String>>) -> Result<Found, SyncError> {
81    let address = address.trim();
82    let domain = domain_of(address).ok_or(SyncError::BadAddress)?;
83    // Gmail: known here, nothing asked of anyone.
84    if is_google_domain(&domain) {
85        return Ok(google_found(address));
86    }
87    // HTTPS to the end: a redirect to a plain address is not followed.
88    let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(HTTP)).https_only(true).build().into();
89    let sources = [
90        (format!("https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={}", encode(address)), FoundBy::Provider),
91        (format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"), FoundBy::Provider),
92        (format!("https://autoconfig.thunderbird.net/v1.1/{domain}"), FoundBy::Ispdb),
93    ];
94    for (url, by) in sources {
95        let Some(xml) = fetch(&agent, &url) else { continue };
96        if let Some(found) = parse_config(&xml, address, by) {
97            return Ok(found);
98        }
99    }
100    // Google Workspace: the domain's mail goes to Google, its servers are Google's.
101    if mx(&domain).is_some_and(|hosts| hosts.iter().any(|h| is_google_mx(h))) {
102        return Ok(google_found(address));
103    }
104    guess(&domain, address).ok_or(SyncError::NotFound(domain))
105}
106
107/// Google's servers for an address whose mail Google keeps: IMAP on 993 and
108/// submission on 465, both encrypted from the first byte; the address as login.
109pub fn google_found(address: &str) -> Found {
110    Found {
111        host: GOOGLE_IMAP.into(),
112        port: 993,
113        security: Security::Tls,
114        username: address.trim().to_string(),
115        by: FoundBy::Google,
116        smtp: Some(Smtp { host: GOOGLE_SMTP.into(), port: 465, security: Security::Tls }),
117    }
118}
119
120/// Gmail's own domains.
121pub fn is_google_domain(domain: &str) -> bool {
122    matches!(domain.trim().trim_end_matches('.').to_ascii_lowercase().as_str(), "gmail.com" | "googlemail.com")
123}
124
125/// Whether a mail exchanger is Google's: smtp.google.com (Workspace since
126/// 2023), aspmx.l.google.com and altN.aspmx.l.google.com, aspmxN.googlemail.com.
127/// Only Google names hosts under google.com and googlemail.com.
128pub fn is_google_mx(host: &str) -> bool {
129    let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
130    host.ends_with(".google.com") || host.ends_with(".googlemail.com")
131}
132
133/// Whether Google keeps an address's mail: its domain is Gmail's, or its MX
134/// (asked of the system's DNS) is Google's.
135pub fn is_google_mail(address: &str) -> bool {
136    is_google_mail_with(address, mx_hosts)
137}
138
139/// `is_google_mail`, the MX asked of `mx`.
140pub(crate) fn is_google_mail_with(address: &str, mx: impl Fn(&str) -> Option<Vec<String>>) -> bool {
141    let Some(domain) = domain_of(address.trim()) else { return false };
142    is_google_domain(&domain) || mx(&domain).is_some_and(|hosts| hosts.iter().any(|h| is_google_mx(h)))
143}
144
145/// The hosts that receive a domain's mail (its MX records), asked of the
146/// system's DNS as the sender checks do (on Android, the network's servers,
147/// which hickory reads through ConnectivityManager); None when it cannot be
148/// asked or does not answer within a few seconds.
149fn mx_hosts(domain: &str) -> Option<Vec<String>> {
150    let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().ok()?;
151    runtime.block_on(async {
152        let resolver = mail_auth::MessageAuthenticator::new_system_conf().ok()?;
153        let none = None::<&mail_auth::common::cache::NoCache<Box<str>, mail_auth::RecordSet<mail_auth::MX>>>;
154        let found = tokio::time::timeout(PROBE, resolver.mx_lookup(domain, none)).await.ok()?.ok()?;
155        Some(found.rrset.iter().flat_map(|mx| mx.exchanges.iter().map(|host| host.trim_end_matches('.').to_string())).collect())
156    })
157}
158
159fn fetch(agent: &ureq::Agent, url: &str) -> Option<String> {
160    agent.get(url).call().ok()?.body_mut().read_to_string().ok()
161}
162
163/// `imap.<domain>`, then `mail.<domain>`, if something answers on port 993.
164fn guess(domain: &str, address: &str) -> Option<Found> {
165    [format!("imap.{domain}"), format!("mail.{domain}")].into_iter().find(|host| answers(host, 993)).map(|host| Found {
166        host,
167        port: 993,
168        security: Security::Tls,
169        username: address.to_string(),
170        by: FoundBy::Guess,
171        smtp: None,
172    })
173}
174
175fn answers(host: &str, port: u16) -> bool {
176    let Ok(addresses) = (host, port).to_socket_addrs() else { return false };
177    addresses.into_iter().any(|a| TcpStream::connect_timeout(&a, PROBE).is_ok())
178}
179
180fn domain_of(address: &str) -> Option<String> {
181    let (local, domain) = address.rsplit_once('@')?;
182    let valid = !local.is_empty() && domain.contains('.') && !domain.contains(['/', ' ', '?', '#']);
183    valid.then(|| domain.to_ascii_lowercase())
184}
185
186/// Percent-encodes a query value (RFC 3986 §2.3 keeps only unreserved characters).
187fn encode(value: &str) -> String {
188    value
189        .bytes()
190        .map(|b| match b {
191            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(),
192            _ => format!("%{b:02X}"),
193        })
194        .collect()
195}
196
197/// Reads the IMAP servers of a `config-v1.1.xml` file; TLS from the first byte
198/// is preferred to STARTTLS (RFC 8314 §3), and plain connections are ignored.
199pub(crate) fn parse_config(xml: &str, address: &str, by: FoundBy) -> Option<Found> {
200    let servers: Vec<Found> = blocks(xml, "incomingServer")
201        .filter(|(attributes, _)| attributes.contains("\"imap\"") || attributes.contains("'imap'"))
202        .filter_map(|(_, body)| server(body, address, by))
203        .collect();
204    let outgoing: Vec<Found> = blocks(xml, "outgoingServer")
205        .filter(|(attributes, _)| attributes.contains("\"smtp\"") || attributes.contains("'smtp'"))
206        .filter_map(|(_, body)| server(body, address, by))
207        .collect();
208    let smtp = outgoing.iter().find(|s| s.security == Security::Tls).or(outgoing.first()).map(|s| Smtp { host: s.host.clone(), port: s.port, security: s.security });
209    let mut found = servers.iter().find(|s| s.security == Security::Tls).or(servers.first()).cloned()?;
210    found.smtp = smtp;
211    Some(found)
212}
213
214/// The SMTP server of an address: from its settings, else `smtp.<domain>`, then the IMAP host, on 465.
215pub fn discover_smtp(address: &str, imap_host: Option<&str>) -> Result<Smtp, SyncError> {
216    if let Ok(Found { smtp: Some(smtp), .. }) = discover(address) {
217        return Ok(smtp);
218    }
219    let domain = domain_of(address).ok_or(SyncError::BadAddress)?;
220    [format!("smtp.{domain}"), imap_host.unwrap_or(&format!("mail.{domain}")).to_string()]
221        .into_iter()
222        .find(|host| answers(host, 465))
223        .map(|host| Smtp { host, port: 465, security: Security::Tls })
224        .ok_or(SyncError::NotFound(domain))
225}
226
227fn server(body: &str, address: &str, by: FoundBy) -> Option<Found> {
228    let security = match tag(body, "socketType")? {
229        "SSL" => Security::Tls,
230        "STARTTLS" => Security::Starttls,
231        _ => return None,
232    };
233    Some(Found {
234        host: substitute(tag(body, "hostname")?, address),
235        port: tag(body, "port")?.parse().ok()?,
236        security,
237        username: substitute(tag(body, "username").unwrap_or("%EMAILADDRESS%"), address),
238        by,
239        smtp: None,
240    })
241}
242
243/// Each `<name attributes>body</name>`, as (attributes, body).
244fn blocks<'a>(xml: &'a str, name: &'a str) -> impl Iterator<Item = (&'a str, &'a str)> + 'a {
245    let open = format!("<{name}");
246    let close = format!("</{name}>");
247    let mut rest = xml;
248    std::iter::from_fn(move || {
249        let start = rest.find(&open)? + open.len();
250        let after = &rest[start..];
251        let end_of_tag = after.find('>')?;
252        let body_end = after.find(&close)?;
253        let found = (&after[..end_of_tag], after.get(end_of_tag + 1..body_end)?);
254        rest = &after[body_end + close.len()..];
255        Some(found)
256    })
257}
258
259/// The text of the first `<name>text</name>`, trimmed.
260fn tag<'a>(body: &'a str, name: &str) -> Option<&'a str> {
261    let start = body.find(&format!("<{name}>"))? + name.len() + 2;
262    let end = start + body[start..].find(&format!("</{name}>"))?;
263    Some(body[start..end].trim())
264}
265
266/// Thunderbird's placeholders: %EMAILADDRESS%, %EMAILLOCALPART%, %EMAILDOMAIN%.
267fn substitute(template: &str, address: &str) -> String {
268    let (local, domain) = address.rsplit_once('@').unwrap_or((address, ""));
269    template.replace("%EMAILADDRESS%", address).replace("%EMAILLOCALPART%", local).replace("%EMAILDOMAIN%", domain)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    /// The shape a cPanel host publishes at autoconfig.<domain>.
277    const CPANEL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
278<clientConfig version="1.1">
279  <emailProvider id="example.com">
280    <domain>example.com</domain>
281    <incomingServer type="imap">
282      <hostname>mail.example.com</hostname>
283      <port>143</port>
284      <socketType>STARTTLS</socketType>
285      <username>%EMAILADDRESS%</username>
286      <authentication>password-cleartext</authentication>
287    </incomingServer>
288    <incomingServer type="imap">
289      <hostname>mail.example.com</hostname>
290      <port>993</port>
291      <socketType>SSL</socketType>
292      <username>%EMAILADDRESS%</username>
293      <authentication>password-cleartext</authentication>
294    </incomingServer>
295    <outgoingServer type="smtp">
296      <hostname>mail.example.com</hostname>
297      <port>465</port>
298      <socketType>SSL</socketType>
299      <username>%EMAILADDRESS%</username>
300    </outgoingServer>
301    <incomingServer type="pop3">
302      <hostname>mail.example.com</hostname>
303      <port>995</port>
304      <socketType>SSL</socketType>
305    </incomingServer>
306  </emailProvider>
307</clientConfig>"#;
308
309    /// The shape of the ISPDB's entries, with a plain server to ignore.
310    const ISPDB: &str = r#"<clientConfig version="1.1"><emailProvider id="example.net">
311      <incomingServer type='imap'><hostname>imap.%EMAILDOMAIN%</hostname><port>143</port>
312      <socketType>plain</socketType><username>%EMAILLOCALPART%</username></incomingServer>
313      <incomingServer type='imap'><hostname>imap.%EMAILDOMAIN%</hostname><port>143</port>
314      <socketType>STARTTLS</socketType><username>%EMAILLOCALPART%</username></incomingServer>
315    </emailProvider></clientConfig>"#;
316
317    #[test]
318    fn prefers_tls_from_the_first_byte() {
319        let found = parse_config(CPANEL, "someone@example.com", FoundBy::Provider).unwrap();
320        assert_eq!((found.host.as_str(), found.port, found.security), ("mail.example.com", 993, Security::Tls));
321        assert_eq!(found.username, "someone@example.com");
322        assert_eq!(found.smtp, Some(Smtp { host: "mail.example.com".into(), port: 465, security: Security::Tls }));
323    }
324
325    #[test]
326    fn fills_placeholders_and_skips_plain() {
327        let found = parse_config(ISPDB, "jane@example.net", FoundBy::Ispdb).unwrap();
328        assert_eq!((found.host.as_str(), found.port, found.security), ("imap.example.net", 143, Security::Starttls));
329        assert_eq!(found.username, "jane");
330        assert_eq!(parse_config("<clientConfig/>", "a@example.org", FoundBy::Ispdb), None);
331    }
332
333    /// Google's mail, by its domains, else by the domain's MX (a stand-in
334    /// for the DNS here); nothing asked for Gmail's own domains.
335    #[test]
336    fn knows_google_mail() {
337        let never = |domain: &str| -> Option<Vec<String>> { panic!("{domain}: Gmail's domains need no DNS") };
338        for address in ["someone@gmail.com", " Someone@GMail.COM ", "someone@googlemail.com"] {
339            assert!(is_google_mail_with(address, never), "{address}");
340            assert_eq!(discover_with(address, never).unwrap(), google_found(address));
341        }
342        let mx = |domain: &str| -> Option<Vec<String>> {
343            match domain {
344                // Google Workspace since 2023, then before (with a trailing dot, as DNS gives it).
345                "new-workspace.example" => Some(vec!["smtp.google.com".into()]),
346                "old-workspace.example" => Some(vec!["aspmx.l.google.com.".into(), "alt1.aspmx.l.google.com.".into(), "aspmx2.googlemail.com.".into()]),
347                "elsewhere.example" => Some(vec!["mx.elsewhere.example".into()]),
348                // Names that only look like Google's.
349                "lookalike.example" => Some(vec!["google.com.lookalike.example".into(), "notgoogle.com".into(), "aspmx.l.google.com.evil.example".into()]),
350                _ => None,
351            }
352        };
353        assert!(is_google_mail_with("you@new-workspace.example", mx));
354        assert!(is_google_mail_with("you@old-workspace.example", mx));
355        assert!(!is_google_mail_with("you@elsewhere.example", mx));
356        assert!(!is_google_mail_with("you@lookalike.example", mx));
357        assert!(!is_google_mail_with("you@unanswered.example", mx), "no answer: not Google");
358        assert!(!is_google_mail_with("not an address", mx));
359        // Google's servers, encrypted from the first byte, the address as login.
360        let found = google_found("you@new-workspace.example");
361        assert_eq!((found.host.as_str(), found.port, found.security, found.username.as_str(), found.by), ("imap.gmail.com", 993, Security::Tls, "you@new-workspace.example", FoundBy::Google));
362        assert_eq!(found.smtp, Some(Smtp { host: "smtp.gmail.com".into(), port: 465, security: Security::Tls }));
363    }
364
365    #[test]
366    fn reads_addresses_carefully() {
367        assert_eq!(domain_of("Someone@Example.org").as_deref(), Some("example.org"));
368        assert_eq!(domain_of("nobody"), None);
369        assert_eq!(domain_of("a@example.org/evil"), None);
370        assert_eq!(encode("a+b@example.org"), "a%2Bb%40example.org");
371    }
372}