1use crate::SyncError;
30pub use crate::microsoft::Kind as MicrosoftKind;
31use sioul_core::config::Security;
32use std::net::{TcpStream, ToSocketAddrs};
33use std::time::Duration;
34
35pub const GOOGLE_IMAP: &str = "imap.gmail.com";
37pub const GOOGLE_SMTP: &str = "smtp.gmail.com";
38
39#[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#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Smtp {
53 pub host: String,
54 pub port: u16,
55 pub security: Security,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum FoundBy {
61 Provider,
62 Ispdb,
63 Guess,
64 Google,
66 Microsoft(MicrosoftKind),
69}
70
71impl FoundBy {
72 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
87pub fn discover(address: &str) -> Result<Found, SyncError> {
89 discover_with(address, mx_hosts)
90}
91
92pub(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 if is_google_domain(&domain) {
98 return Ok(google_found(address));
99 }
100 if is_microsoft_consumer_domain(&domain) {
102 return Ok(microsoft_found(address, MicrosoftKind::Personal));
103 }
104 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 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
129fn 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
140pub 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
154const 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
168pub 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
174pub 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
193pub 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
206pub 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
211pub 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
219pub fn is_google_mail(address: &str) -> bool {
222 is_google_mail_with(address, mx_hosts)
223}
224
225pub(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
231fn 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
249fn 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
272fn 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
283pub(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
300pub 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
330fn 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
346fn 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
353fn 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 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 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 #[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 "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 "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 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 #[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 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 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 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}