feat: enhance autoconfig detection

This commit is contained in:
rustmailer
2026-05-26 15:19:39 +08:00
parent 04a022e850
commit c0a63a1e3c
6 changed files with 430 additions and 19 deletions
+102 -18
View File
@@ -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<MailConfig> {
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<MailConfig> {
// ---------------------------------------------------------------------------
/// 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<MailConfig> {
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<MailConfig> {
{
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<MailConfig> {
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<String> {
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<String> {
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::*;
+105
View File
@@ -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 <http://www.gnu.org/licenses/>.
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<Guess> {
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<MailConfig> {
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
}
+10 -1
View File
@@ -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<MailSe
}
};
// Detect OAuth2 support: the XML <authentication> 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,
})
}
+2
View File
@@ -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;
@@ -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 <http://www.gnu.org/licenses/>.
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<OAuth2Config> {
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());
}
}
+60
View File
@@ -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");
}