CalDav synchronizer, new IMAP setup/edit page.
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
using System;
|
||||
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;
|
||||
@@ -10,47 +15,682 @@ using Wino.Core.Domain.Models.AutoDiscovery;
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// We have 2 methods to do auto discovery.
|
||||
/// 1. Use https://emailsettings.firetrust.com/settings?q={address} API
|
||||
/// 2. TODO: Thunderbird auto discovery file.
|
||||
/// Mail and CalDAV endpoint discovery with Thunderbird-style methods and fallbacks.
|
||||
/// </summary>
|
||||
public class AutoDiscoveryService : IAutoDiscoveryService
|
||||
{
|
||||
private const string FiretrustURL = " https://emailsettings.firetrust.com/settings?q=";
|
||||
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";
|
||||
|
||||
// TODO: Try Thunderbird Auto Discovery as second approach.
|
||||
private static readonly ILogger Logger = Log.ForContext<AutoDiscoveryService>();
|
||||
private static readonly StringComparer IgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
private static readonly HttpMethod OptionsMethod = new("OPTIONS");
|
||||
|
||||
public Task<AutoDiscoverySettings> GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings)
|
||||
=> GetSettingsFromFiretrustAsync(autoDiscoveryMinimalSettings.Email);
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly Dictionary<string, Uri> _calDavUriCache = new(IgnoreCase);
|
||||
private readonly object _calDavCacheLock = new();
|
||||
|
||||
private static async Task<AutoDiscoverySettings> GetSettingsFromFiretrustAsync(string mailAddress)
|
||||
public AutoDiscoveryService(HttpClient httpClient = null)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var response = await client.GetAsync($"{FiretrustURL}{mailAddress}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await DeserializeFiretrustResponse(response);
|
||||
else
|
||||
_httpClient = httpClient ?? new HttpClient
|
||||
{
|
||||
Log.Warning($"Firetrust AutoDiscovery failed. ({response.StatusCode})");
|
||||
|
||||
return null;
|
||||
}
|
||||
Timeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<AutoDiscoverySettings> DeserializeFiretrustResponse(HttpResponseMessage response)
|
||||
public async Task<AutoDiscoverySettings> GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
if (autoDiscoveryMinimalSettings == null || string.IsNullOrWhiteSpace(autoDiscoveryMinimalSettings.Email))
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize(content, DomainModelsJsonContext.Default.AutoDiscoverySettings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
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))
|
||||
{
|
||||
Log.Error(ex, "Failed to deserialize Firetrust response.");
|
||||
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;
|
||||
|
||||
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<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()
|
||||
};
|
||||
}
|
||||
|
||||
private static AutoDiscoveryProviderSetting SelectBestServerSetting(IReadOnlyCollection<AutoDiscoveryProviderSetting> 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<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}";
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoverySettings> 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<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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
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<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);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
private readonly IImapChangeProcessor _imapChangeProcessor;
|
||||
private readonly IAuthenticationProvider _authenticationProvider;
|
||||
private readonly UnifiedImapSynchronizer _unifiedImapSynchronizer;
|
||||
private readonly ICalDavClient _calDavClient;
|
||||
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
||||
|
||||
private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
|
||||
|
||||
@@ -35,7 +37,9 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
IOutlookSynchronizerErrorHandlerFactory outlookSynchronizerErrorHandlerFactory,
|
||||
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory,
|
||||
IImapSynchronizerErrorHandlerFactory imapSynchronizerErrorHandlerFactory,
|
||||
UnifiedImapSynchronizer unifiedImapSynchronizer)
|
||||
UnifiedImapSynchronizer unifiedImapSynchronizer,
|
||||
ICalDavClient calDavClient,
|
||||
IAutoDiscoveryService autoDiscoveryService)
|
||||
{
|
||||
_outlookChangeProcessor = outlookChangeProcessor;
|
||||
_gmailChangeProcessor = gmailChangeProcessor;
|
||||
@@ -47,6 +51,8 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
_gmailSynchronizerErrorHandlerFactory = gmailSynchronizerErrorHandlerFactory;
|
||||
_imapSynchronizerErrorHandlerFactory = imapSynchronizerErrorHandlerFactory;
|
||||
_unifiedImapSynchronizer = unifiedImapSynchronizer;
|
||||
_calDavClient = calDavClient;
|
||||
_autoDiscoveryService = autoDiscoveryService;
|
||||
}
|
||||
|
||||
public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId)
|
||||
@@ -82,7 +88,7 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
|
||||
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
|
||||
case Domain.Enums.MailProviderType.IMAP4:
|
||||
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory);
|
||||
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory, _calDavClient, _autoDiscoveryService);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user