1use crate::SyncError;
22use sioul_core::config::Security;
23use std::net::{TcpStream, ToSocketAddrs};
24use std::time::Duration;
25
26pub const GOOGLE_IMAP: &str = "imap.gmail.com";
28pub const GOOGLE_SMTP: &str = "smtp.gmail.com";
29
30#[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#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Smtp {
44 pub host: String,
45 pub port: u16,
46 pub security: Security,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum FoundBy {
52 Provider,
53 Ispdb,
54 Guess,
55 Google,
57}
58
59impl FoundBy {
60 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
74pub fn discover(address: &str) -> Result<Found, SyncError> {
76 discover_with(address, mx_hosts)
77}
78
79pub(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 if is_google_domain(&domain) {
85 return Ok(google_found(address));
86 }
87 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 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
107pub 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
120pub 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
125pub 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
133pub fn is_google_mail(address: &str) -> bool {
136 is_google_mail_with(address, mx_hosts)
137}
138
139pub(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
145fn 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
163fn 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
186fn 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
197pub(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
214pub 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
243fn 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
259fn 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
266fn 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 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 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 #[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 "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 "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 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}