CalDav synchronizer, new IMAP setup/edit page.
This commit is contained in:
@@ -54,6 +54,7 @@ public class ImapClientPool : IDisposable
|
||||
private readonly CancellationTokenSource _maintenanceCts = new();
|
||||
private readonly SemaphoreSlim _initializeSemaphore = new(1, 1);
|
||||
private readonly object _idleClientLock = new();
|
||||
private readonly object _initialWarmupLock = new();
|
||||
private readonly ImapServerQuirkProfile _quirks;
|
||||
private readonly ImapImplementation _implementation;
|
||||
private readonly int _maxConnections;
|
||||
@@ -64,6 +65,7 @@ public class ImapClientPool : IDisposable
|
||||
private bool _disposedValue;
|
||||
private bool _initialized;
|
||||
private Task _maintenanceTask;
|
||||
private Task _initialWarmupTask = Task.CompletedTask;
|
||||
|
||||
public bool ThrowOnSSLHandshakeCallback { get; set; }
|
||||
public ImapClientPoolOptions ImapClientPoolOptions { get; }
|
||||
@@ -112,30 +114,20 @@ public class ImapClientPool : IDisposable
|
||||
|
||||
_logger.Information("Initializing IMAP client pool with {MinimumConnections} minimum active connections (max: {MaxConnections})", _targetMinimumConnections, _maxConnections);
|
||||
|
||||
for (int i = 0; i < _targetMinimumConnections; i++)
|
||||
// Fast-path startup: create one client eagerly so first RentAsync() is not blocked by full warm-up.
|
||||
var initialClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (initialClient == null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var client = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (client != null)
|
||||
{
|
||||
_clientStates[client] = ImapClientState.Available;
|
||||
await _availableClients.Writer.WriteAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
throw CreatePoolException("Failed to create initial IMAP connection for the pool.");
|
||||
}
|
||||
|
||||
if (CanCreateAdditionalConnection())
|
||||
{
|
||||
_dedicatedIdleClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (_dedicatedIdleClient != null)
|
||||
{
|
||||
_clientStates[_dedicatedIdleClient] = ImapClientState.Idle;
|
||||
}
|
||||
}
|
||||
_clientStates[initialClient] = ImapClientState.Available;
|
||||
await _availableClients.Writer.WriteAsync(initialClient, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_maintenanceTask = Task.Run(() => MaintenanceLoopAsync(_maintenanceCts.Token), _maintenanceCts.Token);
|
||||
|
||||
_initialized = true;
|
||||
|
||||
ScheduleInitialWarmup();
|
||||
_logger.Information("IMAP client pool initialized. Health: {Health}", Health.Summary);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -152,7 +144,21 @@ public class ImapClientPool : IDisposable
|
||||
/// <summary>
|
||||
/// Pre-warms the pool (legacy compatibility method).
|
||||
/// </summary>
|
||||
public Task PreWarmPoolAsync() => InitializeAsync(CancellationToken.None);
|
||||
public async Task PreWarmPoolAsync()
|
||||
{
|
||||
await InitializeAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
Task warmupTask;
|
||||
lock (_initialWarmupLock)
|
||||
{
|
||||
warmupTask = _initialWarmupTask;
|
||||
}
|
||||
|
||||
if (warmupTask != null)
|
||||
{
|
||||
await warmupTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rents a client from the pool with the default timeout.
|
||||
@@ -440,6 +446,63 @@ public class ImapClientPool : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleInitialWarmup()
|
||||
{
|
||||
lock (_initialWarmupLock)
|
||||
{
|
||||
if (_initialWarmupTask != null && !_initialWarmupTask.IsCompleted)
|
||||
return;
|
||||
|
||||
_initialWarmupTask = Task.Run(() => EnsureWarmBaselineAsync(_maintenanceCts.Token), _maintenanceCts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureWarmBaselineAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureMinimumConnectionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_idleClientLock)
|
||||
{
|
||||
if (_dedicatedIdleClient != null && _dedicatedIdleClient.IsConnected)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CanCreateAdditionalConnection())
|
||||
return;
|
||||
|
||||
var idleCandidate = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (idleCandidate == null)
|
||||
return;
|
||||
|
||||
bool assignedAsIdle = false;
|
||||
lock (_idleClientLock)
|
||||
{
|
||||
if (_dedicatedIdleClient == null || !_dedicatedIdleClient.IsConnected)
|
||||
{
|
||||
_dedicatedIdleClient = idleCandidate;
|
||||
_clientStates[idleCandidate] = ImapClientState.Idle;
|
||||
assignedAsIdle = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!assignedAsIdle)
|
||||
{
|
||||
_clientStates[idleCandidate] = ImapClientState.Available;
|
||||
_availableClients.Writer.TryWrite(idleCandidate);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Pool is shutting down.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Initial IMAP pool warm-up failed. Pool will continue with maintenance recovery.");
|
||||
}
|
||||
}
|
||||
|
||||
private Task CleanupFailedConnectionsAsync()
|
||||
{
|
||||
foreach (var kvp in _clientStates)
|
||||
|
||||
@@ -9,6 +9,7 @@ using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Services;
|
||||
|
||||
@@ -117,6 +118,11 @@ public interface IImapChangeProcessor : IDefaultChangeProcessor
|
||||
/// <param name="folderId">Folder ID.</param>
|
||||
/// <param name="count">Number of recent mails to return.</param>
|
||||
Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count);
|
||||
|
||||
Task ManageCalendarEventAsync(CalDavCalendarEvent calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount);
|
||||
Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent);
|
||||
Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId);
|
||||
Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId);
|
||||
}
|
||||
|
||||
public class DefaultChangeProcessor(IDatabaseService databaseService,
|
||||
@@ -196,10 +202,10 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
|
||||
public Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId)
|
||||
=> CalendarService.GetAccountCalendarsAsync(accountId);
|
||||
|
||||
public Task DeleteCalendarItemAsync(Guid calendarItemId)
|
||||
public virtual Task DeleteCalendarItemAsync(Guid calendarItemId)
|
||||
=> CalendarService.DeleteCalendarItemAsync(calendarItemId);
|
||||
|
||||
public Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
|
||||
public virtual Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
|
||||
=> CalendarService.DeleteCalendarItemAsync(calendarRemoteEventId, calendarId);
|
||||
|
||||
public Task<CalendarItem> GetCalendarItemAsync(Guid calendarId, string remoteEventId)
|
||||
|
||||
@@ -1,24 +1,158 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Services;
|
||||
|
||||
namespace Wino.Core.Integration.Processors;
|
||||
|
||||
public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor
|
||||
{
|
||||
private readonly ICalendarIcsFileService _calendarIcsFileService;
|
||||
|
||||
public ImapChangeProcessor(IDatabaseService databaseService,
|
||||
IFolderService folderService,
|
||||
IMailService mailService,
|
||||
IAccountService accountService,
|
||||
ICalendarService calendarService,
|
||||
IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService)
|
||||
IMimeFileService mimeFileService,
|
||||
ICalendarIcsFileService calendarIcsFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService)
|
||||
{
|
||||
_calendarIcsFileService = calendarIcsFileService;
|
||||
}
|
||||
|
||||
public Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId) => FolderService.GetKnownUidsForFolderAsync(folderId);
|
||||
|
||||
public Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
|
||||
=> MailService.GetRecentMailIdsForFolderAsync(folderId, count);
|
||||
|
||||
public async Task ManageCalendarEventAsync(CalDavCalendarEvent calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
|
||||
{
|
||||
if (calendarEvent == null || assignedCalendar == null)
|
||||
return;
|
||||
|
||||
var existingItem = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.RemoteEventId).ConfigureAwait(false);
|
||||
var isNewItem = existingItem == null;
|
||||
var savingItemId = existingItem?.Id ?? Guid.NewGuid();
|
||||
var savingItem = existingItem ?? new CalendarItem { Id = savingItemId };
|
||||
|
||||
var start = calendarEvent.Start.UtcDateTime;
|
||||
var end = calendarEvent.End.UtcDateTime;
|
||||
|
||||
if (end <= start)
|
||||
end = start.AddHours(1);
|
||||
|
||||
savingItem.RemoteEventId = calendarEvent.RemoteEventId;
|
||||
savingItem.CalendarId = assignedCalendar.Id;
|
||||
savingItem.StartDate = start;
|
||||
savingItem.DurationInSeconds = (end - start).TotalSeconds;
|
||||
savingItem.StartTimeZone = calendarEvent.StartTimeZone;
|
||||
savingItem.EndTimeZone = calendarEvent.EndTimeZone;
|
||||
savingItem.Title = calendarEvent.Title;
|
||||
savingItem.Description = calendarEvent.Description;
|
||||
savingItem.Location = calendarEvent.Location;
|
||||
savingItem.Recurrence = calendarEvent.Recurrence;
|
||||
savingItem.Status = calendarEvent.Status;
|
||||
savingItem.Visibility = calendarEvent.Visibility;
|
||||
savingItem.ShowAs = calendarEvent.ShowAs;
|
||||
savingItem.IsHidden = calendarEvent.IsHidden;
|
||||
savingItem.HtmlLink = string.Empty;
|
||||
savingItem.IsLocked = false;
|
||||
savingItem.OrganizerDisplayName = !string.IsNullOrWhiteSpace(calendarEvent.OrganizerDisplayName)
|
||||
? calendarEvent.OrganizerDisplayName
|
||||
: organizerAccount?.SenderName ?? string.Empty;
|
||||
savingItem.OrganizerEmail = !string.IsNullOrWhiteSpace(calendarEvent.OrganizerEmail)
|
||||
? calendarEvent.OrganizerEmail
|
||||
: organizerAccount?.Address ?? string.Empty;
|
||||
savingItem.AssignedCalendar = assignedCalendar;
|
||||
|
||||
if (savingItem.CreatedAt == default)
|
||||
savingItem.CreatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
savingItem.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(calendarEvent.SeriesMasterRemoteEventId))
|
||||
{
|
||||
var parentEvent = await CalendarService
|
||||
.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.SeriesMasterRemoteEventId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (parentEvent != null)
|
||||
{
|
||||
savingItem.RecurringCalendarItemId = parentEvent.Id;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
savingItem.RecurringCalendarItemId = null;
|
||||
}
|
||||
|
||||
var attendees = calendarEvent.Attendees?
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Email))
|
||||
.Select(a => new CalendarEventAttendee
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = savingItemId,
|
||||
Name = a.Name,
|
||||
Email = a.Email,
|
||||
AttendenceStatus = a.AttendenceStatus,
|
||||
IsOrganizer = a.IsOrganizer,
|
||||
IsOptionalAttendee = a.IsOptionalAttendee
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var reminders = calendarEvent.Reminders?
|
||||
.Where(r => r.DurationInSeconds > 0)
|
||||
.Select(r => new Reminder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = savingItemId,
|
||||
DurationInSeconds = r.DurationInSeconds,
|
||||
ReminderType = r.ReminderType
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (isNewItem)
|
||||
{
|
||||
await CalendarService.CreateNewCalendarItemAsync(savingItem, attendees).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await CalendarService.UpdateCalendarItemAsync(savingItem, attendees).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await CalendarService.SaveRemindersAsync(savingItemId, reminders).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent)
|
||||
=> _calendarIcsFileService.SaveCalendarItemIcsAsync(accountId, calendarId, calendarItemId, remoteEventId, remoteResourceHref, eTag, icsContent);
|
||||
|
||||
public Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId)
|
||||
=> _calendarIcsFileService.DeleteCalendarItemIcsAsync(accountId, calendarItemId);
|
||||
|
||||
public Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId)
|
||||
=> _calendarIcsFileService.DeleteCalendarIcsForCalendarAsync(accountId, calendarId);
|
||||
|
||||
public override async Task DeleteCalendarItemAsync(Guid calendarItemId)
|
||||
{
|
||||
var item = await CalendarService.GetCalendarItemAsync(calendarItemId).ConfigureAwait(false);
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
await _calendarIcsFileService.DeleteCalendarItemIcsAsync(item.AssignedCalendar?.AccountId ?? Guid.Empty, calendarItemId).ConfigureAwait(false);
|
||||
await base.DeleteCalendarItemAsync(calendarItemId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override async Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
|
||||
{
|
||||
var item = await CalendarService.GetCalendarItemAsync(calendarId, calendarRemoteEventId).ConfigureAwait(false);
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
await DeleteCalendarItemAsync(item.Id).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using MimeKit;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Connectivity;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
@@ -26,6 +28,7 @@ using Wino.Core.Requests.Bundles;
|
||||
using Wino.Core.Requests.Folder;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Core.Synchronizers.ImapSync;
|
||||
using Wino.Core.Misc;
|
||||
using Wino.Messaging.Server;
|
||||
using Wino.Messaging.UI;
|
||||
using Wino.Services.Extensions;
|
||||
@@ -58,18 +61,27 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||
private readonly UnifiedImapSynchronizer _unifiedSynchronizer;
|
||||
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
|
||||
private readonly ICalDavClient _calDavClient;
|
||||
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
||||
private readonly SemaphoreSlim _calDavDiscoveryLock = new(1, 1);
|
||||
private Uri _cachedCalDavServiceUri;
|
||||
private bool _isCalDavDiscoveryAttempted;
|
||||
|
||||
public ImapSynchronizer(MailAccount account,
|
||||
IImapChangeProcessor imapChangeProcessor,
|
||||
IApplicationConfiguration applicationConfiguration,
|
||||
UnifiedImapSynchronizer unifiedSynchronizer,
|
||||
IImapSynchronizerErrorHandlerFactory errorHandlerFactory) : base(account, WeakReferenceMessenger.Default)
|
||||
IImapSynchronizerErrorHandlerFactory errorHandlerFactory,
|
||||
ICalDavClient calDavClient,
|
||||
IAutoDiscoveryService autoDiscoveryService) : base(account, WeakReferenceMessenger.Default)
|
||||
{
|
||||
// Create client pool with account protocol log.
|
||||
_imapChangeProcessor = imapChangeProcessor;
|
||||
_applicationConfiguration = applicationConfiguration;
|
||||
_unifiedSynchronizer = unifiedSynchronizer;
|
||||
_errorHandlerFactory = errorHandlerFactory;
|
||||
_calDavClient = calDavClient;
|
||||
_autoDiscoveryService = autoDiscoveryService;
|
||||
|
||||
var protocolLogStream = CreateAccountProtocolLogFileStream();
|
||||
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
|
||||
@@ -978,8 +990,310 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
public bool ShouldUpdateFolder(IMailFolder remoteFolder, MailItemFolder localFolder)
|
||||
=> !localFolder.FolderName.Equals(remoteFolder.Name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
protected override async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Account.ProviderType != MailProviderType.IMAP4 || !Account.IsCalendarAccessGranted || Account.ServerInformation == null)
|
||||
return CalendarSynchronizationResult.Empty;
|
||||
|
||||
if (Account.ServerInformation.CalendarSupportMode is ImapCalendarSupportMode.Disabled or ImapCalendarSupportMode.LocalOnly)
|
||||
return CalendarSynchronizationResult.Empty;
|
||||
|
||||
var calDavServiceUri = await ResolveCalDavServiceUriAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (calDavServiceUri == null)
|
||||
{
|
||||
_logger.Information("Skipping calendar sync for {AccountName}: CalDAV endpoint is not configured.", Account.Name);
|
||||
return CalendarSynchronizationResult.Empty;
|
||||
}
|
||||
|
||||
var password = ResolveCalDavPassword();
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
_logger.Warning("Skipping calendar sync for {AccountName}: empty credentials.", Account.Name);
|
||||
return CalendarSynchronizationResult.Empty;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var calDavUsername = ResolveCalDavUsername();
|
||||
if (string.IsNullOrWhiteSpace(calDavUsername))
|
||||
{
|
||||
_logger.Warning("Skipping calendar sync for {AccountName}: account email address is empty for CalDAV credentials.", Account.Name);
|
||||
return CalendarSynchronizationResult.Empty;
|
||||
}
|
||||
|
||||
var activeConnection = new CalDavConnectionSettings
|
||||
{
|
||||
ServiceUri = calDavServiceUri,
|
||||
Username = calDavUsername,
|
||||
Password = password
|
||||
};
|
||||
|
||||
IReadOnlyList<CalDavCalendar> remoteCalendars;
|
||||
|
||||
try
|
||||
{
|
||||
remoteCalendars = await _calDavClient
|
||||
.DiscoverCalendarsAsync(activeConnection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
_logger.Warning("Skipping calendar sync for {AccountName}: CalDAV authentication failed for username {Username}.", Account.Name, calDavUsername);
|
||||
return CalendarSynchronizationResult.Empty;
|
||||
}
|
||||
|
||||
await SynchronizeCalendarMetadataAsync(remoteCalendars).ConfigureAwait(false);
|
||||
|
||||
var localCalendars = await _imapChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
|
||||
var remoteCalendarsById = remoteCalendars.ToDictionary(c => c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (options?.Type == CalendarSynchronizationType.SingleCalendar && options.SynchronizationCalendarIds?.Count > 0)
|
||||
{
|
||||
localCalendars = localCalendars
|
||||
.Where(c => options.SynchronizationCalendarIds.Contains(c.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
localCalendars = localCalendars
|
||||
.Where(c => c.IsSynchronizationEnabled)
|
||||
.ToList();
|
||||
|
||||
var periodStartUtc = DateTimeOffset.UtcNow.AddYears(-1);
|
||||
var periodEndUtc = DateTimeOffset.UtcNow.AddYears(2);
|
||||
|
||||
foreach (var localCalendar in localCalendars)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!remoteCalendarsById.TryGetValue(localCalendar.RemoteCalendarId, out var remoteCalendar))
|
||||
continue;
|
||||
|
||||
var remoteToken = !string.IsNullOrWhiteSpace(remoteCalendar.SyncToken)
|
||||
? remoteCalendar.SyncToken
|
||||
: remoteCalendar.CTag;
|
||||
|
||||
var isInitialSync = string.IsNullOrWhiteSpace(localCalendar.SynchronizationDeltaToken);
|
||||
var tokenChanged = !string.Equals(localCalendar.SynchronizationDeltaToken, remoteToken, StringComparison.Ordinal);
|
||||
var forceSync = options?.Type is CalendarSynchronizationType.ExecuteRequests or CalendarSynchronizationType.SingleCalendar;
|
||||
|
||||
if (!isInitialSync && !tokenChanged && !forceSync)
|
||||
continue;
|
||||
|
||||
var remoteEvents = await _calDavClient.GetCalendarEventsAsync(
|
||||
activeConnection,
|
||||
remoteCalendar,
|
||||
periodStartUtc,
|
||||
periodEndUtc,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var remoteEvent in remoteEvents)
|
||||
{
|
||||
await _imapChangeProcessor
|
||||
.ManageCalendarEventAsync(remoteEvent, localCalendar, Account)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remoteEvent.IcsContent))
|
||||
continue;
|
||||
|
||||
var localItem = await _imapChangeProcessor
|
||||
.GetCalendarItemAsync(localCalendar.Id, remoteEvent.RemoteEventId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (localItem == null)
|
||||
continue;
|
||||
|
||||
await _imapChangeProcessor
|
||||
.SaveCalendarItemIcsAsync(
|
||||
Account.Id,
|
||||
localCalendar.Id,
|
||||
localItem.Id,
|
||||
remoteEvent.RemoteEventId,
|
||||
remoteEvent.RemoteResourceHref,
|
||||
remoteEvent.ETag,
|
||||
remoteEvent.IcsContent)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
localCalendar.SynchronizationDeltaToken = remoteToken;
|
||||
await _imapChangeProcessor.UpdateAccountCalendarAsync(localCalendar).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return CalendarSynchronizationResult.Empty;
|
||||
}
|
||||
|
||||
private async Task<Uri> ResolveCalDavServiceUriAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var explicitCalDavUri = TryGetExplicitCalDavServiceUri();
|
||||
if (explicitCalDavUri != null)
|
||||
{
|
||||
_cachedCalDavServiceUri = explicitCalDavUri;
|
||||
_isCalDavDiscoveryAttempted = true;
|
||||
return _cachedCalDavServiceUri;
|
||||
}
|
||||
|
||||
if (_cachedCalDavServiceUri != null)
|
||||
return _cachedCalDavServiceUri;
|
||||
|
||||
if (_isCalDavDiscoveryAttempted)
|
||||
return null;
|
||||
|
||||
await _calDavDiscoveryLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (_cachedCalDavServiceUri != null)
|
||||
return _cachedCalDavServiceUri;
|
||||
|
||||
if (_isCalDavDiscoveryAttempted)
|
||||
return null;
|
||||
|
||||
_isCalDavDiscoveryAttempted = true;
|
||||
|
||||
var emailCandidates = new[]
|
||||
{
|
||||
Account.ServerInformation?.Address,
|
||||
Account.Address
|
||||
}
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value) && value.Contains('@'))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var email in emailCandidates)
|
||||
{
|
||||
var discoveredUri = await _autoDiscoveryService
|
||||
.DiscoverCalDavServiceUriAsync(email, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (discoveredUri == null)
|
||||
continue;
|
||||
|
||||
_cachedCalDavServiceUri = discoveredUri;
|
||||
return _cachedCalDavServiceUri;
|
||||
}
|
||||
|
||||
if (Account.SpecialImapProvider == SpecialImapProvider.iCloud)
|
||||
{
|
||||
_cachedCalDavServiceUri = new Uri("https://caldav.icloud.com/");
|
||||
return _cachedCalDavServiceUri;
|
||||
}
|
||||
|
||||
if (Account.SpecialImapProvider == SpecialImapProvider.Yahoo)
|
||||
{
|
||||
_cachedCalDavServiceUri = new Uri("https://caldav.calendar.yahoo.com/");
|
||||
return _cachedCalDavServiceUri;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_calDavDiscoveryLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveCalDavPassword()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.CalDavPassword))
|
||||
return Account.ServerInformation.CalDavPassword;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.IncomingServerPassword))
|
||||
return Account.ServerInformation.IncomingServerPassword;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.OutgoingServerPassword))
|
||||
return Account.ServerInformation.OutgoingServerPassword;
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private string ResolveCalDavUsername()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.CalDavUsername))
|
||||
return Account.ServerInformation.CalDavUsername.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.Address))
|
||||
return Account.ServerInformation.Address.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Account.Address))
|
||||
return Account.Address.Trim();
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private Uri TryGetExplicitCalDavServiceUri()
|
||||
{
|
||||
var configuredUrl = Account.ServerInformation?.CalDavServiceUrl;
|
||||
if (string.IsNullOrWhiteSpace(configuredUrl))
|
||||
return null;
|
||||
|
||||
if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var uri))
|
||||
{
|
||||
_logger.Warning("Configured CalDAV URL is invalid for account {AccountName}: {Url}", Account.Name, configuredUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private async Task SynchronizeCalendarMetadataAsync(IReadOnlyList<CalDavCalendar> remoteCalendars)
|
||||
{
|
||||
var localCalendars = await _imapChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
|
||||
var remoteCalendarsById = remoteCalendars
|
||||
.GroupBy(c => c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId;
|
||||
|
||||
foreach (var localCalendar in localCalendars.ToList())
|
||||
{
|
||||
if (remoteCalendarsById.ContainsKey(localCalendar.RemoteCalendarId))
|
||||
continue;
|
||||
|
||||
await _imapChangeProcessor
|
||||
.DeleteCalendarIcsForCalendarAsync(Account.Id, localCalendar.Id)
|
||||
.ConfigureAwait(false);
|
||||
await _imapChangeProcessor.DeleteAccountCalendarAsync(localCalendar).ConfigureAwait(false);
|
||||
localCalendars.Remove(localCalendar);
|
||||
}
|
||||
|
||||
foreach (var remoteCalendar in remoteCalendars)
|
||||
{
|
||||
var existingLocal = localCalendars.FirstOrDefault(c =>
|
||||
string.Equals(c.RemoteCalendarId, remoteCalendar.RemoteCalendarId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var isPrimary = string.Equals(remoteCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (existingLocal == null)
|
||||
{
|
||||
var newCalendar = new AccountCalendar
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = Account.Id,
|
||||
RemoteCalendarId = remoteCalendar.RemoteCalendarId,
|
||||
Name = remoteCalendar.Name,
|
||||
IsPrimary = isPrimary,
|
||||
IsSynchronizationEnabled = true,
|
||||
IsExtended = true,
|
||||
TextColorHex = "#000000",
|
||||
BackgroundColorHex = ColorHelpers.GenerateFlatColorHex(),
|
||||
TimeZone = "UTC",
|
||||
SynchronizationDeltaToken = string.Empty
|
||||
};
|
||||
|
||||
await _imapChangeProcessor.InsertAccountCalendarAsync(newCalendar).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|
||||
|| existingLocal.IsPrimary != isPrimary;
|
||||
|
||||
if (!shouldUpdate)
|
||||
continue;
|
||||
|
||||
existingLocal.Name = remoteCalendar.Name;
|
||||
existingLocal.IsPrimary = isPrimary;
|
||||
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task StartIdleClientAsync()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user