CalDav synchronizer, new IMAP setup/edit page.

This commit is contained in:
Burak Kaan Köse
2026-02-15 02:20:18 +01:00
parent 64b9bfc392
commit acf0f649e8
58 changed files with 3993 additions and 1732 deletions
+82 -19
View File
@@ -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);
}
}
+667 -27
View File
@@ -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);
}
+8 -2
View File
@@ -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;
}
+317 -3
View File
@@ -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()
{