using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using Serilog; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.AutoDiscovery; namespace Wino.Core.Services; /// /// Mail and CalDAV endpoint discovery with Thunderbird-style methods and fallbacks. /// public class AutoDiscoveryService : IAutoDiscoveryService { 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"; private static readonly ILogger Logger = Log.ForContext(); private static readonly StringComparer IgnoreCase = StringComparer.OrdinalIgnoreCase; private static readonly HttpMethod OptionsMethod = new("OPTIONS"); private readonly HttpClient _httpClient; private readonly Dictionary _calDavUriCache = new(IgnoreCase); private readonly object _calDavCacheLock = new(); public AutoDiscoveryService(HttpClient httpClient = null) { _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; } public async Task 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 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 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 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 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 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 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; return new AutoDiscoverySettings { 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 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() }; } private static AutoDiscoveryProviderSetting SelectBestServerSetting(IReadOnlyCollection settings) { if (settings == null || settings.Count == 0) return null; 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; } 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 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}"; } private async Task GetSettingsFromFiretrustAsync(string mailAddress, CancellationToken cancellationToken) { try { 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; } var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize(content, DomainModelsJsonContext.Default.AutoDiscoverySettings); } catch (OperationCanceledException) { throw; } catch (Exception ex) { Logger.Error(ex, "Failed to deserialize Firetrust autodiscovery response."); return null; } } private async Task 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> GetMxSearchDomainsAsync(string domain, CancellationToken cancellationToken) { var results = new List { 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 GetFirstResolvableHostAsync(IEnumerable 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 HasAnyDnsAddressRecordAsync(string host, CancellationToken cancellationToken) { 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> 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(); 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(); } var values = new List(); 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(); } } private async Task 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 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; } catch (Exception ex) { 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/"); } return null; } private static IEnumerable 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 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); }