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//!
21//! So is Microsoft's, which takes no password at all, only its own sign-in
22//! (docs/microsoft.md): Outlook.com's own domains (hotmail.com, live.fr…,
23//! the list Thunderbird's ISPDB gives them), a domain whose MX is
24//! Outlook.com's (`*.olc.protection.outlook.com`: a personal account) or
25//! Microsoft 365's (`*.mail.protection.outlook.com` and its older and newer
26//! forms: a work or school account), and settings that name Microsoft's
27//! IMAP server. The ID token settles the kind at sign-in.
28
29use crate::SyncError;
30pub use crate::microsoft::Kind as MicrosoftKind;
31use sioul_core::config::Security;
32use std::net::{TcpStream, ToSocketAddrs};
33use std::time::Duration;
34
35/// Google's mail servers, for Gmail and Google Workspace alike.
36pub const GOOGLE_IMAP: &str = "imap.gmail.com";
37pub const GOOGLE_SMTP: &str = "smtp.gmail.com";
38
39/// IMAP settings found for an address, with the SMTP server when the settings name one.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Found {
42    pub host: String,
43    pub port: u16,
44    pub security: Security,
45    pub username: String,
46    pub by: FoundBy,
47    pub smtp: Option<Smtp>,
48}
49
50/// An SMTP submission server.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Smtp {
53    pub host: String,
54    pub port: u16,
55    pub security: Security,
56}
57
58/// Where the settings came from, said to you before you type a password.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum FoundBy {
61    Provider,
62    Ispdb,
63    Guess,
64    /// Google keeps this address's mail: Gmail, or a domain whose MX is Google's.
65    Google,
66    /// Microsoft keeps this address's mail: Outlook.com's domains, a domain
67    /// whose MX is Microsoft's, or settings naming its server; and of which kind.
68    Microsoft(MicrosoftKind),
69}
70
71impl FoundBy {
72    /// The message naming it ("account-by-provider").
73    pub fn message_id(self) -> &'static str {
74        match self {
75            FoundBy::Provider => "account-by-provider",
76            FoundBy::Ispdb => "account-by-ispdb",
77            FoundBy::Guess => "account-by-guess",
78            FoundBy::Google => "account-by-google",
79            FoundBy::Microsoft(_) => "account-by-microsoft",
80        }
81    }
82}
83
84const HTTP: Duration = Duration::from_secs(10);
85const PROBE: Duration = Duration::from_secs(5);
86
87/// The IMAP settings for an address.
88pub fn discover(address: &str) -> Result<Found, SyncError> {
89    discover_with(address, mx_hosts)
90}
91
92/// `discover`, the domain's MX asked of `mx` (the system's DNS, or a stand-in in tests).
93pub(crate) fn discover_with(address: &str, mx: impl Fn(&str) -> Option<Vec<String>>) -> Result<Found, SyncError> {
94    let address = address.trim();
95    let domain = domain_of(address).ok_or(SyncError::BadAddress)?;
96    // Gmail: known here, nothing asked of anyone.
97    if is_google_domain(&domain) {
98        return Ok(google_found(address));
99    }
100    // Outlook.com's own domains: known here too.
101    if is_microsoft_consumer_domain(&domain) {
102        return Ok(microsoft_found(address, MicrosoftKind::Personal));
103    }
104    // HTTPS to the end: a redirect to a plain address is not followed.
105    let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(HTTP)).https_only(true).build().into();
106    let sources = [
107        (format!("https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={}", encode(address)), FoundBy::Provider),
108        (format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"), FoundBy::Provider),
109        (format!("https://autoconfig.thunderbird.net/v1.1/{domain}"), FoundBy::Ispdb),
110    ];
111    for (url, by) in sources {
112        let Some(xml) = fetch(&agent, &url) else { continue };
113        if let Some(found) = parse_config(&xml, address, by) {
114            return Ok(microsoft_if_named(found, address));
115        }
116    }
117    // Google Workspace, Outlook.com, Microsoft 365: the domain's mail goes
118    // there (one question to the DNS for both), its servers are theirs.
119    let exchangers = mx(&domain).unwrap_or_default();
120    if exchangers.iter().any(|h| is_google_mx(h)) {
121        return Ok(google_found(address));
122    }
123    if let Some(kind) = exchangers.iter().find_map(|h| microsoft_mx_kind(h)) {
124        return Ok(microsoft_found(address, kind));
125    }
126    guess(&domain, address).ok_or(SyncError::NotFound(domain))
127}
128
129/// Settings that name Microsoft's IMAP server are Microsoft's mail: its own
130/// sign-in, its servers; a personal account's when they send through
131/// Outlook.com's server, else a work or school one's.
132fn microsoft_if_named(found: Found, address: &str) -> Found {
133    if !crate::microsoft::is_imap_host(&found.host) {
134        return found;
135    }
136    let personal = found.smtp.as_ref().is_some_and(|s| s.host.eq_ignore_ascii_case(crate::microsoft::SMTP_PERSONAL));
137    microsoft_found(address, if personal { MicrosoftKind::Personal } else { MicrosoftKind::Work })
138}
139
140/// Microsoft's servers for an address whose mail Microsoft keeps: IMAP on
141/// 993 encrypted from the first byte, submission on 587 with STARTTLS (no
142/// 465 at Microsoft), the sending server of its kind; the address as login.
143pub fn microsoft_found(address: &str, kind: MicrosoftKind) -> Found {
144    Found {
145        host: crate::microsoft::IMAP_HOST.into(),
146        port: 993,
147        security: Security::Tls,
148        username: address.trim().to_string(),
149        by: FoundBy::Microsoft(kind),
150        smtp: Some(Smtp { host: kind.smtp_host().into(), port: crate::microsoft::SMTP_PORT, security: Security::Starttls }),
151    }
152}
153
154/// Outlook.com's own domains, those of personal Microsoft accounts: the list
155/// Thunderbird's ISPDB gives (`autoconfig.thunderbird.net/v1.1/outlook.com`,
156/// read 13 October 2026), but for its exchangers' domain,
157/// olc.protection.outlook.com, which `microsoft_mx_kind` knows. Exact names only.
158const MICROSOFT_CONSUMER_DOMAINS: &[&str] = &[
159    "hotmail.com", "live.com", "msn.com", "outlook.com", "windowslive.com", "livemail.tw",
160    "outlook.at", "outlook.be", "outlook.cl", "outlook.cz", "outlook.de", "outlook.dk", "outlook.es", "outlook.fr", "outlook.hu", "outlook.ie", "outlook.in", "outlook.it", "outlook.jp", "outlook.kr", "outlook.lv", "outlook.my", "outlook.ph", "outlook.pt", "outlook.sa", "outlook.sg", "outlook.sk",
161    "outlook.co.id", "outlook.co.il", "outlook.co.th", "outlook.com.ar", "outlook.com.au", "outlook.com.br", "outlook.com.gr", "outlook.com.tr", "outlook.com.vn",
162    "hotmail.be", "hotmail.ca", "hotmail.cl", "hotmail.cz", "hotmail.de", "hotmail.dk", "hotmail.es", "hotmail.fi", "hotmail.fr", "hotmail.gr", "hotmail.hu", "hotmail.it", "hotmail.lt", "hotmail.lv", "hotmail.my", "hotmail.nl", "hotmail.no", "hotmail.ph", "hotmail.rs", "hotmail.se", "hotmail.sg", "hotmail.sk",
163    "hotmail.co.id", "hotmail.co.il", "hotmail.co.in", "hotmail.co.jp", "hotmail.co.kr", "hotmail.co.th", "hotmail.co.uk", "hotmail.co.za", "hotmail.com.ar", "hotmail.com.au", "hotmail.com.br", "hotmail.com.hk", "hotmail.com.tr", "hotmail.com.tw", "hotmail.com.vn",
164    "live.at", "live.be", "live.ca", "live.cl", "live.cn", "live.de", "live.dk", "live.fi", "live.fr", "live.hk", "live.ie", "live.in", "live.it", "live.jp", "live.nl", "live.no", "live.ru", "live.se",
165    "live.co.jp", "live.co.kr", "live.co.uk", "live.co.za", "live.com.ar", "live.com.au", "live.com.mx", "live.com.my", "live.com.ph", "live.com.pt", "live.com.sg",
166];
167
168/// Whether a domain is one of Outlook.com's own (personal Microsoft accounts).
169pub fn is_microsoft_consumer_domain(domain: &str) -> bool {
170    let domain = domain.trim().trim_end_matches('.').to_ascii_lowercase();
171    MICROSOFT_CONSUMER_DOMAINS.contains(&domain.as_str())
172}
173
174/// Whether a mail exchanger is Microsoft's, and of which kind: Outlook.com's
175/// (`*.olc.protection.outlook.com`: personal accounts, and the addresses of a
176/// domain of your own Outlook.com receives), Microsoft 365's
177/// (`*.mail.protection.outlook.com`, older `*.mail.eo.outlook.com` and
178/// `*.mail.protection.outlook.de`, and `*.mx.microsoft` where the domain
179/// signs its mail exchange with DNSSEC). A whole label before each, so that
180/// names only made to look like these are not taken.
181pub fn microsoft_mx_kind(host: &str) -> Option<MicrosoftKind> {
182    let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
183    let under = |suffix: &str| host.strip_suffix(suffix).is_some_and(|label| !label.is_empty() && !label.ends_with('.'));
184    if under(".olc.protection.outlook.com") {
185        Some(MicrosoftKind::Personal)
186    } else if [".mail.protection.outlook.com", ".mail.eo.outlook.com", ".mail.protection.outlook.de", ".mx.microsoft"].iter().any(|s| under(s)) {
187        Some(MicrosoftKind::Work)
188    } else {
189        None
190    }
191}
192
193/// Google's servers for an address whose mail Google keeps: IMAP on 993 and
194/// submission on 465, both encrypted from the first byte; the address as login.
195pub fn google_found(address: &str) -> Found {
196    Found {
197        host: GOOGLE_IMAP.into(),
198        port: 993,
199        security: Security::Tls,
200        username: address.trim().to_string(),
201        by: FoundBy::Google,
202        smtp: Some(Smtp { host: GOOGLE_SMTP.into(), port: 465, security: Security::Tls }),
203    }
204}
205
206/// Gmail's own domains.
207pub fn is_google_domain(domain: &str) -> bool {
208    matches!(domain.trim().trim_end_matches('.').to_ascii_lowercase().as_str(), "gmail.com" | "googlemail.com")
209}
210
211/// Whether a mail exchanger is Google's: smtp.google.com (Workspace since
212/// 2023), aspmx.l.google.com and altN.aspmx.l.google.com, aspmxN.googlemail.com.
213/// Only Google names hosts under google.com and googlemail.com.
214pub fn is_google_mx(host: &str) -> bool {
215    let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
216    host.ends_with(".google.com") || host.ends_with(".googlemail.com")
217}
218
219/// Whether Google keeps an address's mail: its domain is Gmail's, or its MX
220/// (asked of the system's DNS) is Google's.
221pub fn is_google_mail(address: &str) -> bool {
222    is_google_mail_with(address, mx_hosts)
223}
224
225/// `is_google_mail`, the MX asked of `mx`.
226pub(crate) fn is_google_mail_with(address: &str, mx: impl Fn(&str) -> Option<Vec<String>>) -> bool {
227    let Some(domain) = domain_of(address.trim()) else { return false };
228    is_google_domain(&domain) || mx(&domain).is_some_and(|hosts| hosts.iter().any(|h| is_google_mx(h)))
229}
230
231/// The hosts that receive a domain's mail (its MX records), asked of the
232/// system's DNS as the sender checks do (on Android, the network's servers,
233/// which hickory reads through ConnectivityManager); None when it cannot be
234/// asked or does not answer within a few seconds.
235fn mx_hosts(domain: &str) -> Option<Vec<String>> {
236    let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().ok()?;
237    runtime.block_on(async {
238        let resolver = mail_auth::MessageAuthenticator::new_system_conf().ok()?;
239        let none = None::<&mail_auth::common::cache::NoCache<Box<str>, mail_auth::RecordSet<mail_auth::MX>>>;
240        let found = tokio::time::timeout(PROBE, resolver.mx_lookup(domain, none)).await.ok()?.ok()?;
241        Some(found.rrset.iter().flat_map(|mx| mx.exchanges.iter().map(|host| host.trim_end_matches('.').to_string())).collect())
242    })
243}
244
245fn fetch(agent: &ureq::Agent, url: &str) -> Option<String> {
246    agent.get(url).call().ok()?.body_mut().read_to_string().ok()
247}
248
249/// `imap.<domain>`, then `mail.<domain>`, if something answers on port 993.
250fn guess(domain: &str, address: &str) -> Option<Found> {
251    [format!("imap.{domain}"), format!("mail.{domain}")].into_iter().find(|host| answers(host, 993)).map(|host| Found {
252        host,
253        port: 993,
254        security: Security::Tls,
255        username: address.to_string(),
256        by: FoundBy::Guess,
257        smtp: None,
258    })
259}
260
261fn answers(host: &str, port: u16) -> bool {
262    let Ok(addresses) = (host, port).to_socket_addrs() else { return false };
263    addresses.into_iter().any(|a| TcpStream::connect_timeout(&a, PROBE).is_ok())
264}
265
266fn domain_of(address: &str) -> Option<String> {
267    let (local, domain) = address.rsplit_once('@')?;
268    let valid = !local.is_empty() && domain.contains('.') && !domain.contains(['/', ' ', '?', '#']);
269    valid.then(|| domain.to_ascii_lowercase())
270}
271
272/// Percent-encodes a query value (RFC 3986 §2.3 keeps only unreserved characters).
273fn encode(value: &str) -> String {
274    value
275        .bytes()
276        .map(|b| match b {
277            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(),
278            _ => format!("%{b:02X}"),
279        })
280        .collect()
281}
282
283/// Reads the IMAP servers of a `config-v1.1.xml` file; TLS from the first byte
284/// is preferred to STARTTLS (RFC 8314 §3), and plain connections are ignored.
285pub(crate) fn parse_config(xml: &str, address: &str, by: FoundBy) -> Option<Found> {
286    let servers: Vec<Found> = blocks(xml, "incomingServer")
287        .filter(|(attributes, _)| attributes.contains("\"imap\"") || attributes.contains("'imap'"))
288        .filter_map(|(_, body)| server(body, address, by))
289        .collect();
290    let outgoing: Vec<Found> = blocks(xml, "outgoingServer")
291        .filter(|(attributes, _)| attributes.contains("\"smtp\"") || attributes.contains("'smtp'"))
292        .filter_map(|(_, body)| server(body, address, by))
293        .collect();
294    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 });
295    let mut found = servers.iter().find(|s| s.security == Security::Tls).or(servers.first()).cloned()?;
296    found.smtp = smtp;
297    Some(found)
298}
299
300/// The SMTP server of an address: from its settings, else `smtp.<domain>`, then the IMAP host, on 465.
301/// An account at Microsoft is never sent here: its sending server is set when it is added.
302pub fn discover_smtp(address: &str, imap_host: Option<&str>) -> Result<Smtp, SyncError> {
303    if let Ok(Found { smtp: Some(smtp), .. }) = discover(address) {
304        return Ok(smtp);
305    }
306    let domain = domain_of(address).ok_or(SyncError::BadAddress)?;
307    [format!("smtp.{domain}"), imap_host.unwrap_or(&format!("mail.{domain}")).to_string()]
308        .into_iter()
309        .find(|host| answers(host, 465))
310        .map(|host| Smtp { host, port: 465, security: Security::Tls })
311        .ok_or(SyncError::NotFound(domain))
312}
313
314fn server(body: &str, address: &str, by: FoundBy) -> Option<Found> {
315    let security = match tag(body, "socketType")? {
316        "SSL" => Security::Tls,
317        "STARTTLS" => Security::Starttls,
318        _ => return None,
319    };
320    Some(Found {
321        host: substitute(tag(body, "hostname")?, address),
322        port: tag(body, "port")?.parse().ok()?,
323        security,
324        username: substitute(tag(body, "username").unwrap_or("%EMAILADDRESS%"), address),
325        by,
326        smtp: None,
327    })
328}
329
330/// Each `<name attributes>body</name>`, as (attributes, body).
331fn blocks<'a>(xml: &'a str, name: &'a str) -> impl Iterator<Item = (&'a str, &'a str)> + 'a {
332    let open = format!("<{name}");
333    let close = format!("</{name}>");
334    let mut rest = xml;
335    std::iter::from_fn(move || {
336        let start = rest.find(&open)? + open.len();
337        let after = &rest[start..];
338        let end_of_tag = after.find('>')?;
339        let body_end = after.find(&close)?;
340        let found = (&after[..end_of_tag], after.get(end_of_tag + 1..body_end)?);
341        rest = &after[body_end + close.len()..];
342        Some(found)
343    })
344}
345
346/// The text of the first `<name>text</name>`, trimmed.
347fn tag<'a>(body: &'a str, name: &str) -> Option<&'a str> {
348    let start = body.find(&format!("<{name}>"))? + name.len() + 2;
349    let end = start + body[start..].find(&format!("</{name}>"))?;
350    Some(body[start..end].trim())
351}
352
353/// Thunderbird's placeholders: %EMAILADDRESS%, %EMAILLOCALPART%, %EMAILDOMAIN%.
354fn substitute(template: &str, address: &str) -> String {
355    let (local, domain) = address.rsplit_once('@').unwrap_or((address, ""));
356    template.replace("%EMAILADDRESS%", address).replace("%EMAILLOCALPART%", local).replace("%EMAILDOMAIN%", domain)
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    /// The shape a cPanel host publishes at autoconfig.<domain>.
364    const CPANEL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
365<clientConfig version="1.1">
366  <emailProvider id="example.com">
367    <domain>example.com</domain>
368    <incomingServer type="imap">
369      <hostname>mail.example.com</hostname>
370      <port>143</port>
371      <socketType>STARTTLS</socketType>
372      <username>%EMAILADDRESS%</username>
373      <authentication>password-cleartext</authentication>
374    </incomingServer>
375    <incomingServer type="imap">
376      <hostname>mail.example.com</hostname>
377      <port>993</port>
378      <socketType>SSL</socketType>
379      <username>%EMAILADDRESS%</username>
380      <authentication>password-cleartext</authentication>
381    </incomingServer>
382    <outgoingServer type="smtp">
383      <hostname>mail.example.com</hostname>
384      <port>465</port>
385      <socketType>SSL</socketType>
386      <username>%EMAILADDRESS%</username>
387    </outgoingServer>
388    <incomingServer type="pop3">
389      <hostname>mail.example.com</hostname>
390      <port>995</port>
391      <socketType>SSL</socketType>
392    </incomingServer>
393  </emailProvider>
394</clientConfig>"#;
395
396    /// The shape of the ISPDB's entries, with a plain server to ignore.
397    const ISPDB: &str = r#"<clientConfig version="1.1"><emailProvider id="example.net">
398      <incomingServer type='imap'><hostname>imap.%EMAILDOMAIN%</hostname><port>143</port>
399      <socketType>plain</socketType><username>%EMAILLOCALPART%</username></incomingServer>
400      <incomingServer type='imap'><hostname>imap.%EMAILDOMAIN%</hostname><port>143</port>
401      <socketType>STARTTLS</socketType><username>%EMAILLOCALPART%</username></incomingServer>
402    </emailProvider></clientConfig>"#;
403
404    #[test]
405    fn prefers_tls_from_the_first_byte() {
406        let found = parse_config(CPANEL, "someone@example.com", FoundBy::Provider).unwrap();
407        assert_eq!((found.host.as_str(), found.port, found.security), ("mail.example.com", 993, Security::Tls));
408        assert_eq!(found.username, "someone@example.com");
409        assert_eq!(found.smtp, Some(Smtp { host: "mail.example.com".into(), port: 465, security: Security::Tls }));
410    }
411
412    #[test]
413    fn fills_placeholders_and_skips_plain() {
414        let found = parse_config(ISPDB, "jane@example.net", FoundBy::Ispdb).unwrap();
415        assert_eq!((found.host.as_str(), found.port, found.security), ("imap.example.net", 143, Security::Starttls));
416        assert_eq!(found.username, "jane");
417        assert_eq!(parse_config("<clientConfig/>", "a@example.org", FoundBy::Ispdb), None);
418    }
419
420    /// Google's mail, by its domains, else by the domain's MX (a stand-in
421    /// for the DNS here); nothing asked for Gmail's own domains.
422    #[test]
423    fn knows_google_mail() {
424        let never = |domain: &str| -> Option<Vec<String>> { panic!("{domain}: Gmail's domains need no DNS") };
425        for address in ["someone@gmail.com", " Someone@GMail.COM ", "someone@googlemail.com"] {
426            assert!(is_google_mail_with(address, never), "{address}");
427            assert_eq!(discover_with(address, never).unwrap(), google_found(address));
428        }
429        let mx = |domain: &str| -> Option<Vec<String>> {
430            match domain {
431                // Google Workspace since 2023, then before (with a trailing dot, as DNS gives it).
432                "new-workspace.example" => Some(vec!["smtp.google.com".into()]),
433                "old-workspace.example" => Some(vec!["aspmx.l.google.com.".into(), "alt1.aspmx.l.google.com.".into(), "aspmx2.googlemail.com.".into()]),
434                "elsewhere.example" => Some(vec!["mx.elsewhere.example".into()]),
435                // Names that only look like Google's.
436                "lookalike.example" => Some(vec!["google.com.lookalike.example".into(), "notgoogle.com".into(), "aspmx.l.google.com.evil.example".into()]),
437                _ => None,
438            }
439        };
440        assert!(is_google_mail_with("you@new-workspace.example", mx));
441        assert!(is_google_mail_with("you@old-workspace.example", mx));
442        assert!(!is_google_mail_with("you@elsewhere.example", mx));
443        assert!(!is_google_mail_with("you@lookalike.example", mx));
444        assert!(!is_google_mail_with("you@unanswered.example", mx), "no answer: not Google");
445        assert!(!is_google_mail_with("not an address", mx));
446        // Google's servers, encrypted from the first byte, the address as login.
447        let found = google_found("you@new-workspace.example");
448        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));
449        assert_eq!(found.smtp, Some(Smtp { host: "smtp.gmail.com".into(), port: 465, security: Security::Tls }));
450    }
451
452    /// Part 8, 7: Microsoft's mail, by Outlook.com's own domains (no DNS
453    /// asked), else by the domain's MX; names only made to look like
454    /// Microsoft's are not taken; settings naming its server are Microsoft's.
455    #[test]
456    fn knows_microsoft_mail() {
457        let never = |domain: &str| -> Option<Vec<String>> { panic!("{domain}: Outlook.com's domains need no DNS") };
458        for address in ["someone@outlook.com", " Someone@Hotmail.FR ", "someone@live.co.uk", "someone@msn.com", "someone@hotmail.com.br", "someone@outlook.jp"] {
459            let found = discover_with(address, never).unwrap();
460            assert_eq!(found, microsoft_found(address, MicrosoftKind::Personal), "{address}");
461        }
462        assert_eq!(MICROSOFT_CONSUMER_DOMAINS.len(), 102, "the ISPDB's list, whole (its 103rd name, olc.protection.outlook.com, is an exchanger's)");
463        assert!(!is_microsoft_consumer_domain("outlook.com.evil.example") && !is_microsoft_consumer_domain("notoutlook.com"));
464        // Outlook.com's and Microsoft 365's exchangers, with the trailing dot DNS gives.
465        assert_eq!(microsoft_mx_kind("outlook-com.olc.protection.outlook.com."), Some(MicrosoftKind::Personal));
466        assert_eq!(microsoft_mx_kind("eur.olc.protection.outlook.com"), Some(MicrosoftKind::Personal));
467        for work in ["contoso-com.mail.protection.outlook.com", "contoso-com.o-v1.mx.microsoft", "x.mail.eo.outlook.com", "contoso-de.mail.protection.outlook.de"] {
468            assert_eq!(microsoft_mx_kind(work), Some(MicrosoftKind::Work), "{work}");
469        }
470        for lookalike in ["mail.protection.outlook.com.evil.example", "evilmx.microsoft.example", "mail.protection.outlook.com", ".mail.protection.outlook.com", "evilmail.protection.outlook.com", "olc.protection.outlook.com", "mx.microsoft"] {
471            assert_eq!(microsoft_mx_kind(lookalike), None, "{lookalike}");
472        }
473        // Settings naming Microsoft's server: Microsoft's, of the kind its sending server says.
474        let settings = parse_config(
475            "<clientConfig><emailProvider><incomingServer type=\"imap\"><hostname>outlook.office365.com</hostname><port>993</port><socketType>SSL</socketType></incomingServer>\
476             <outgoingServer type=\"smtp\"><hostname>smtp.office365.com</hostname><port>587</port><socketType>STARTTLS</socketType></outgoingServer></emailProvider></clientConfig>",
477            "you@contoso.example",
478            FoundBy::Provider,
479        )
480        .unwrap();
481        assert_eq!(microsoft_if_named(settings, "you@contoso.example"), microsoft_found("you@contoso.example", MicrosoftKind::Work));
482        // Microsoft's servers: 993 encrypted from the first byte, 587 with STARTTLS, the sending server by kind.
483        let found = microsoft_found("you@contoso.example", MicrosoftKind::Work);
484        assert_eq!((found.host.as_str(), found.port, found.security, found.by), ("outlook.office365.com", 993, Security::Tls, FoundBy::Microsoft(MicrosoftKind::Work)));
485        assert_eq!(found.smtp, Some(Smtp { host: "smtp.office365.com".into(), port: 587, security: Security::Starttls }));
486        assert_eq!(microsoft_found("you@hotmail.fr", MicrosoftKind::Personal).smtp.unwrap().host, "smtp-mail.outlook.com");
487    }
488
489    #[test]
490    fn reads_addresses_carefully() {
491        assert_eq!(domain_of("Someone@Example.org").as_deref(), Some("example.org"));
492        assert_eq!(domain_of("nobody"), None);
493        assert_eq!(domain_of("a@example.org/evil"), None);
494        assert_eq!(encode("a+b@example.org"), "a%2Bb%40example.org");
495    }
496}