2026-02-15 02:20:18 +01:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Net;
|
2024-04-18 01:44:37 +02:00
|
|
|
using System.Net.Http;
|
2024-08-11 23:58:54 +02:00
|
|
|
using System.Text.Json;
|
2026-02-15 02:20:18 +01:00
|
|
|
using System.Threading;
|
2024-04-18 01:44:37 +02:00
|
|
|
using System.Threading.Tasks;
|
2026-02-15 02:20:18 +01:00
|
|
|
using System.Xml.Linq;
|
2024-04-18 01:44:37 +02:00
|
|
|
using Serilog;
|
|
|
|
|
using Wino.Core.Domain.Interfaces;
|
2025-02-14 01:43:52 +01:00
|
|
|
using Wino.Core.Domain.Models;
|
2024-04-18 01:44:37 +02:00
|
|
|
using Wino.Core.Domain.Models.AutoDiscovery;
|
2026-04-19 20:13:09 +02:00
|
|
|
using Wino.Core.Domain.Validation;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
namespace Wino.Core.Services;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-02-15 02:20:18 +01:00
|
|
|
/// Mail and CalDAV endpoint discovery with Thunderbird-style methods and fallbacks.
|
2025-02-16 11:54:23 +01:00
|
|
|
/// </summary>
|
|
|
|
|
public class AutoDiscoveryService : IAutoDiscoveryService
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2026-02-15 02:20:18 +01:00
|
|
|
private const string ThunderbirdIspdbUrl = "https://autoconfig.thunderbird.net/v1.1/";
|
|
|
|
|
private const string FiretrustUrl = "https://emailsettings.firetrust.com/settings?q=";
|
|
|
|
|
private const string GoogleDnsResolveUrl = "https://dns.google/resolve";
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
private static readonly ILogger Logger = Log.ForContext<AutoDiscoveryService>();
|
|
|
|
|
private static readonly StringComparer IgnoreCase = StringComparer.OrdinalIgnoreCase;
|
|
|
|
|
private static readonly HttpMethod OptionsMethod = new("OPTIONS");
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
private readonly HttpClient _httpClient;
|
|
|
|
|
private readonly Dictionary<string, Uri> _calDavUriCache = new(IgnoreCase);
|
|
|
|
|
private readonly object _calDavCacheLock = new();
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
public AutoDiscoveryService(HttpClient httpClient = null)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-15 02:20:18 +01:00
|
|
|
_httpClient = httpClient ?? new HttpClient
|
|
|
|
|
{
|
|
|
|
|
Timeout = TimeSpan.FromSeconds(15)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<AutoDiscoverySettings> GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings)
|
|
|
|
|
{
|
|
|
|
|
if (autoDiscoveryMinimalSettings == null || string.IsNullOrWhiteSpace(autoDiscoveryMinimalSettings.Email))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
if (!TryGetEmailParts(autoDiscoveryMinimalSettings.Email, out var localPart, out var domain))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
|
|
|
|
|
|
var settings = await TryGetThunderbirdSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, localPart, cancellationToken).ConfigureAwait(false)
|
|
|
|
|
?? await TryGetIspdbSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, localPart, cancellationToken).ConfigureAwait(false)
|
|
|
|
|
?? await TryGetMxBasedSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, localPart, cancellationToken).ConfigureAwait(false)
|
|
|
|
|
?? await TryGetSrvBasedSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, cancellationToken).ConfigureAwait(false)
|
|
|
|
|
?? await TryGetGuessedHostSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, cancellationToken).ConfigureAwait(false)
|
|
|
|
|
?? await GetSettingsFromFiretrustAsync(autoDiscoveryMinimalSettings.Email, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (settings != null && string.IsNullOrWhiteSpace(settings.Domain))
|
|
|
|
|
{
|
|
|
|
|
settings.Domain = domain;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return settings;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<Uri> DiscoverCalDavServiceUriAsync(string mailAddress, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
if (!TryGetEmailParts(mailAddress, out _, out var domain))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
lock (_calDavCacheLock)
|
|
|
|
|
{
|
|
|
|
|
if (_calDavUriCache.TryGetValue(domain, out var cachedUri))
|
|
|
|
|
return cachedUri;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var knownProviderUri = TryGetKnownProviderCalDavUri(domain);
|
|
|
|
|
if (knownProviderUri != null)
|
|
|
|
|
{
|
|
|
|
|
CacheCalDavUri(domain, knownProviderUri);
|
|
|
|
|
return knownProviderUri;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var candidate in GetCalDavCandidates(domain))
|
|
|
|
|
{
|
|
|
|
|
var resolved = await TryResolveCalDavEndpointAsync(candidate, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (resolved == null)
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
CacheCalDavUri(domain, resolved);
|
|
|
|
|
return resolved;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<AutoDiscoverySettings> TryGetThunderbirdSettingsAsync(
|
|
|
|
|
string lookupDomain,
|
|
|
|
|
string email,
|
|
|
|
|
string localPart,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
foreach (var endpoint in BuildThunderbirdEndpoints(lookupDomain, email))
|
|
|
|
|
{
|
|
|
|
|
var settings = await TryGetSettingsFromXmlEndpointAsync(endpoint, email, localPart, lookupDomain, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (settings != null)
|
|
|
|
|
return settings;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<AutoDiscoverySettings> TryGetIspdbSettingsAsync(
|
|
|
|
|
string lookupDomain,
|
|
|
|
|
string email,
|
|
|
|
|
string localPart,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var endpoint = $"{ThunderbirdIspdbUrl}{lookupDomain}?emailaddress={Uri.EscapeDataString(email)}";
|
|
|
|
|
return await TryGetSettingsFromXmlEndpointAsync(endpoint, email, localPart, lookupDomain, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<AutoDiscoverySettings> TryGetMxBasedSettingsAsync(
|
|
|
|
|
string domain,
|
|
|
|
|
string email,
|
|
|
|
|
string localPart,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var mxDomains = await GetMxSearchDomainsAsync(domain, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
foreach (var mxDomain in mxDomains)
|
|
|
|
|
{
|
|
|
|
|
if (IgnoreCase.Equals(mxDomain, domain))
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
var settings = await TryGetThunderbirdSettingsAsync(mxDomain, email, localPart, cancellationToken).ConfigureAwait(false)
|
|
|
|
|
?? await TryGetIspdbSettingsAsync(mxDomain, email, localPart, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (settings != null)
|
|
|
|
|
return settings;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<AutoDiscoverySettings> TryGetSrvBasedSettingsAsync(
|
|
|
|
|
string domain,
|
|
|
|
|
string email,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var incoming = await TryResolveSrvRecordAsync($"_imaps._tcp.{domain}", "IMAP", "SSL", cancellationToken).ConfigureAwait(false)
|
|
|
|
|
?? await TryResolveSrvRecordAsync($"_imap._tcp.{domain}", "IMAP", "STARTTLS", cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
var outgoing = await TryResolveSrvRecordAsync($"_submissions._tcp.{domain}", "SMTP", "SSL", cancellationToken).ConfigureAwait(false)
|
|
|
|
|
?? await TryResolveSrvRecordAsync($"_submission._tcp.{domain}", "SMTP", "STARTTLS", cancellationToken).ConfigureAwait(false)
|
|
|
|
|
?? await TryResolveSrvRecordAsync($"_smtp._tcp.{domain}", "SMTP", "STARTTLS", cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (incoming == null || outgoing == null)
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
incoming.Username = email;
|
|
|
|
|
outgoing.Username = email;
|
|
|
|
|
|
|
|
|
|
return new AutoDiscoverySettings
|
|
|
|
|
{
|
|
|
|
|
Domain = domain,
|
|
|
|
|
Settings = [incoming, outgoing]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<AutoDiscoverySettings> TryGetGuessedHostSettingsAsync(
|
|
|
|
|
string domain,
|
|
|
|
|
string email,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var imapHost = await GetFirstResolvableHostAsync(
|
|
|
|
|
[$"imap.{domain}", $"mail.{domain}", domain],
|
|
|
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
var smtpHost = await GetFirstResolvableHostAsync(
|
|
|
|
|
[$"smtp.{domain}", $"mail.{domain}", domain],
|
|
|
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(imapHost) || string.IsNullOrWhiteSpace(smtpHost))
|
|
|
|
|
return null;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
return new AutoDiscoverySettings
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-15 02:20:18 +01:00
|
|
|
Domain = domain,
|
|
|
|
|
Settings =
|
|
|
|
|
[
|
|
|
|
|
new AutoDiscoveryProviderSetting
|
|
|
|
|
{
|
|
|
|
|
Protocol = "IMAP",
|
|
|
|
|
Address = imapHost,
|
|
|
|
|
Port = 993,
|
|
|
|
|
Secure = "SSL",
|
|
|
|
|
Username = email
|
|
|
|
|
},
|
|
|
|
|
new AutoDiscoveryProviderSetting
|
|
|
|
|
{
|
|
|
|
|
Protocol = "SMTP",
|
|
|
|
|
Address = smtpHost,
|
|
|
|
|
Port = 587,
|
|
|
|
|
Secure = "STARTTLS",
|
|
|
|
|
Username = email
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<AutoDiscoverySettings> TryGetSettingsFromXmlEndpointAsync(
|
|
|
|
|
string endpoint,
|
|
|
|
|
string email,
|
|
|
|
|
string localPart,
|
|
|
|
|
string domain,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var response = await _httpClient.GetAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
return ParseThunderbirdSettings(content, email, localPart, domain);
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.Debug(ex, "Failed to read autodiscovery XML endpoint {Endpoint}", endpoint);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static AutoDiscoverySettings ParseThunderbirdSettings(string xmlContent, string email, string localPart, string domain)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(xmlContent))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var document = XDocument.Parse(xmlContent);
|
|
|
|
|
|
|
|
|
|
var incomingServers = document
|
|
|
|
|
.Descendants()
|
|
|
|
|
.Where(e => e.Name.LocalName == "incomingServer")
|
|
|
|
|
.Where(e => string.Equals((string)e.Attribute("type"), "imap", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
.Select(e => ParseThunderbirdServer(e, "IMAP", email, localPart, domain))
|
|
|
|
|
.Where(e => e != null)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
var outgoingServers = document
|
|
|
|
|
.Descendants()
|
|
|
|
|
.Where(e => e.Name.LocalName == "outgoingServer")
|
|
|
|
|
.Where(e => string.Equals((string)e.Attribute("type"), "smtp", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
.Select(e => ParseThunderbirdServer(e, "SMTP", email, localPart, domain))
|
|
|
|
|
.Where(e => e != null)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
var bestIncoming = SelectBestServerSetting(incomingServers);
|
|
|
|
|
var bestOutgoing = SelectBestServerSetting(outgoingServers);
|
|
|
|
|
|
|
|
|
|
if (bestIncoming == null || bestOutgoing == null)
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
return new AutoDiscoverySettings
|
|
|
|
|
{
|
|
|
|
|
Domain = domain,
|
|
|
|
|
Settings = [bestIncoming, bestOutgoing]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.Debug(ex, "Failed to parse Thunderbird autodiscovery XML.");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static AutoDiscoveryProviderSetting ParseThunderbirdServer(
|
|
|
|
|
XElement serverElement,
|
|
|
|
|
string protocol,
|
|
|
|
|
string email,
|
|
|
|
|
string localPart,
|
|
|
|
|
string domain)
|
|
|
|
|
{
|
|
|
|
|
var address = ResolveTemplate(GetElementValue(serverElement, "hostname"), email, localPart, domain);
|
|
|
|
|
var username = ResolveTemplate(GetElementValue(serverElement, "username"), email, localPart, domain);
|
|
|
|
|
var socketType = ResolveTemplate(GetElementValue(serverElement, "socketType"), email, localPart, domain);
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(address))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
if (!int.TryParse(GetElementValue(serverElement, "port"), out var port))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
return new AutoDiscoveryProviderSetting
|
|
|
|
|
{
|
|
|
|
|
Protocol = protocol,
|
|
|
|
|
Address = address.Trim(),
|
|
|
|
|
Port = port,
|
|
|
|
|
Secure = socketType?.Trim() ?? string.Empty,
|
|
|
|
|
Username = string.IsNullOrWhiteSpace(username) ? email : username.Trim()
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
private static AutoDiscoveryProviderSetting SelectBestServerSetting(IReadOnlyCollection<AutoDiscoveryProviderSetting> settings)
|
|
|
|
|
{
|
|
|
|
|
if (settings == null || settings.Count == 0)
|
2025-02-16 11:54:23 +01:00
|
|
|
return null;
|
2026-02-15 02:20:18 +01:00
|
|
|
|
|
|
|
|
return settings
|
|
|
|
|
.OrderByDescending(GetSecurityScore)
|
|
|
|
|
.ThenBy(s => s.Port)
|
|
|
|
|
.FirstOrDefault();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int GetSecurityScore(AutoDiscoveryProviderSetting setting)
|
|
|
|
|
{
|
|
|
|
|
if (setting == null)
|
|
|
|
|
return 0;
|
|
|
|
|
|
|
|
|
|
var secureValue = setting.Secure ?? string.Empty;
|
|
|
|
|
|
|
|
|
|
if (secureValue.Contains("SSL", StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
secureValue.Contains("TLS", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
return 3;
|
2024-04-18 01:44:37 +02:00
|
|
|
}
|
2026-02-15 02:20:18 +01:00
|
|
|
|
|
|
|
|
if (secureValue.Contains("STARTTLS", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
return 2;
|
|
|
|
|
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string GetElementValue(XElement element, string localName)
|
|
|
|
|
=> element.Elements().FirstOrDefault(e => e.Name.LocalName == localName)?.Value;
|
|
|
|
|
|
|
|
|
|
private static string ResolveTemplate(string value, string email, string localPart, string domain)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
|
|
|
return value;
|
|
|
|
|
|
|
|
|
|
return value
|
|
|
|
|
.Replace("%EMAILADDRESS%", email, StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
.Replace("%EMAILLOCALPART%", localPart, StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
.Replace("%EMAILDOMAIN%", domain, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static IEnumerable<string> BuildThunderbirdEndpoints(string domain, string email)
|
|
|
|
|
{
|
|
|
|
|
var escapedEmail = Uri.EscapeDataString(email);
|
|
|
|
|
yield return $"https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={escapedEmail}";
|
|
|
|
|
yield return $"https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={escapedEmail}";
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
private async Task<AutoDiscoverySettings> GetSettingsFromFiretrustAsync(string mailAddress, CancellationToken cancellationToken)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
|
|
|
|
try
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2026-02-15 02:20:18 +01:00
|
|
|
using var response = await _httpClient.GetAsync($"{FiretrustUrl}{Uri.EscapeDataString(mailAddress)}", cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
Logger.Warning("Firetrust autodiscovery failed with status {StatusCode}", response.StatusCode);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-02-16 11:35:43 +01:00
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
2025-02-16 11:54:23 +01:00
|
|
|
return JsonSerializer.Deserialize(content, DomainModelsJsonContext.Default.AutoDiscoverySettings);
|
2025-02-16 11:43:30 +01:00
|
|
|
}
|
2026-02-15 02:20:18 +01:00
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.Error(ex, "Failed to deserialize Firetrust autodiscovery response.");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<AutoDiscoveryProviderSetting> TryResolveSrvRecordAsync(
|
|
|
|
|
string queryName,
|
|
|
|
|
string protocol,
|
|
|
|
|
string secureHint,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var records = await QueryDnsAsync(queryName, "SRV", cancellationToken).ConfigureAwait(false);
|
|
|
|
|
var srvRecord = records
|
|
|
|
|
.Select(ParseSrvRecord)
|
|
|
|
|
.Where(r => r != null)
|
|
|
|
|
.OrderBy(r => r.Priority)
|
|
|
|
|
.ThenBy(r => r.Weight)
|
|
|
|
|
.FirstOrDefault();
|
|
|
|
|
|
|
|
|
|
if (srvRecord == null)
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
return new AutoDiscoveryProviderSetting
|
|
|
|
|
{
|
|
|
|
|
Protocol = protocol,
|
|
|
|
|
Address = srvRecord.Target,
|
|
|
|
|
Port = srvRecord.Port,
|
|
|
|
|
Secure = secureHint
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<IReadOnlyList<string>> GetMxSearchDomainsAsync(string domain, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var results = new List<string> { domain };
|
|
|
|
|
var records = await QueryDnsAsync(domain, "MX", cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
var hosts = records
|
|
|
|
|
.Select(ParseMxRecord)
|
|
|
|
|
.Where(r => r != null)
|
|
|
|
|
.OrderBy(r => r.Preference)
|
|
|
|
|
.Select(r => r.Target)
|
|
|
|
|
.Distinct(IgnoreCase)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
foreach (var host in hosts)
|
|
|
|
|
{
|
|
|
|
|
foreach (var candidateDomain in BuildDomainCandidatesFromHost(host))
|
|
|
|
|
{
|
|
|
|
|
if (!results.Contains(candidateDomain, IgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
results.Add(candidateDomain);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<string> GetFirstResolvableHostAsync(IEnumerable<string> hostCandidates, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
foreach (var host in hostCandidates.Where(h => !string.IsNullOrWhiteSpace(h)).Distinct(IgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
if (await HasAnyDnsAddressRecordAsync(host, cancellationToken).ConfigureAwait(false))
|
|
|
|
|
return host;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<bool> HasAnyDnsAddressRecordAsync(string host, CancellationToken cancellationToken)
|
|
|
|
|
{
|
2026-04-19 20:13:09 +02:00
|
|
|
if (MailAccountAddressValidator.IsImplicitlyResolvableHost(host))
|
|
|
|
|
return true;
|
|
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
var aRecords = await QueryDnsAsync(host, "A", cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (aRecords.Count > 0)
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
var aaaaRecords = await QueryDnsAsync(host, "AAAA", cancellationToken).ConfigureAwait(false);
|
|
|
|
|
return aaaaRecords.Count > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<IReadOnlyList<string>> QueryDnsAsync(string queryName, string queryType, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var url = $"{GoogleDnsResolveUrl}?name={Uri.EscapeDataString(queryName)}&type={Uri.EscapeDataString(queryType)}";
|
|
|
|
|
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return Array.Empty<string>();
|
|
|
|
|
|
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (!document.RootElement.TryGetProperty("Answer", out var answerArray) ||
|
|
|
|
|
answerArray.ValueKind != JsonValueKind.Array)
|
|
|
|
|
{
|
|
|
|
|
return Array.Empty<string>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var values = new List<string>();
|
|
|
|
|
|
|
|
|
|
foreach (var answer in answerArray.EnumerateArray())
|
|
|
|
|
{
|
|
|
|
|
if (answer.TryGetProperty("data", out var dataNode) && dataNode.ValueKind == JsonValueKind.String)
|
|
|
|
|
{
|
|
|
|
|
var data = dataNode.GetString();
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(data))
|
|
|
|
|
values.Add(data);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return values;
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.Debug(ex, "DNS-over-HTTPS query failed for {QueryName} ({Type})", queryName, queryType);
|
|
|
|
|
return Array.Empty<string>();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<Uri> TryResolveCalDavEndpointAsync(Uri candidate, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var getResult = await ProbeCalDavEndpointAsync(candidate, HttpMethod.Get, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (getResult != null)
|
|
|
|
|
return getResult;
|
|
|
|
|
|
|
|
|
|
return await ProbeCalDavEndpointAsync(candidate, OptionsMethod, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<Uri> ProbeCalDavEndpointAsync(Uri uri, HttpMethod method, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var request = new HttpRequestMessage(method, uri);
|
|
|
|
|
using var response = await _httpClient
|
|
|
|
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (TryResolveRedirectTarget(uri, response, out var redirectTarget))
|
|
|
|
|
return redirectTarget;
|
|
|
|
|
|
|
|
|
|
if (!IsPossibleCalDavEndpoint(response))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
return response.RequestMessage?.RequestUri ?? uri;
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
throw;
|
|
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2026-02-15 02:20:18 +01:00
|
|
|
Logger.Debug(ex, "CalDAV probe failed for {Uri} with method {Method}", uri, method);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool IsPossibleCalDavEndpoint(HttpResponseMessage response)
|
|
|
|
|
{
|
|
|
|
|
if (response == null)
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.MultiStatus)
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
var hasDavHeader = response.Headers.Contains("DAV");
|
|
|
|
|
var hasDavMethod = response.Headers.TryGetValues("Allow", out var allowValues)
|
|
|
|
|
&& allowValues.Any(value =>
|
|
|
|
|
value.Contains("PROPFIND", StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
value.Contains("REPORT", StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
|
|
|
|
|
if (response.StatusCode == HttpStatusCode.MethodNotAllowed)
|
|
|
|
|
return hasDavHeader || hasDavMethod;
|
|
|
|
|
|
|
|
|
|
return response.IsSuccessStatusCode && (hasDavHeader || hasDavMethod);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool TryResolveRedirectTarget(Uri baseUri, HttpResponseMessage response, out Uri resolvedUri)
|
|
|
|
|
{
|
|
|
|
|
resolvedUri = null;
|
|
|
|
|
|
|
|
|
|
if (response == null || !IsRedirectStatusCode(response.StatusCode))
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
if (response.Headers.Location == null)
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
resolvedUri = response.Headers.Location.IsAbsoluteUri
|
|
|
|
|
? response.Headers.Location
|
|
|
|
|
: new Uri(baseUri, response.Headers.Location);
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool IsRedirectStatusCode(HttpStatusCode statusCode)
|
|
|
|
|
=> statusCode == HttpStatusCode.MovedPermanently
|
|
|
|
|
|| statusCode == HttpStatusCode.Found
|
|
|
|
|
|| statusCode == HttpStatusCode.RedirectMethod
|
|
|
|
|
|| statusCode == HttpStatusCode.TemporaryRedirect
|
|
|
|
|
|| (int)statusCode == 308;
|
|
|
|
|
|
|
|
|
|
private static Uri TryGetKnownProviderCalDavUri(string domain)
|
|
|
|
|
{
|
|
|
|
|
if (domain.EndsWith("icloud.com", StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
domain.EndsWith("me.com", StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
domain.EndsWith("mac.com", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
return new Uri("https://caldav.icloud.com/");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (domain.Contains("yahoo.", StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
domain.EndsWith("aol.com", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
return new Uri("https://caldav.calendar.yahoo.com/");
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
2024-04-18 01:44:37 +02:00
|
|
|
}
|
2026-02-15 02:20:18 +01:00
|
|
|
|
|
|
|
|
private static IEnumerable<Uri> GetCalDavCandidates(string domain)
|
|
|
|
|
{
|
|
|
|
|
foreach (var candidateDomain in BuildDomainCandidatesFromHost(domain))
|
|
|
|
|
{
|
|
|
|
|
yield return new Uri($"https://{candidateDomain}/.well-known/caldav");
|
|
|
|
|
yield return new Uri($"https://caldav.{candidateDomain}/");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static IEnumerable<string> BuildDomainCandidatesFromHost(string hostOrDomain)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(hostOrDomain))
|
|
|
|
|
yield break;
|
|
|
|
|
|
|
|
|
|
var normalized = hostOrDomain.Trim().TrimEnd('.');
|
|
|
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
|
|
|
yield break;
|
|
|
|
|
|
|
|
|
|
yield return normalized;
|
|
|
|
|
|
|
|
|
|
var segments = normalized.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
if (segments.Length > 2)
|
|
|
|
|
{
|
|
|
|
|
yield return string.Join('.', segments.Skip(1));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool TryGetEmailParts(string email, out string localPart, out string domain)
|
|
|
|
|
{
|
|
|
|
|
localPart = null;
|
|
|
|
|
domain = null;
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(email))
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
var separatorIndex = email.IndexOf('@');
|
|
|
|
|
if (separatorIndex <= 0 || separatorIndex >= email.Length - 1)
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
localPart = email[..separatorIndex];
|
|
|
|
|
domain = email[(separatorIndex + 1)..];
|
|
|
|
|
return !string.IsNullOrWhiteSpace(localPart) && !string.IsNullOrWhiteSpace(domain);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void CacheCalDavUri(string domain, Uri calDavUri)
|
|
|
|
|
{
|
|
|
|
|
lock (_calDavCacheLock)
|
|
|
|
|
{
|
|
|
|
|
_calDavUriCache[domain] = calDavUri;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static SrvRecord ParseSrvRecord(string rawValue)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(rawValue))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
var parts = rawValue.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
if (parts.Length < 4)
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
if (!ushort.TryParse(parts[0], out var priority) ||
|
|
|
|
|
!ushort.TryParse(parts[1], out var weight) ||
|
|
|
|
|
!int.TryParse(parts[2], out var port))
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var target = parts[3].Trim().TrimEnd('.');
|
|
|
|
|
if (string.IsNullOrWhiteSpace(target))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
return new SrvRecord(priority, weight, port, target);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static MxRecord ParseMxRecord(string rawValue)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(rawValue))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
var parts = rawValue.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
if (parts.Length < 2 || !ushort.TryParse(parts[0], out var preference))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
var target = parts[1].Trim().TrimEnd('.');
|
|
|
|
|
if (string.IsNullOrWhiteSpace(target))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
return new MxRecord(preference, target);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed record SrvRecord(ushort Priority, ushort Weight, int Port, string Target);
|
|
|
|
|
private sealed record MxRecord(ushort Preference, string Target);
|
2024-04-18 01:44:37 +02:00
|
|
|
}
|