mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: enhance autoconfig detection
This commit is contained in:
@@ -45,6 +45,10 @@ pub struct IncomingServer {
|
|||||||
#[serde(rename = "socketType")]
|
#[serde(rename = "socketType")]
|
||||||
pub socket_type: String,
|
pub socket_type: String,
|
||||||
pub username: 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)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
@@ -137,6 +141,7 @@ async fn lookup_srv(domain: &str) -> Option<MailConfig> {
|
|||||||
port: imap_port,
|
port: imap_port,
|
||||||
socket_type: "SSL".to_string(),
|
socket_type: "SSL".to_string(),
|
||||||
username: "%EMAILADDRESS%".to_string(),
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
authentication: String::new(),
|
||||||
}],
|
}],
|
||||||
outgoing: vec![OutgoingServer {
|
outgoing: vec![OutgoingServer {
|
||||||
protocol: "smtp".to_string(),
|
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
|
/// 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:
|
/// Probe order:
|
||||||
/// 1. `https://autoconfig.{domain}/mail/config-v1.1.xml`
|
/// 1. `https://autoconfig.{domain}/mail/config-v1.1.xml`
|
||||||
/// 2. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
|
/// 2. `http://autoconfig.{domain}/mail/config-v1.1.xml`
|
||||||
/// 3. DNS SRV records (`_imaps._tcp` / `_submission._tcp`)
|
/// 3. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
|
||||||
/// 4. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`)
|
/// 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> {
|
pub async fn fetch(domain: &str) -> BichonResult<MailConfig> {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
|
|
||||||
// 1. Try autoconfig subdomain
|
// ── ISP autoconfig (HTTPS, then HTTP) ──────────────────────────
|
||||||
if let Some(config) = fetch_xml(
|
if let Some(config) =
|
||||||
&client,
|
fetch_xml(&client, &format!("https://autoconfig.{domain}/mail/config-v1.1.xml")).await
|
||||||
&format!("https://autoconfig.{domain}/mail/config-v1.1.xml"),
|
{
|
||||||
)
|
return Ok(config);
|
||||||
.await
|
}
|
||||||
|
if let Some(config) =
|
||||||
|
fetch_xml(&client, &format!("http://autoconfig.{domain}/mail/config-v1.1.xml")).await
|
||||||
{
|
{
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Try well-known path
|
// ── Well-known path (HTTPS, then HTTP) ─────────────────────────
|
||||||
if let Some(config) = fetch_xml(
|
if let Some(config) = fetch_xml(
|
||||||
&client,
|
&client,
|
||||||
&format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
|
&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);
|
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 {
|
if let Some(config) = lookup_srv(domain).await {
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fall back to Thunderbird central database
|
// ── Thunderbird central ISPDB ──────────────────────────────────
|
||||||
if let Some(config) = fetch_xml(
|
if let Some(config) =
|
||||||
&client,
|
fetch_xml(&client, &format!("https://autoconfig.thunderbird.net/v1.1/{domain}")).await
|
||||||
&format!("https://autoconfig.thunderbird.net/v1.1/{domain}"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
return Ok(config);
|
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!(
|
Err(raise_error!(
|
||||||
format!("No autoconfig found for domain: {domain}"),
|
format!("No autoconfig found for domain: {domain}"),
|
||||||
ErrorCode::InternalError
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
use crate::account::entity::Encryption;
|
use crate::account::entity::Encryption;
|
||||||
use crate::autoconfig::client::{self, MailConfig};
|
use crate::autoconfig::client::{self, MailConfig};
|
||||||
use crate::autoconfig::entity::{MailServerConfig, ServerConfig};
|
use crate::autoconfig::entity::{MailServerConfig, ServerConfig};
|
||||||
|
use crate::autoconfig::oauth2_providers::lookup_oauth2;
|
||||||
use crate::autoconfig::CachedMailSettings;
|
use crate::autoconfig::CachedMailSettings;
|
||||||
use crate::error::code::ErrorCode;
|
use crate::error::code::ErrorCode;
|
||||||
use crate::error::BichonResult;
|
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 {
|
Some(MailServerConfig {
|
||||||
imap: ServerConfig::new(imap.hostname.clone(), port, encryption),
|
imap: ServerConfig::new(imap.hostname.clone(), port, encryption),
|
||||||
oauth2: None,
|
oauth2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
|
pub mod guess;
|
||||||
pub mod load;
|
pub mod load;
|
||||||
|
mod oauth2_providers;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,6 +195,7 @@ fn make_imap_server(host: &str, port: u16, socket_type: &str) -> IncomingServer
|
|||||||
port,
|
port,
|
||||||
socket_type: socket_type.to_string(),
|
socket_type: socket_type.to_string(),
|
||||||
username: "%EMAILADDRESS%".to_string(),
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
authentication: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +242,7 @@ fn convert_no_imap_only_pop3() {
|
|||||||
port: 995,
|
port: 995,
|
||||||
socket_type: "SSL".to_string(),
|
socket_type: "SSL".to_string(),
|
||||||
username: "%EMAILADDRESS%".to_string(),
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
authentication: String::new(),
|
||||||
}],
|
}],
|
||||||
outgoing: vec![],
|
outgoing: vec![],
|
||||||
};
|
};
|
||||||
@@ -266,6 +268,7 @@ fn convert_picks_imap_over_pop3() {
|
|||||||
port: 995,
|
port: 995,
|
||||||
socket_type: "SSL".to_string(),
|
socket_type: "SSL".to_string(),
|
||||||
username: "%EMAILADDRESS%".to_string(),
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
authentication: String::new(),
|
||||||
},
|
},
|
||||||
make_imap_server("imap.example.com", 993, "SSL"),
|
make_imap_server("imap.example.com", 993, "SSL"),
|
||||||
],
|
],
|
||||||
@@ -284,6 +287,7 @@ fn convert_imaps_protocol_variant() {
|
|||||||
port: 993,
|
port: 993,
|
||||||
socket_type: "SSL".to_string(),
|
socket_type: "SSL".to_string(),
|
||||||
username: "%EMAILADDRESS%".to_string(),
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
authentication: String::new(),
|
||||||
}],
|
}],
|
||||||
outgoing: vec![],
|
outgoing: vec![],
|
||||||
};
|
};
|
||||||
@@ -300,9 +304,65 @@ fn convert_case_insensitive_protocol() {
|
|||||||
port: 143,
|
port: 143,
|
||||||
socket_type: "STARTTLS".to_string(),
|
socket_type: "STARTTLS".to_string(),
|
||||||
username: "%EMAILADDRESS%".to_string(),
|
username: "%EMAILADDRESS%".to_string(),
|
||||||
|
authentication: String::new(),
|
||||||
}],
|
}],
|
||||||
outgoing: vec![],
|
outgoing: vec![],
|
||||||
};
|
};
|
||||||
let result = mail_config_to_server_config(&config).expect("should recognize 'IMAP'");
|
let result = mail_config_to_server_config(&config).expect("should recognize 'IMAP'");
|
||||||
assert_eq!(result.imap.host, "imap.example.com");
|
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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user