From c0a63a1e3cfff5caeb85b2ec2bd9c637c28d98ee Mon Sep 17 00:00:00 2001 From: rustmailer Date: Tue, 26 May 2026 15:19:39 +0800 Subject: [PATCH] feat: enhance autoconfig detection --- crates/core/src/autoconfig/client.rs | 120 +++++++++++--- crates/core/src/autoconfig/guess.rs | 105 ++++++++++++ crates/core/src/autoconfig/load.rs | 11 +- crates/core/src/autoconfig/mod.rs | 2 + .../core/src/autoconfig/oauth2_providers.rs | 151 ++++++++++++++++++ crates/core/src/autoconfig/tests.rs | 60 +++++++ 6 files changed, 430 insertions(+), 19 deletions(-) create mode 100644 crates/core/src/autoconfig/guess.rs create mode 100644 crates/core/src/autoconfig/oauth2_providers.rs diff --git a/crates/core/src/autoconfig/client.rs b/crates/core/src/autoconfig/client.rs index dcc1d1a..1bc5656 100644 --- a/crates/core/src/autoconfig/client.rs +++ b/crates/core/src/autoconfig/client.rs @@ -45,6 +45,10 @@ pub struct IncomingServer { #[serde(rename = "socketType")] pub socket_type: String, pub username: String, + /// Authentication method from the XML, e.g. "OAuth2", "password-cleartext", + /// "password-encrypted", "GSSAPI", "NTLM". Absent in DNS SRV fallback. + #[serde(default)] + pub authentication: String, } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] @@ -137,6 +141,7 @@ async fn lookup_srv(domain: &str) -> Option { port: imap_port, socket_type: "SSL".to_string(), username: "%EMAILADDRESS%".to_string(), + authentication: String::new(), }], outgoing: vec![OutgoingServer { protocol: "smtp".to_string(), @@ -153,30 +158,37 @@ async fn lookup_srv(domain: &str) -> Option { // --------------------------------------------------------------------------- /// Discover mail server configuration for a domain using the Thunderbird -/// autoconfig protocol (ISPDB) and DNS SRV fallback. +/// autoconfig protocol (ISPDB), DNS SRV, MX fallback, and finally guessing. /// /// Probe order: /// 1. `https://autoconfig.{domain}/mail/config-v1.1.xml` -/// 2. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml` -/// 3. DNS SRV records (`_imaps._tcp` / `_submission._tcp`) -/// 4. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`) +/// 2. `http://autoconfig.{domain}/mail/config-v1.1.xml` +/// 3. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml` +/// 4. `http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml` +/// 5. DNS SRV records (`_imaps._tcp` / `_submission._tcp`) +/// 6. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`) +/// 7. MX lookup → ISPDB for MX domain +/// 8. MX lookup → ISP autoconfig for MX domain +/// 9. GuessConfig — probe common hostnames + ports pub async fn fetch(domain: &str) -> BichonResult { let client = Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; - // 1. Try autoconfig subdomain - if let Some(config) = fetch_xml( - &client, - &format!("https://autoconfig.{domain}/mail/config-v1.1.xml"), - ) - .await + // ── ISP autoconfig (HTTPS, then HTTP) ────────────────────────── + if let Some(config) = + fetch_xml(&client, &format!("https://autoconfig.{domain}/mail/config-v1.1.xml")).await + { + return Ok(config); + } + if let Some(config) = + fetch_xml(&client, &format!("http://autoconfig.{domain}/mail/config-v1.1.xml")).await { return Ok(config); } - // 2. Try well-known path + // ── Well-known path (HTTPS, then HTTP) ───────────────────────── if let Some(config) = fetch_xml( &client, &format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"), @@ -185,28 +197,100 @@ pub async fn fetch(domain: &str) -> BichonResult { { return Ok(config); } + if let Some(config) = fetch_xml( + &client, + &format!("http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"), + ) + .await + { + return Ok(config); + } - // 3. Try DNS SRV records + // ── DNS SRV records ──────────────────────────────────────────── if let Some(config) = lookup_srv(domain).await { return Ok(config); } - // 4. Fall back to Thunderbird central database - if let Some(config) = fetch_xml( - &client, - &format!("https://autoconfig.thunderbird.net/v1.1/{domain}"), - ) - .await + // ── Thunderbird central ISPDB ────────────────────────────────── + if let Some(config) = + fetch_xml(&client, &format!("https://autoconfig.thunderbird.net/v1.1/{domain}")).await { return Ok(config); } + // ── MX fallback ──────────────────────────────────────────────── + if let Some(config) = fetch_for_mx(&client, domain).await { + return Ok(config); + } + + // ── GuessConfig ──────────────────────────────────────────────── + if let Some(config) = crate::autoconfig::guess::guess_config(domain).await { + return Ok(config); + } + Err(raise_error!( format!("No autoconfig found for domain: {domain}"), ErrorCode::InternalError )) } +/// DNS MX lookup → retry ISPDB and ISP autoconfig for the MX domain. +/// +/// Many self-hosted domains have their MX pointed at Google, Microsoft, etc. +/// The MX domain's ISPDB entry covers the original domain. +async fn fetch_for_mx(client: &Client, domain: &str) -> Option { + let mx_domain = lookup_mx_domain(domain).await?; + if mx_domain == domain.to_ascii_lowercase() { + return None; // same domain, already tried above + } + + // Try ISPDB for the MX domain + if let Some(config) = + fetch_xml(client, &format!("https://autoconfig.thunderbird.net/v1.1/{mx_domain}")).await + { + return Some(config); + } + + // Try ISP autoconfig for the MX domain (HTTPS then HTTP) + if let Some(config) = + fetch_xml(client, &format!("https://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await + { + return Some(config); + } + if let Some(config) = + fetch_xml(client, &format!("http://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await + { + return Some(config); + } + + None +} + +/// DNS MX lookup → extract the second-level domain of the first MX hostname. +async fn lookup_mx_domain(domain: &str) -> Option { + let resolver = TokioResolver::builder(TokioConnectionProvider::default()) + .ok()? + .build(); + let lookup = resolver.mx_lookup(domain).await.ok()?; + let record = lookup.iter().next()?; + let mx_host = record.to_string().trim_end_matches('.').to_string(); + + // Extract a reasonable base domain from the MX hostname. + // E.g., "aspmx.l.google.com" → "google.com" + // "company.mail.protection.outlook.com" → "outlook.com" + extract_base_domain(&mx_host) +} + +/// Extract the top two labels from a hostname as a rough base domain. +fn extract_base_domain(host: &str) -> Option { + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() >= 2 { + Some(parts[parts.len() - 2..].join(".")) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/autoconfig/guess.rs b/crates/core/src/autoconfig/guess.rs new file mode 100644 index 0000000..9acd4cc --- /dev/null +++ b/crates/core/src/autoconfig/guess.rs @@ -0,0 +1,105 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::account::entity::Encryption; +use crate::autoconfig::client::{IncomingServer, MailConfig}; +use crate::imap::client::Client; +use tracing::{debug, info}; + +/// A single host:port:encryption combination to probe. +struct Guess { + hostname: String, + port: u16, + encryption: Encryption, + socket_type: &'static str, +} + +/// Generate candidates in the same order Thunderbird uses: +/// 1. imap.{domain} — most common +/// 2. mail.{domain} — fallback +/// 3. {domain} — bare domain (rare) +fn make_guesses(domain: &str) -> Vec { + let hosts = [ + format!("imap.{domain}"), + format!("mail.{domain}"), + domain.to_string(), + ]; + + let mut guesses = Vec::with_capacity(hosts.len() * 2); + for host in &hosts { + guesses.push(Guess { + hostname: host.clone(), + port: 993, + encryption: Encryption::Ssl, + socket_type: "SSL", + }); + guesses.push(Guess { + hostname: host.clone(), + port: 143, + encryption: Encryption::StartTls, + socket_type: "STARTTLS", + }); + } + guesses +} + +/// Try to open a connection, read the IMAP banner, and close. +/// Returns `true` if the server responds with an IMAP greeting. +async fn probe(hostname: &str, port: u16, encryption: &Encryption) -> bool { + match Client::connection(hostname, encryption, port, None, true).await { + Ok(_) => { + debug!("GuessConfig probe succeeded: {hostname}:{port} ({encryption:?})"); + true + } + Err(e) => { + debug!("GuessConfig probe failed for {hostname}:{port}: {e:?}"); + false + } + } +} + +/// Thunderbird-style guessing: try common hostnames and ports, probing +/// each with a real TCP connection. +/// +/// Returns the first working `MailConfig`, or `None` if nothing works. +pub async fn guess_config(domain: &str) -> Option { + let guesses = make_guesses(domain); + info!("GuessConfig: trying {} candidates for {domain}", guesses.len()); + + for g in &guesses { + if probe(&g.hostname, g.port, &g.encryption).await { + info!( + "GuessConfig: found working IMAP at {}:{} ({})", + g.hostname, g.port, g.socket_type + ); + return Some(MailConfig { + incoming: vec![IncomingServer { + protocol: "imap".to_string(), + hostname: g.hostname.clone(), + port: g.port, + socket_type: g.socket_type.to_string(), + username: "%EMAILADDRESS%".to_string(), + authentication: String::new(), + }], + outgoing: vec![], + }); + } + } + + None +} diff --git a/crates/core/src/autoconfig/load.rs b/crates/core/src/autoconfig/load.rs index 9cf89ef..1408681 100644 --- a/crates/core/src/autoconfig/load.rs +++ b/crates/core/src/autoconfig/load.rs @@ -19,6 +19,7 @@ use crate::account::entity::Encryption; use crate::autoconfig::client::{self, MailConfig}; use crate::autoconfig::entity::{MailServerConfig, ServerConfig}; +use crate::autoconfig::oauth2_providers::lookup_oauth2; use crate::autoconfig::CachedMailSettings; use crate::error::code::ErrorCode; use crate::error::BichonResult; @@ -54,9 +55,17 @@ pub(crate) fn mail_config_to_server_config(config: &MailConfig) -> Option field and a known + // hostname → issuer mapping determine whether the provider supports OAuth2. + let oauth2 = if imap.authentication.eq_ignore_ascii_case("OAuth2") { + lookup_oauth2(&imap.hostname) + } else { + None + }; + Some(MailServerConfig { imap: ServerConfig::new(imap.hostname.clone(), port, encryption), - oauth2: None, + oauth2, }) } diff --git a/crates/core/src/autoconfig/mod.rs b/crates/core/src/autoconfig/mod.rs index 3980974..4f8afc7 100644 --- a/crates/core/src/autoconfig/mod.rs +++ b/crates/core/src/autoconfig/mod.rs @@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize}; pub mod client; pub mod entity; +pub mod guess; pub mod load; +mod oauth2_providers; #[cfg(test)] mod tests; diff --git a/crates/core/src/autoconfig/oauth2_providers.rs b/crates/core/src/autoconfig/oauth2_providers.rs new file mode 100644 index 0000000..71b8227 --- /dev/null +++ b/crates/core/src/autoconfig/oauth2_providers.rs @@ -0,0 +1,151 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::autoconfig::entity::OAuth2Config; + +/// Per-provider OAuth2 metadata, mirroring Thunderbird's `OAuth2Providers.sys.mjs`. +/// +/// Each entry maps one or more IMAP hostname suffixes to a well-known OIDC issuer +/// and the IMAP-specific OAuth2 scopes. +struct Provider { + /// Suffixes matched case-insensitively against the end of the IMAP hostname. + host_suffixes: &'static [&'static str], + /// The OIDC issuer URL used by the provider. + issuer: &'static str, + /// OAuth2 scope(s) required for IMAP access. + scopes: &'static [&'static str], +} + +const PROVIDERS: &[Provider] = &[ + // Google + Provider { + host_suffixes: &["imap.gmail.com", ".gmail.com", ".googlemail.com"], + issuer: "https://accounts.google.com", + scopes: &["https://mail.google.com/"], + }, + // Microsoft (Outlook / Office 365 / Hotmail / Live) + Provider { + host_suffixes: &[ + "outlook.office365.com", + ".outlook.com", + ".hotmail.com", + ".live.com", + ".office365.com", + ], + issuer: "https://login.microsoftonline.com/common/v2.0", + scopes: &[ + "https://outlook.office365.com/IMAP.AccessAsUser.All", + "offline_access", + ], + }, + // Yahoo / AOL / ATT / Verizon + Provider { + host_suffixes: &[ + "imap.mail.yahoo.com", + ".yahoo.com", + ".yahoodns.net", + ".aol.com", + "imap.aol.com", + ], + issuer: "https://login.yahoo.com", + scopes: &["mail-w"], + }, + // Yandex + Provider { + host_suffixes: &["imap.yandex.ru", "imap.yandex.com", ".yandex.ru"], + issuer: "https://oauth.yandex.com", + scopes: &["imap:all"], + }, + // Mail.ru + Provider { + host_suffixes: &["imap.mail.ru", ".mail.ru", ".bk.ru", ".list.ru", ".inbox.ru"], + issuer: "https://o2.mail.ru", + scopes: &["imap"], + }, + // Fastmail + Provider { + host_suffixes: &["imap.fastmail.com", ".fastmail.com"], + issuer: "https://www.fastmail.com", + scopes: &[ + "https://www.fastmail.com/dev/imap", + "offline_access", + ], + }, + // Comcast + Provider { + host_suffixes: &["imap.comcast.net", ".comcast.net"], + issuer: "https://oauth.xfinity.com", + scopes: &["https://email.comcast.net/"], + }, +]; + +/// Try to find an OAuth2 provider that matches the given IMAP hostname. +/// +/// Matching is case-insensitive and done by suffix: a hostname "imap.gmail.com" +/// matches the suffix ".gmail.com". +pub fn lookup_oauth2(hostname: &str) -> Option { + let host = hostname.to_ascii_lowercase(); + for provider in PROVIDERS { + if provider + .host_suffixes + .iter() + .any(|suffix| host.ends_with(&suffix.to_ascii_lowercase())) + { + return Some(OAuth2Config { + issuer: provider.issuer.to_string(), + scope: provider.scopes.iter().map(|s| s.to_string()).collect(), + auth_url: String::new(), + token_url: String::new(), + }); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_known_providers() { + let cases = [ + ("imap.gmail.com", Some("https://accounts.google.com")), + ("imap.gmail.com", Some("https://accounts.google.com")), + ("outlook.office365.com", Some("https://login.microsoftonline.com/common/v2.0")), + ("imap.mail.yahoo.com", Some("https://login.yahoo.com")), + ("imap.aol.com", Some("https://login.yahoo.com")), + ("imap.yandex.ru", Some("https://oauth.yandex.com")), + ("imap.mail.ru", Some("https://o2.mail.ru")), + ("imap.fastmail.com", Some("https://www.fastmail.com")), + ("imap.comcast.net", Some("https://oauth.xfinity.com")), + ]; + for (hostname, expected_issuer) in &cases { + let result = lookup_oauth2(hostname); + assert_eq!( + result.map(|c| c.issuer), + expected_issuer.map(|s| s.to_string()), + "failed for hostname: {hostname}" + ); + } + } + + #[test] + fn test_unknown_provider() { + assert!(lookup_oauth2("mail.my-company.example").is_none()); + } +} diff --git a/crates/core/src/autoconfig/tests.rs b/crates/core/src/autoconfig/tests.rs index a721116..b9d3670 100644 --- a/crates/core/src/autoconfig/tests.rs +++ b/crates/core/src/autoconfig/tests.rs @@ -195,6 +195,7 @@ fn make_imap_server(host: &str, port: u16, socket_type: &str) -> IncomingServer port, socket_type: socket_type.to_string(), username: "%EMAILADDRESS%".to_string(), + authentication: String::new(), } } @@ -241,6 +242,7 @@ fn convert_no_imap_only_pop3() { port: 995, socket_type: "SSL".to_string(), username: "%EMAILADDRESS%".to_string(), + authentication: String::new(), }], outgoing: vec![], }; @@ -266,6 +268,7 @@ fn convert_picks_imap_over_pop3() { port: 995, socket_type: "SSL".to_string(), username: "%EMAILADDRESS%".to_string(), + authentication: String::new(), }, make_imap_server("imap.example.com", 993, "SSL"), ], @@ -284,6 +287,7 @@ fn convert_imaps_protocol_variant() { port: 993, socket_type: "SSL".to_string(), username: "%EMAILADDRESS%".to_string(), + authentication: String::new(), }], outgoing: vec![], }; @@ -300,9 +304,65 @@ fn convert_case_insensitive_protocol() { port: 143, socket_type: "STARTTLS".to_string(), username: "%EMAILADDRESS%".to_string(), + authentication: String::new(), }], outgoing: vec![], }; let result = mail_config_to_server_config(&config).expect("should recognize 'IMAP'"); assert_eq!(result.imap.host, "imap.example.com"); } + +#[test] +fn convert_gmail_oauth2() { + let config = MailConfig { + incoming: vec![IncomingServer { + protocol: "imap".to_string(), + hostname: "imap.gmail.com".to_string(), + port: 993, + socket_type: "SSL".to_string(), + username: "%EMAILADDRESS%".to_string(), + authentication: "OAuth2".to_string(), + }], + outgoing: vec![], + }; + let result = mail_config_to_server_config(&config).expect("should convert"); + let oauth2 = result.oauth2.expect("Gmail should have OAuth2"); + assert_eq!(oauth2.issuer, "https://accounts.google.com"); + assert!(oauth2.scope.contains(&"https://mail.google.com/".to_string())); +} + +#[test] +fn convert_outlook_oauth2() { + let config = MailConfig { + incoming: vec![IncomingServer { + protocol: "imap".to_string(), + hostname: "outlook.office365.com".to_string(), + port: 993, + socket_type: "SSL".to_string(), + username: "%EMAILADDRESS%".to_string(), + authentication: "OAuth2".to_string(), + }], + outgoing: vec![], + }; + let result = mail_config_to_server_config(&config).expect("should convert"); + let oauth2 = result.oauth2.expect("Outlook should have OAuth2"); + assert!(oauth2.issuer.contains("microsoftonline")); +} + +#[test] +fn convert_unknown_host_no_oauth2() { + // OAuth2 auth flag on an unknown hostname → no OAuth2 returned + let config = MailConfig { + incoming: vec![IncomingServer { + protocol: "imap".to_string(), + hostname: "mail.random-isp.example".to_string(), + port: 993, + socket_type: "SSL".to_string(), + username: "%EMAILADDRESS%".to_string(), + authentication: "OAuth2".to_string(), + }], + outgoing: vec![], + }; + let result = mail_config_to_server_config(&config).expect("should convert"); + assert!(result.oauth2.is_none(), "unknown hostname → no OAuth2 mapping"); +}