2026-02-14 12:52:17 +01:00
|
|
|
using System;
|
2024-04-18 01:44:37 +02:00
|
|
|
using System.Collections.Generic;
|
2024-08-25 10:32:07 +02:00
|
|
|
using System.IO;
|
2024-04-18 01:44:37 +02:00
|
|
|
using System.Linq;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Tasks;
|
2024-08-24 17:22:47 +02:00
|
|
|
using CommunityToolkit.Mvvm.Messaging;
|
2026-02-18 20:43:10 +01:00
|
|
|
using Itenso.TimePeriod;
|
2024-04-18 01:44:37 +02:00
|
|
|
using MailKit;
|
|
|
|
|
using MailKit.Net.Imap;
|
2025-02-22 00:22:00 +01:00
|
|
|
using MailKit.Search;
|
2026-02-09 22:39:30 +01:00
|
|
|
using MimeKit;
|
2024-04-18 01:44:37 +02:00
|
|
|
using Serilog;
|
2026-04-11 12:57:51 +02:00
|
|
|
using Wino.Core.Domain;
|
2026-02-15 02:20:18 +01:00
|
|
|
using Wino.Core.Domain.Entities.Calendar;
|
2024-11-10 23:28:25 +01:00
|
|
|
using Wino.Core.Domain.Entities.Mail;
|
|
|
|
|
using Wino.Core.Domain.Entities.Shared;
|
2024-04-18 01:44:37 +02:00
|
|
|
using Wino.Core.Domain.Enums;
|
|
|
|
|
using Wino.Core.Domain.Exceptions;
|
2026-03-07 17:13:48 +01:00
|
|
|
using Wino.Core.Domain.Extensions;
|
2024-04-18 01:44:37 +02:00
|
|
|
using Wino.Core.Domain.Interfaces;
|
2026-02-15 02:20:18 +01:00
|
|
|
using Wino.Core.Domain.Models.Calendar;
|
2024-09-29 21:21:51 +02:00
|
|
|
using Wino.Core.Domain.Models.Connectivity;
|
2025-02-22 00:22:00 +01:00
|
|
|
using Wino.Core.Domain.Models.Folders;
|
2024-04-18 01:44:37 +02:00
|
|
|
using Wino.Core.Domain.Models.MailItem;
|
|
|
|
|
using Wino.Core.Domain.Models.Synchronization;
|
|
|
|
|
using Wino.Core.Extensions;
|
|
|
|
|
using Wino.Core.Integration;
|
|
|
|
|
using Wino.Core.Integration.Processors;
|
|
|
|
|
using Wino.Core.Requests.Bundles;
|
2026-02-15 11:27:30 +01:00
|
|
|
using Wino.Core.Requests.Calendar;
|
2024-11-26 20:03:10 +01:00
|
|
|
using Wino.Core.Requests.Folder;
|
|
|
|
|
using Wino.Core.Requests.Mail;
|
2026-02-06 01:18:12 +01:00
|
|
|
using Wino.Core.Synchronizers.ImapSync;
|
2026-02-15 02:20:18 +01:00
|
|
|
using Wino.Core.Misc;
|
2025-02-15 12:53:32 +01:00
|
|
|
using Wino.Messaging.Server;
|
2024-08-24 17:22:47 +02:00
|
|
|
using Wino.Messaging.UI;
|
2024-11-30 23:05:07 +01:00
|
|
|
using Wino.Services.Extensions;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
namespace Wino.Core.Synchronizers.Mail;
|
|
|
|
|
|
|
|
|
|
public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreationPackage, object>, IImapSynchronizer
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2025-11-14 14:42:05 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// N/A for IMAP as it doesn't support batch modifications natively.
|
|
|
|
|
/// </summary>
|
2025-02-16 11:54:23 +01:00
|
|
|
public override uint BatchModificationSize => 1000;
|
|
|
|
|
public override uint InitialMessageDownloadCountPerFolder => 500;
|
2025-02-15 12:53:32 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
#region Idle Implementation
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
private static readonly Random IdleReconnectJitter = new();
|
|
|
|
|
private readonly object _idleDebounceLock = new();
|
|
|
|
|
private CancellationTokenSource _idleLoopCancellationTokenSource;
|
|
|
|
|
private Task _idleLoopTask;
|
|
|
|
|
private int _lastIdleInboxCount = -1;
|
|
|
|
|
private DateTime _lastIdleSyncRequestUtc = DateTime.MinValue;
|
|
|
|
|
private readonly TimeSpan _idleSyncDebounceWindow = TimeSpan.FromSeconds(15);
|
2025-02-15 12:53:32 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
#endregion
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
private readonly ILogger _logger = Log.ForContext<ImapSynchronizer>();
|
|
|
|
|
private readonly ImapClientPool _clientPool;
|
|
|
|
|
private readonly IImapChangeProcessor _imapChangeProcessor;
|
|
|
|
|
private readonly IApplicationConfiguration _applicationConfiguration;
|
2026-02-06 01:18:12 +01:00
|
|
|
private readonly UnifiedImapSynchronizer _unifiedSynchronizer;
|
|
|
|
|
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
|
2026-02-15 02:20:18 +01:00
|
|
|
private readonly ICalDavClient _calDavClient;
|
|
|
|
|
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
2026-02-15 11:27:30 +01:00
|
|
|
private readonly ICalendarService _calendarService;
|
2026-02-15 02:20:18 +01:00
|
|
|
private readonly SemaphoreSlim _calDavDiscoveryLock = new(1, 1);
|
|
|
|
|
private Uri _cachedCalDavServiceUri;
|
|
|
|
|
private bool _isCalDavDiscoveryAttempted;
|
2026-02-15 11:27:30 +01:00
|
|
|
private readonly IImapCalendarOperationHandler _localCalendarOperationHandler;
|
|
|
|
|
private readonly IImapCalendarOperationHandler _calDavCalendarOperationHandler;
|
2026-03-01 16:23:28 +01:00
|
|
|
private bool _isFolderStructureChanged;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public ImapSynchronizer(MailAccount account,
|
|
|
|
|
IImapChangeProcessor imapChangeProcessor,
|
2026-02-06 01:18:12 +01:00
|
|
|
IApplicationConfiguration applicationConfiguration,
|
|
|
|
|
UnifiedImapSynchronizer unifiedSynchronizer,
|
2026-02-15 02:20:18 +01:00
|
|
|
IImapSynchronizerErrorHandlerFactory errorHandlerFactory,
|
|
|
|
|
ICalDavClient calDavClient,
|
2026-02-15 11:27:30 +01:00
|
|
|
IAutoDiscoveryService autoDiscoveryService,
|
|
|
|
|
ICalendarService calendarService) : base(account, WeakReferenceMessenger.Default)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
|
|
|
|
_imapChangeProcessor = imapChangeProcessor;
|
|
|
|
|
_applicationConfiguration = applicationConfiguration;
|
2026-02-06 01:18:12 +01:00
|
|
|
_unifiedSynchronizer = unifiedSynchronizer;
|
|
|
|
|
_errorHandlerFactory = errorHandlerFactory;
|
2026-02-15 02:20:18 +01:00
|
|
|
_calDavClient = calDavClient;
|
|
|
|
|
_autoDiscoveryService = autoDiscoveryService;
|
2026-02-15 11:27:30 +01:00
|
|
|
_calendarService = calendarService;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-04-05 13:18:50 +02:00
|
|
|
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation);
|
2024-09-29 21:21:51 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
_clientPool = new ImapClientPool(poolOptions);
|
2026-03-07 17:13:48 +01:00
|
|
|
_localCalendarOperationHandler = new LocalCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService, _applicationConfiguration.ApplicationDataFolderPath, "local");
|
2026-02-18 20:43:10 +01:00
|
|
|
_calDavCalendarOperationHandler = new CalDavCalendarOperationHandler(this, Account, _calendarService, _calDavClient);
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Returns UniqueId for the given mail copy id.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private UniqueId GetUniqueId(string mailCopyId) => new(MailkitClientExtensions.ResolveUid(mailCopyId));
|
2024-11-26 20:03:10 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
#region Mail Integrations
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Items are grouped before being passed to this method.
|
|
|
|
|
// Meaning that all items will come from and to the same folder.
|
|
|
|
|
// It's fine to assume that here.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> Move(BatchMoveRequest requests)
|
|
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
if (requests == null || requests.Count == 0)
|
|
|
|
|
return [];
|
|
|
|
|
|
|
|
|
|
return CreateSingleTaskBundle(async (client, _) =>
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
var sourceFolder = await client.GetFolderAsync(requests[0].FromFolder.RemoteFolderId).ConfigureAwait(false);
|
|
|
|
|
var destinationFolder = await client.GetFolderAsync(requests[0].ToFolder.RemoteFolderId).ConfigureAwait(false);
|
|
|
|
|
var uniqueIds = requests.Select(item => GetUniqueId(item.Item.Id)).ToList();
|
2025-02-16 11:54:23 +01:00
|
|
|
|
|
|
|
|
await sourceFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
|
2026-04-20 02:18:23 +02:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await sourceFolder.MoveToAsync(uniqueIds, destinationFolder).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
await sourceFolder.CloseAsync().ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}, requests[0], requests);
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> ChangeFlag(BatchChangeFlagRequest requests)
|
|
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
if (requests == null || requests.Count == 0)
|
|
|
|
|
return [];
|
|
|
|
|
|
|
|
|
|
return CreateSingleTaskBundle(async (client, _) =>
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
var folder = requests[0].Item.AssignedFolder;
|
|
|
|
|
var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId).ConfigureAwait(false);
|
|
|
|
|
var uniqueIds = requests.Select(item => GetUniqueId(item.Item.Id)).ToList();
|
|
|
|
|
var request = new StoreFlagsRequest(requests[0].IsFlagged ? StoreAction.Add : StoreAction.Remove, MessageFlags.Flagged)
|
|
|
|
|
{
|
|
|
|
|
Silent = true
|
|
|
|
|
};
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
|
2026-04-20 02:18:23 +02:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await remoteFolder.StoreAsync(uniqueIds, request).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
await remoteFolder.CloseAsync().ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}, requests[0], requests);
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> Delete(BatchDeleteRequest requests)
|
|
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
if (requests == null || requests.Count == 0)
|
|
|
|
|
return [];
|
|
|
|
|
|
|
|
|
|
return CreateSingleTaskBundle(async (client, _) =>
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
var folder = requests[0].Item.AssignedFolder;
|
2025-02-16 11:54:23 +01:00
|
|
|
var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId).ConfigureAwait(false);
|
2026-04-20 02:18:23 +02:00
|
|
|
var uniqueIds = requests.Select(request => GetUniqueId(request.Item.Id)).ToList();
|
|
|
|
|
var storeRequest = new StoreFlagsRequest(StoreAction.Add, MessageFlags.Deleted) { Silent = true };
|
2025-02-16 11:54:23 +01:00
|
|
|
|
|
|
|
|
await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
|
2026-04-20 02:18:23 +02:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await remoteFolder.StoreAsync(uniqueIds, storeRequest).ConfigureAwait(false);
|
|
|
|
|
await remoteFolder.ExpungeAsync(uniqueIds).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
await remoteFolder.CloseAsync().ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}, requests[0], requests);
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> MarkRead(BatchMarkReadRequest requests)
|
|
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
if (requests == null || requests.Count == 0)
|
|
|
|
|
return [];
|
|
|
|
|
|
|
|
|
|
return CreateSingleTaskBundle(async (client, _) =>
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
var folder = requests[0].Item.AssignedFolder;
|
|
|
|
|
var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId).ConfigureAwait(false);
|
|
|
|
|
var uniqueIds = requests.Select(request => GetUniqueId(request.Item.Id)).ToList();
|
|
|
|
|
var storeRequest = new StoreFlagsRequest(requests[0].IsRead ? StoreAction.Add : StoreAction.Remove, MessageFlags.Seen)
|
|
|
|
|
{
|
|
|
|
|
Silent = true
|
|
|
|
|
};
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
|
2026-04-20 02:18:23 +02:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await remoteFolder.StoreAsync(uniqueIds, storeRequest).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
await remoteFolder.CloseAsync().ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}, requests[0], requests);
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> CreateDraft(CreateDraftRequest request)
|
|
|
|
|
{
|
|
|
|
|
return CreateSingleTaskBundle(async (client, item) =>
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
var remoteDraftFolder = await client.GetFolderAsync(request.DraftPreperationRequest.CreatedLocalDraftCopy.AssignedFolder.RemoteFolderId).ConfigureAwait(false);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
await remoteDraftFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
|
|
|
|
|
await remoteDraftFolder.AppendAsync(request.DraftPreperationRequest.CreatedLocalDraftMimeMessage, MessageFlags.Draft).ConfigureAwait(false);
|
|
|
|
|
await remoteDraftFolder.CloseAsync().ConfigureAwait(false);
|
|
|
|
|
}, request, request);
|
|
|
|
|
}
|
2024-11-26 20:03:10 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> Archive(BatchArchiveRequest request)
|
|
|
|
|
{
|
|
|
|
|
var batchMoveRequest = new BatchMoveRequest(request.Select(item => new MoveRequest(item.Item, item.FromFolder, item.ToFolder)));
|
|
|
|
|
return Move(batchMoveRequest);
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2024-07-09 01:05:16 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> EmptyFolder(EmptyFolderRequest request)
|
|
|
|
|
=> Delete(new BatchDeleteRequest(request.MailsToDelete.Select(a => new DeleteRequest(a))));
|
2024-07-09 01:05:16 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request)
|
|
|
|
|
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true))));
|
2024-06-21 23:48:03 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> SendDraft(SendDraftRequest request)
|
|
|
|
|
{
|
|
|
|
|
return CreateSingleTaskBundle(async (client, item) =>
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
// Batch sending is not supported. It will always be a single request therefore no need for a loop here.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var singleRequest = request.Request;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
using var smtpClient = new MailKit.Net.Smtp.SmtpClient();
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (smtpClient.IsConnected && client.IsAuthenticated) return;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (!smtpClient.IsConnected)
|
|
|
|
|
await smtpClient.ConnectAsync(Account.ServerInformation.OutgoingServer, int.Parse(Account.ServerInformation.OutgoingServerPort), MailKit.Security.SecureSocketOptions.Auto);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (!smtpClient.IsAuthenticated)
|
|
|
|
|
await smtpClient.AuthenticateAsync(Account.ServerInformation.OutgoingServerUsername, Account.ServerInformation.OutgoingServerPassword);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-06 20:13:44 +01:00
|
|
|
// Remove local draft header before sending to prevent leaking to recipients.
|
|
|
|
|
singleRequest.Mime.Headers.Remove(Domain.Constants.WinoLocalDraftHeader);
|
|
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// TODO: Transfer progress implementation as popup in the UI.
|
|
|
|
|
await smtpClient.SendAsync(singleRequest.Mime, default);
|
|
|
|
|
await smtpClient.DisconnectAsync(true);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// SMTP sent the message, but we need to remove it from the Draft folder.
|
|
|
|
|
var draftFolder = singleRequest.MailItem.AssignedFolder;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var folder = await client.GetFolderAsync(draftFolder.RemoteFolderId);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
await folder.OpenAsync(FolderAccess.ReadWrite);
|
2025-02-26 23:13:05 +01:00
|
|
|
await folder.AddFlagsAsync(new UniqueId(MailkitClientExtensions.ResolveUid(singleRequest.MailItem.Id)), MessageFlags.Deleted, true);
|
2025-02-16 11:54:23 +01:00
|
|
|
await folder.ExpungeAsync();
|
|
|
|
|
await folder.CloseAsync();
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Check whether we need to create a copy of the message to Sent folder.
|
|
|
|
|
// This comes from the account preferences.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (singleRequest.AccountPreferences.ShouldAppendMessagesToSentFolder && singleRequest.SentFolder != null)
|
|
|
|
|
{
|
|
|
|
|
var sentFolder = await client.GetFolderAsync(singleRequest.SentFolder.RemoteFolderId);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
await sentFolder.OpenAsync(FolderAccess.ReadWrite);
|
|
|
|
|
await sentFolder.AppendAsync(singleRequest.Mime, MessageFlags.Seen);
|
|
|
|
|
await sentFolder.CloseAsync();
|
|
|
|
|
}
|
|
|
|
|
}, request, request);
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-10-03 15:46:38 +02:00
|
|
|
public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem,
|
2025-02-16 11:54:23 +01:00
|
|
|
ITransferProgress transferProgress = null,
|
|
|
|
|
CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var folder = mailItem.AssignedFolder;
|
|
|
|
|
var remoteFolderId = folder.RemoteFolderId;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var client = await _clientPool.GetClientAsync().ConfigureAwait(false);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-08 22:20:38 +01:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var remoteFolder = await client.GetFolderAsync(remoteFolderId, cancellationToken).ConfigureAwait(false);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-08 22:20:38 +01:00
|
|
|
var uniqueId = new UniqueId(MailkitClientExtensions.ResolveUid(mailItem.Id));
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-08 22:20:38 +01:00
|
|
|
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-08 22:20:38 +01:00
|
|
|
var message = await remoteFolder.GetMessageAsync(uniqueId, cancellationToken, transferProgress).ConfigureAwait(false);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-08 22:20:38 +01:00
|
|
|
await _imapChangeProcessor.SaveMimeFileAsync(mailItem.FileId, message, Account.Id).ConfigureAwait(false);
|
|
|
|
|
await remoteFolder.CloseAsync(false, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
catch (FolderNotFoundException ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Warning("IMAP folder {FolderId} not found during MIME download for {MailId}. Deleting locally.", remoteFolderId, mailItem.Id);
|
|
|
|
|
await _imapChangeProcessor.DeleteMailAsync(Account.Id, mailItem.Id).ConfigureAwait(false);
|
|
|
|
|
throw new SynchronizerEntityNotFoundException(ex.Message);
|
|
|
|
|
}
|
|
|
|
|
catch (ImapCommandException ex) when (ex.Response == ImapCommandResponse.No)
|
|
|
|
|
{
|
|
|
|
|
_logger.Warning("IMAP message {MailId} not found during MIME download (NO response). Deleting locally.", mailItem.Id);
|
|
|
|
|
await _imapChangeProcessor.DeleteMailAsync(Account.Id, mailItem.Id).ConfigureAwait(false);
|
|
|
|
|
throw new SynchronizerEntityNotFoundException(ex.Message);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
_clientPool.Release(client);
|
|
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-01-03 23:59:37 +01:00
|
|
|
public override Task DownloadCalendarAttachmentAsync(
|
|
|
|
|
Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem,
|
|
|
|
|
Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment,
|
|
|
|
|
string localFilePath,
|
|
|
|
|
CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
// IMAP protocol doesn't support calendar operations natively
|
|
|
|
|
// Calendar functionality would require CalDAV protocol
|
|
|
|
|
_logger.Warning("IMAP protocol does not support calendar attachments. CalDAV would be required.");
|
|
|
|
|
throw new NotSupportedException("IMAP does not support calendar attachments. Use Outlook or Gmail for calendar functionality.");
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> RenameFolder(RenameFolderRequest request)
|
|
|
|
|
{
|
|
|
|
|
return CreateSingleTaskBundle(async (client, item) =>
|
2024-07-09 01:05:16 +02:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
var folder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false);
|
|
|
|
|
await folder.RenameAsync(folder.ParentFolder, request.NewFolderName).ConfigureAwait(false);
|
|
|
|
|
}, request, request);
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-07 19:47:21 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> DeleteFolder(DeleteFolderRequest request)
|
|
|
|
|
{
|
|
|
|
|
return CreateSingleTaskBundle(async (client, item) =>
|
|
|
|
|
{
|
|
|
|
|
var folder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false);
|
|
|
|
|
await folder.DeleteAsync().ConfigureAwait(false);
|
|
|
|
|
}, request, request);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override List<IRequestBundle<ImapRequest>> CreateSubFolder(CreateSubFolderRequest request)
|
|
|
|
|
{
|
|
|
|
|
return CreateSingleTaskBundle(async (client, item) =>
|
|
|
|
|
{
|
|
|
|
|
var parentFolder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false);
|
|
|
|
|
await parentFolder.CreateAsync(request.NewFolderName, true).ConfigureAwait(false);
|
|
|
|
|
}, request, request);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:46:52 +02:00
|
|
|
public override List<IRequestBundle<ImapRequest>> CreateRootFolder(CreateRootFolderRequest request)
|
|
|
|
|
{
|
|
|
|
|
return CreateSingleTaskBundle(async (client, item) =>
|
|
|
|
|
{
|
|
|
|
|
var rootFolder = client.GetFolder(client.PersonalNamespaces[0]);
|
|
|
|
|
await rootFolder.CreateAsync(request.NewFolderName, true).ConfigureAwait(false);
|
|
|
|
|
}, request, request);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 11:27:30 +01:00
|
|
|
public override List<IRequestBundle<ImapRequest>> CreateCalendarEvent(CreateCalendarEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
var handler = ResolveCalendarOperationHandler();
|
|
|
|
|
return CreateCalendarOperationTaskBundle(
|
|
|
|
|
request,
|
|
|
|
|
async value => await handler.CreateCalendarEventAsync(value).ConfigureAwait(false),
|
|
|
|
|
handler.RequiresConnectedClient);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override List<IRequestBundle<ImapRequest>> UpdateCalendarEvent(UpdateCalendarEventRequest request)
|
2026-04-08 23:46:02 +02:00
|
|
|
{
|
|
|
|
|
var handler = ResolveCalendarOperationHandler();
|
|
|
|
|
return CreateCalendarOperationTaskBundle(
|
|
|
|
|
request,
|
|
|
|
|
async value => await handler.UpdateCalendarEventAsync(value).ConfigureAwait(false),
|
|
|
|
|
handler.RequiresConnectedClient);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override List<IRequestBundle<ImapRequest>> ChangeStartAndEndDate(ChangeStartAndEndDateRequest request)
|
2026-02-15 11:27:30 +01:00
|
|
|
{
|
|
|
|
|
var handler = ResolveCalendarOperationHandler();
|
|
|
|
|
return CreateCalendarOperationTaskBundle(
|
|
|
|
|
request,
|
|
|
|
|
async value => await handler.UpdateCalendarEventAsync(value).ConfigureAwait(false),
|
|
|
|
|
handler.RequiresConnectedClient);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override List<IRequestBundle<ImapRequest>> DeleteCalendarEvent(DeleteCalendarEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
var handler = ResolveCalendarOperationHandler();
|
|
|
|
|
return CreateCalendarOperationTaskBundle(
|
|
|
|
|
request,
|
|
|
|
|
async value => await handler.DeleteCalendarEventAsync(value).ConfigureAwait(false),
|
|
|
|
|
handler.RequiresConnectedClient);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override List<IRequestBundle<ImapRequest>> AcceptEvent(AcceptEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
var handler = ResolveCalendarOperationHandler();
|
|
|
|
|
return CreateCalendarOperationTaskBundle(
|
|
|
|
|
request,
|
|
|
|
|
async value => await handler.AcceptEventAsync(value).ConfigureAwait(false),
|
|
|
|
|
handler.RequiresConnectedClient);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override List<IRequestBundle<ImapRequest>> DeclineEvent(DeclineEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
var handler = ResolveCalendarOperationHandler();
|
|
|
|
|
return CreateCalendarOperationTaskBundle(
|
|
|
|
|
request,
|
|
|
|
|
async value => await handler.DeclineEventAsync(value).ConfigureAwait(false),
|
|
|
|
|
handler.RequiresConnectedClient);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override List<IRequestBundle<ImapRequest>> TentativeEvent(TentativeEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
var handler = ResolveCalendarOperationHandler();
|
|
|
|
|
return CreateCalendarOperationTaskBundle(
|
|
|
|
|
request,
|
|
|
|
|
async value => await handler.TentativeEventAsync(value).ConfigureAwait(false),
|
|
|
|
|
handler.RequiresConnectedClient);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private IImapCalendarOperationHandler ResolveCalendarOperationHandler()
|
|
|
|
|
{
|
|
|
|
|
var mode = Account.ServerInformation?.CalendarSupportMode ?? ImapCalendarSupportMode.Disabled;
|
|
|
|
|
|
|
|
|
|
return mode switch
|
|
|
|
|
{
|
|
|
|
|
ImapCalendarSupportMode.LocalOnly => _localCalendarOperationHandler,
|
|
|
|
|
ImapCalendarSupportMode.CalDav => _calDavCalendarOperationHandler,
|
|
|
|
|
_ => throw new NotSupportedException("Calendar operations are disabled for this IMAP account.")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private List<IRequestBundle<ImapRequest>> CreateCalendarOperationTaskBundle<TRequest>(
|
|
|
|
|
TRequest request,
|
|
|
|
|
Func<TRequest, Task> operation,
|
|
|
|
|
bool requiresConnectedClient)
|
|
|
|
|
where TRequest : IRequestBase, IUIChangeRequest
|
|
|
|
|
{
|
|
|
|
|
return
|
|
|
|
|
[
|
|
|
|
|
new ImapRequestBundle(
|
|
|
|
|
new ImapRequest<TRequest>((client, value) => operation(value), request, requiresConnectedClient),
|
|
|
|
|
request,
|
|
|
|
|
request)
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
#endregion
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var mailCopy = message.MessageSummary.GetMailDetails(assignedFolder, message.MimeMessage);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Draft folder message updates must be updated as IsDraft.
|
|
|
|
|
// I couldn't find it in MimeMesssage...
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
mailCopy.IsDraft = assignedFolder.SpecialFolderType == SpecialFolderType.Draft;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Check draft mapping.
|
|
|
|
|
// This is the same implementation as in the OutlookSynchronizer.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
string draftHeaderValue = null;
|
|
|
|
|
|
|
|
|
|
if (message.MimeMessage?.Headers?.Contains(Domain.Constants.WinoLocalDraftHeader) == true)
|
|
|
|
|
{
|
|
|
|
|
draftHeaderValue = message.MimeMessage.Headers[Domain.Constants.WinoLocalDraftHeader];
|
|
|
|
|
}
|
|
|
|
|
else if (message.MessageSummary?.Headers?.Contains(Domain.Constants.WinoLocalDraftHeader) == true)
|
|
|
|
|
{
|
|
|
|
|
draftHeaderValue = message.MessageSummary.Headers[Domain.Constants.WinoLocalDraftHeader];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Guid.TryParse(draftHeaderValue, out Guid localDraftCopyUniqueId))
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
|
|
|
|
// This message belongs to existing local draft copy.
|
|
|
|
|
// We don't need to create a new mail copy for this message, just update the existing one.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
bool isMappingSuccessful = await _imapChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, draftHeaderValue, mailCopy.ThreadId);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (isMappingSuccessful) return null;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Local copy doesn't exists. Continue execution to insert mail copy.
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
var contacts = message.MimeMessage != null
|
|
|
|
|
? ExtractContactsFromMimeMessage(message.MimeMessage)
|
|
|
|
|
: ExtractContactsFromMessageSummary(message.MessageSummary);
|
|
|
|
|
|
2026-02-09 22:39:30 +01:00
|
|
|
var package = new NewMailItemPackage(mailCopy, message.MimeMessage, assignedFolder.RemoteFolderId, contacts);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
return
|
|
|
|
|
[
|
|
|
|
|
package
|
|
|
|
|
];
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-09 22:39:30 +01:00
|
|
|
private static IReadOnlyList<AccountContact> ExtractContactsFromMimeMessage(MimeMessage mimeMessage)
|
|
|
|
|
{
|
|
|
|
|
if (mimeMessage == null) return [];
|
|
|
|
|
|
|
|
|
|
var contacts = new Dictionary<string, AccountContact>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
|
|
|
|
AddFromInternetAddressList(mimeMessage.From);
|
|
|
|
|
AddFromInternetAddressList(mimeMessage.To);
|
|
|
|
|
AddFromInternetAddressList(mimeMessage.Cc);
|
|
|
|
|
AddFromInternetAddressList(mimeMessage.Bcc);
|
|
|
|
|
AddFromInternetAddressList(mimeMessage.ReplyTo);
|
|
|
|
|
|
|
|
|
|
if (mimeMessage.Sender is MailboxAddress senderMailbox)
|
|
|
|
|
{
|
|
|
|
|
AddContact(senderMailbox.Address, senderMailbox.Name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return contacts.Values.ToList();
|
|
|
|
|
|
|
|
|
|
void AddFromInternetAddressList(InternetAddressList addresses)
|
|
|
|
|
{
|
|
|
|
|
if (addresses == null) return;
|
|
|
|
|
|
|
|
|
|
foreach (var mailbox in addresses.Mailboxes)
|
|
|
|
|
{
|
|
|
|
|
AddContact(mailbox.Address, mailbox.Name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void AddContact(string address, string name)
|
|
|
|
|
{
|
|
|
|
|
var trimmedAddress = address?.Trim();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(trimmedAddress)) return;
|
|
|
|
|
|
|
|
|
|
var displayName = string.IsNullOrWhiteSpace(name) ? trimmedAddress : name.Trim();
|
|
|
|
|
|
|
|
|
|
contacts[trimmedAddress] = new AccountContact
|
|
|
|
|
{
|
|
|
|
|
Address = trimmedAddress,
|
|
|
|
|
Name = displayName
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
private static IReadOnlyList<AccountContact> ExtractContactsFromMessageSummary(IMessageSummary summary)
|
|
|
|
|
{
|
|
|
|
|
if (summary?.Envelope == null) return [];
|
|
|
|
|
|
|
|
|
|
var contacts = new Dictionary<string, AccountContact>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
|
|
|
|
AddFromInternetAddressList(summary.Envelope.From);
|
|
|
|
|
AddFromInternetAddressList(summary.Envelope.To);
|
|
|
|
|
AddFromInternetAddressList(summary.Envelope.Cc);
|
|
|
|
|
AddFromInternetAddressList(summary.Envelope.Bcc);
|
|
|
|
|
AddFromInternetAddressList(summary.Envelope.ReplyTo);
|
|
|
|
|
|
|
|
|
|
var senderMailbox = summary.Envelope.Sender?.Mailboxes?.FirstOrDefault();
|
|
|
|
|
if (senderMailbox != null)
|
|
|
|
|
{
|
|
|
|
|
AddContact(senderMailbox.Address, senderMailbox.Name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return contacts.Values.ToList();
|
|
|
|
|
|
|
|
|
|
void AddFromInternetAddressList(InternetAddressList addresses)
|
|
|
|
|
{
|
|
|
|
|
if (addresses == null) return;
|
|
|
|
|
|
|
|
|
|
foreach (var mailbox in addresses.Mailboxes)
|
|
|
|
|
{
|
|
|
|
|
AddContact(mailbox.Address, mailbox.Name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void AddContact(string address, string name)
|
|
|
|
|
{
|
|
|
|
|
var trimmedAddress = address?.Trim();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(trimmedAddress)) return;
|
|
|
|
|
|
|
|
|
|
var displayName = string.IsNullOrWhiteSpace(name) ? trimmedAddress : name.Trim();
|
|
|
|
|
|
|
|
|
|
contacts[trimmedAddress] = new AccountContact
|
|
|
|
|
{
|
|
|
|
|
Address = trimmedAddress,
|
|
|
|
|
Name = displayName
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var downloadedMessageIds = new List<string>();
|
2026-02-06 01:18:12 +01:00
|
|
|
var folderResults = new List<FolderSyncResult>();
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
_logger.Information("Internal synchronization started for {Name}", Account.Name);
|
|
|
|
|
_logger.Information("Options: {Options}", options);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-06 01:18:12 +01:00
|
|
|
try
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-03-01 16:23:28 +01:00
|
|
|
_isFolderStructureChanged = false;
|
|
|
|
|
|
2026-02-06 01:18:12 +01:00
|
|
|
// Set indeterminate progress initially
|
|
|
|
|
UpdateSyncProgress(0, 0, "Synchronizing...");
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2026-02-06 01:18:12 +01:00
|
|
|
bool shouldDoFolderSync = options.Type == MailSynchronizationType.FullFolders || options.Type == MailSynchronizationType.FoldersOnly;
|
2025-10-31 00:51:27 +01:00
|
|
|
|
2026-02-06 01:18:12 +01:00
|
|
|
if (shouldDoFolderSync)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-06 01:18:12 +01:00
|
|
|
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
|
2026-03-01 16:23:28 +01:00
|
|
|
|
|
|
|
|
if (_isFolderStructureChanged)
|
|
|
|
|
{
|
|
|
|
|
WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id));
|
|
|
|
|
}
|
2026-02-06 01:18:12 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-06 01:18:12 +01:00
|
|
|
if (options.Type != MailSynchronizationType.FoldersOnly)
|
|
|
|
|
{
|
|
|
|
|
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
2025-02-15 12:53:32 +01:00
|
|
|
|
2026-02-06 01:18:12 +01:00
|
|
|
var totalFolders = synchronizationFolders.Count;
|
2026-02-14 12:52:17 +01:00
|
|
|
const int maxParallelFolderSyncClients = 3;
|
|
|
|
|
var folderSyncSemaphore = new SemaphoreSlim(maxParallelFolderSyncClients, maxParallelFolderSyncClients);
|
|
|
|
|
using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
|
|
|
var linkedToken = linkedCancellationTokenSource.Token;
|
|
|
|
|
var resultLock = new object();
|
|
|
|
|
int completedFolders = 0;
|
|
|
|
|
|
|
|
|
|
var syncTasks = synchronizationFolders.Select(async folder =>
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
await folderSyncSemaphore.WaitAsync(linkedToken).ConfigureAwait(false);
|
2026-02-06 01:18:12 +01:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
IImapClient client = null;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
client = await _clientPool.GetClientAsync(linkedToken).ConfigureAwait(false);
|
|
|
|
|
var folderResult = await _unifiedSynchronizer
|
|
|
|
|
.SynchronizeFolderAsync(client, folder, this, Account.ServerInformation?.IncomingServer, linkedToken)
|
|
|
|
|
.ConfigureAwait(false);
|
2026-02-06 01:18:12 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
List<string> folderDownloadedIds = null;
|
2026-02-06 01:18:12 +01:00
|
|
|
if (folderResult.Success && folderResult.DownloadedCount > 0)
|
|
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
folderDownloadedIds = await GetDownloadedIdsForFolderAsync(folder, folderResult.DownloadedCount).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lock (resultLock)
|
|
|
|
|
{
|
|
|
|
|
folderResults.Add(folderResult);
|
|
|
|
|
if (folderDownloadedIds != null && folderDownloadedIds.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
downloadedMessageIds.AddRange(folderDownloadedIds);
|
|
|
|
|
}
|
2026-02-06 01:18:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (client != null)
|
|
|
|
|
{
|
|
|
|
|
_clientPool.Release(client);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
var errorContext = new SynchronizerErrorContext
|
|
|
|
|
{
|
|
|
|
|
Account = Account,
|
|
|
|
|
ErrorMessage = ex.Message,
|
|
|
|
|
Exception = ex,
|
|
|
|
|
FolderId = folder.Id,
|
|
|
|
|
FolderName = folder.FolderName,
|
|
|
|
|
OperationType = "ImapFolderSync"
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
_ = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
|
|
|
|
var failedResult = FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext);
|
2026-02-06 01:18:12 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
lock (resultLock)
|
2026-02-06 01:18:12 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
folderResults.Add(failedResult);
|
2026-02-06 01:18:12 +01:00
|
|
|
}
|
2026-02-14 12:52:17 +01:00
|
|
|
|
|
|
|
|
if (!errorContext.CanContinueSync)
|
2026-02-06 01:18:12 +01:00
|
|
|
{
|
|
|
|
|
_logger.Error(ex, "Folder {FolderName} sync failed with fatal error", folder.FolderName);
|
2026-02-14 12:52:17 +01:00
|
|
|
linkedCancellationTokenSource.Cancel();
|
2026-02-06 01:18:12 +01:00
|
|
|
throw;
|
|
|
|
|
}
|
2026-02-14 12:52:17 +01:00
|
|
|
|
|
|
|
|
_logger.Warning(ex, "Folder {FolderName} sync failed, continuing with other folders", folder.FolderName);
|
2026-02-06 01:18:12 +01:00
|
|
|
}
|
2026-02-14 12:52:17 +01:00
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
folderSyncSemaphore.Release();
|
2026-02-06 01:18:12 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
var completed = Interlocked.Increment(ref completedFolders);
|
|
|
|
|
UpdateSyncProgress(totalFolders, totalFolders - completed, $"Syncing {folder.FolderName}...");
|
|
|
|
|
}
|
|
|
|
|
}).ToList();
|
|
|
|
|
|
|
|
|
|
await Task.WhenAll(syncTasks).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (cancellationToken.IsCancellationRequested) return MailSynchronizationResult.Canceled;
|
2024-04-18 01:44:37 +02:00
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2026-02-06 01:18:12 +01:00
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
|
|
|
|
|
return MailSynchronizationResult.Canceled;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
|
|
|
|
|
return MailSynchronizationResult.Failed(ex);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
// Reset progress
|
|
|
|
|
ResetSyncProgress();
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Get all unread new downloaded items and return in the result.
|
|
|
|
|
// This is primarily used in notifications.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var unreadNewItems = await _imapChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-06 01:18:12 +01:00
|
|
|
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the most recent downloaded message IDs for a folder.
|
|
|
|
|
/// Used for notification purposes after sync completes.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task<List<string>> GetDownloadedIdsForFolderAsync(MailItemFolder folder, int count)
|
|
|
|
|
{
|
|
|
|
|
// Get the most recent mail IDs from the folder
|
|
|
|
|
var recentMails = await _imapChangeProcessor.GetRecentMailIdsForFolderAsync(folder.Id, count).ConfigureAwait(false);
|
|
|
|
|
return recentMails?.ToList() ?? new List<string>();
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override async Task ExecuteNativeRequestsAsync(List<IRequestBundle<ImapRequest>> batchedRequests, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
// First apply the UI changes for each bundle.
|
|
|
|
|
// This is important to reflect changes to the UI before the network call is done.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-04-20 02:18:23 +02:00
|
|
|
ApplyOptimisticUiChanges(batchedRequests, ShouldApplyOptimisticUIChanges);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// All task bundles will execute on the same client.
|
|
|
|
|
// Tasks themselves don't pull the client from the pool
|
|
|
|
|
// because exception handling is easier this way.
|
|
|
|
|
// Also we might parallelize these bundles later on for additional performance.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
foreach (var item in batchedRequests)
|
|
|
|
|
{
|
|
|
|
|
// At this point this client is ready to execute async commands.
|
|
|
|
|
// Each task bundle will await and execution will continue in case of error.
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
IImapClient executorClient = null;
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
bool isCrashed = false;
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
try
|
|
|
|
|
{
|
2026-02-15 11:27:30 +01:00
|
|
|
if (item.NativeRequest.RequiresConnectedClient)
|
|
|
|
|
{
|
|
|
|
|
executorClient = await _clientPool.GetClientAsync();
|
|
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
|
|
|
|
catch (ImapClientPoolException)
|
|
|
|
|
{
|
|
|
|
|
// Client pool failed to get a client.
|
|
|
|
|
// Requests may not be executed at this point.
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2026-02-15 19:44:07 +01:00
|
|
|
if (ShouldApplyOptimisticUIChanges(item.Request))
|
|
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
item.UIChangeRequest?.RevertUIChanges();
|
2026-02-15 19:44:07 +01:00
|
|
|
}
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
isCrashed = true;
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
// Make sure that the client is released from the pool for next usages if error occurs.
|
|
|
|
|
if (isCrashed && executorClient != null)
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
|
|
|
|
_clientPool.Release(executorClient);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
try
|
2024-06-21 04:27:17 +02:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
await item.NativeRequest.IntegratorTask(executorClient, item.Request).ConfigureAwait(false);
|
2024-06-21 04:27:17 +02:00
|
|
|
}
|
2026-02-08 22:20:38 +01:00
|
|
|
catch (Exception ex)
|
2025-02-16 11:35:43 +01:00
|
|
|
{
|
2026-02-08 22:20:38 +01:00
|
|
|
var errorContext = new SynchronizerErrorContext
|
|
|
|
|
{
|
|
|
|
|
Account = Account,
|
|
|
|
|
ErrorCode = ex is FolderNotFoundException ? 404 : null,
|
|
|
|
|
ErrorMessage = ex.Message,
|
|
|
|
|
Exception = ex,
|
|
|
|
|
RequestBundle = item,
|
2026-04-07 16:48:46 +02:00
|
|
|
Request = item.Request,
|
2026-04-07 13:23:07 +02:00
|
|
|
OperationType = "RequestExecution",
|
|
|
|
|
IsEntityNotFound = ex is FolderNotFoundException || ex is SynchronizerEntityNotFoundException
|
2026-02-08 22:20:38 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var handled = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (!handled)
|
|
|
|
|
{
|
2026-04-07 16:48:46 +02:00
|
|
|
CaptureSynchronizationIssue(errorContext);
|
|
|
|
|
|
2026-02-15 19:44:07 +01:00
|
|
|
if (ShouldApplyOptimisticUIChanges(item.Request))
|
|
|
|
|
{
|
2026-04-20 02:18:23 +02:00
|
|
|
item.UIChangeRequest?.RevertUIChanges();
|
2026-02-15 19:44:07 +01:00
|
|
|
}
|
2026-02-08 22:20:38 +01:00
|
|
|
throw;
|
|
|
|
|
}
|
2025-02-16 11:35:43 +01:00
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
finally
|
|
|
|
|
{
|
2026-02-15 11:27:30 +01:00
|
|
|
if (executorClient != null)
|
|
|
|
|
{
|
|
|
|
|
_clientPool.Release(executorClient);
|
|
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 19:44:07 +01:00
|
|
|
private bool ShouldApplyOptimisticUIChanges(IRequestBase request)
|
|
|
|
|
{
|
|
|
|
|
// Mail changes are always applied.
|
|
|
|
|
// Calendar changes are applied only if calendar is not in local mode.
|
|
|
|
|
// Database updates are immidiate and will be reflected in the UI right after the request is processed, so no need for optimistic changes.
|
|
|
|
|
|
|
|
|
|
if (request is not ICalendarActionRequest)
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var mode = Account.ServerInformation?.CalendarSupportMode ?? ImapCalendarSupportMode.Disabled;
|
|
|
|
|
return mode != ImapCalendarSupportMode.LocalOnly;
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Assigns special folder type for the given local folder.
|
|
|
|
|
/// If server doesn't support special folders, we can't determine the type. MailKit will throw for GetFolder.
|
|
|
|
|
/// Default type is Other.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="executorClient">ImapClient from the pool</param>
|
|
|
|
|
/// <param name="remoteFolder">Assigning remote folder.</param>
|
|
|
|
|
/// <param name="localFolder">Assigning local folder.</param>
|
|
|
|
|
private void AssignSpecialFolderType(IImapClient executorClient, IMailFolder remoteFolder, MailItemFolder localFolder)
|
|
|
|
|
{
|
|
|
|
|
// Inbox is awlawys available. Don't miss it for assignment even though XList or SpecialUser is not supported.
|
|
|
|
|
if (executorClient.Inbox == remoteFolder)
|
|
|
|
|
{
|
|
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Inbox;
|
|
|
|
|
return;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
bool isSpecialFoldersSupported = executorClient.Capabilities.HasFlag(ImapCapabilities.SpecialUse) || executorClient.Capabilities.HasFlag(ImapCapabilities.XList);
|
|
|
|
|
|
|
|
|
|
if (!isSpecialFoldersSupported)
|
2024-06-21 04:27:17 +02:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Other;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (remoteFolder == executorClient.Inbox)
|
|
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Inbox;
|
|
|
|
|
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Drafts))
|
|
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Draft;
|
|
|
|
|
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Junk))
|
|
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Junk;
|
|
|
|
|
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Trash))
|
|
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Deleted;
|
|
|
|
|
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Sent))
|
|
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Sent;
|
|
|
|
|
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Archive))
|
|
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Archive;
|
|
|
|
|
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Important))
|
|
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Important;
|
|
|
|
|
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Flagged))
|
|
|
|
|
localFolder.SpecialFolderType = SpecialFolderType.Starred;
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
private async Task SynchronizeFoldersAsync(CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
// https://www.rfc-editor.org/rfc/rfc4549#section-1.1
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var localFolders = await _imapChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
|
2024-07-09 01:05:16 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
IImapClient executorClient = null;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
List<MailItemFolder> insertedFolders = new();
|
|
|
|
|
List<MailItemFolder> updatedFolders = new();
|
|
|
|
|
List<MailItemFolder> deletedFolders = new();
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
executorClient = await _clientPool.GetClientAsync().ConfigureAwait(false);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var remoteFolders = (await executorClient.GetFoldersAsync(executorClient.PersonalNamespaces[0], cancellationToken: cancellationToken)).ToList();
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// 1. First check deleted folders.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// 1.a If local folder doesn't exists remotely, delete it.
|
|
|
|
|
// 1.b If local folder exists remotely, check if it is still a valid folder. If UidValidity is changed, delete it.
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
foreach (var localFolder in localFolders)
|
|
|
|
|
{
|
|
|
|
|
IMailFolder remoteFolder = null;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
remoteFolder = remoteFolders.FirstOrDefault(a => a.FullName == localFolder.RemoteFolderId);
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
bool shouldDeleteLocalFolder = false;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Check UidValidity of the remote folder if exists.
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (remoteFolder != null)
|
|
|
|
|
{
|
|
|
|
|
// UidValidity won't be available until it's opened.
|
|
|
|
|
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
shouldDeleteLocalFolder = remoteFolder.UidValidity != localFolder.UidValidity;
|
2024-06-21 04:27:17 +02:00
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
// Remote folder doesn't exist. Delete it.
|
|
|
|
|
shouldDeleteLocalFolder = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shouldDeleteLocalFolder)
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
await _imapChangeProcessor.DeleteFolderAsync(Account.Id, localFolder.RemoteFolderId).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
deletedFolders.Add(localFolder);
|
2024-04-18 01:44:37 +02:00
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
|
|
|
|
catch (Exception)
|
|
|
|
|
{
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (remoteFolder != null)
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
await remoteFolder.CloseAsync().ConfigureAwait(false);
|
2024-04-18 01:44:37 +02:00
|
|
|
}
|
2024-06-21 04:27:17 +02:00
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
deletedFolders.ForEach(a => localFolders.Remove(a));
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// 2. Get all remote folders and insert/update each of them.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var nameSpace = executorClient.PersonalNamespaces[0];
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
IMailFolder inbox = executorClient.Inbox;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Sometimes Inbox is the root namespace. We need to check for that.
|
|
|
|
|
if (inbox != null && !remoteFolders.Contains(inbox))
|
|
|
|
|
remoteFolders.Add(inbox);
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
foreach (var remoteFolder in remoteFolders)
|
|
|
|
|
{
|
|
|
|
|
// Namespaces are not needed as folders.
|
|
|
|
|
// Non-existed folders don't need to be synchronized.
|
2025-02-15 12:53:32 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (remoteFolder.IsNamespace && !remoteFolder.Attributes.HasFlag(FolderAttributes.Inbox) || !remoteFolder.Exists)
|
|
|
|
|
continue;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 16:17:41 +01:00
|
|
|
// Ignore folders that can't be opened.
|
|
|
|
|
if (!remoteFolder.CanOpen) continue;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.FullName);
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (existingLocalFolder == null)
|
|
|
|
|
{
|
|
|
|
|
// Folder doesn't exist locally. Insert it.
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var localFolder = remoteFolder.GetLocalFolder();
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Check whether this is a special folder.
|
|
|
|
|
AssignSpecialFolderType(executorClient, remoteFolder, localFolder);
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
bool isSystemFolder = localFolder.SpecialFolderType != SpecialFolderType.Other;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
localFolder.IsSynchronizationEnabled = isSystemFolder;
|
|
|
|
|
localFolder.IsSticky = isSystemFolder;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// By default, all special folders update unread count in the UI except Trash.
|
|
|
|
|
localFolder.ShowUnreadCount = localFolder.SpecialFolderType != SpecialFolderType.Deleted || localFolder.SpecialFolderType != SpecialFolderType.Other;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
localFolder.MailAccountId = Account.Id;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Sometimes sub folders are parented under Inbox.
|
|
|
|
|
// Even though this makes sense in server level, in the client it sucks.
|
|
|
|
|
// That will make sub folders to be parented under Inbox in the client.
|
|
|
|
|
// Instead, we will mark them as non-parented folders.
|
|
|
|
|
// This is better. Model allows personalized folder structure anyways
|
|
|
|
|
// even though we don't have the page/control to adjust it.
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (remoteFolder.ParentFolder == executorClient.Inbox)
|
|
|
|
|
localFolder.ParentRemoteFolderId = string.Empty;
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Set UidValidity for cache expiration.
|
|
|
|
|
// Folder must be opened for this.
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken);
|
2024-06-21 04:27:17 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
localFolder.UidValidity = remoteFolder.UidValidity;
|
2024-07-09 01:05:16 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
await remoteFolder.CloseAsync(cancellationToken: cancellationToken);
|
2024-08-24 17:22:47 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
insertedFolders.Add(localFolder);
|
2024-08-24 17:22:47 +02:00
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
else
|
2025-02-16 11:43:30 +01:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
// Update existing folder. Right now we only update the name.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// TODO: Moving folders around different parents. This is not supported right now.
|
|
|
|
|
// We will need more comphrensive folder update mechanism to support this.
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
|
|
|
|
|
{
|
|
|
|
|
existingLocalFolder.FolderName = remoteFolder.Name;
|
|
|
|
|
updatedFolders.Add(existingLocalFolder);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
// Remove it from the local folder list to skip additional folder updates.
|
|
|
|
|
localFolders.Remove(existingLocalFolder);
|
|
|
|
|
}
|
2025-02-16 11:43:30 +01:00
|
|
|
}
|
2025-02-15 12:53:32 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Process changes in order-> Insert, Update. Deleted ones are already processed.
|
|
|
|
|
|
|
|
|
|
foreach (var folder in insertedFolders)
|
2024-04-18 01:44:37 +02:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
await _imapChangeProcessor.InsertFolderAsync(folder).ConfigureAwait(false);
|
2025-02-15 12:53:32 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
foreach (var folder in updatedFolders)
|
2025-02-16 11:43:30 +01:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
await _imapChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
|
2025-02-16 11:43:30 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
if (insertedFolders.Any() || deletedFolders.Any() || updatedFolders.Any())
|
2025-02-16 11:43:30 +01:00
|
|
|
{
|
2026-03-01 16:23:28 +01:00
|
|
|
_isFolderStructureChanged = true;
|
2025-02-16 11:43:30 +01:00
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Error(ex, "Synchronizing IMAP folders failed.");
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (executorClient != null)
|
2025-02-16 11:43:30 +01:00
|
|
|
{
|
2025-02-16 11:54:23 +01:00
|
|
|
_clientPool.Release(executorClient);
|
2025-02-16 11:43:30 +01:00
|
|
|
}
|
|
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-22 00:22:00 +01:00
|
|
|
public override async Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> folders, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
IImapClient client = null;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
client = await _clientPool.GetClientAsync().ConfigureAwait(false);
|
|
|
|
|
|
2026-04-12 15:56:27 +02:00
|
|
|
var distinctFolders = folders?
|
|
|
|
|
.Where(folder => folder != null)
|
|
|
|
|
.GroupBy(folder => folder.Id)
|
|
|
|
|
.Select(group => group.First())
|
|
|
|
|
.ToList() ?? [];
|
2025-02-22 00:22:00 +01:00
|
|
|
|
2026-04-12 15:56:27 +02:00
|
|
|
HashSet<string> searchResultFolderMailUids = new(StringComparer.Ordinal);
|
|
|
|
|
|
|
|
|
|
foreach (var folder in distinctFolders)
|
2025-02-22 00:22:00 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
if (folder is not MailItemFolder localFolder)
|
|
|
|
|
continue;
|
|
|
|
|
|
2025-02-23 22:17:40 +01:00
|
|
|
var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
2025-02-22 00:22:00 +01:00
|
|
|
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
// Look for subject and body.
|
|
|
|
|
var query = SearchQuery.BodyContains(queryText).Or(SearchQuery.SubjectContains(queryText));
|
|
|
|
|
|
|
|
|
|
var searchResultsInFolder = await remoteFolder.SearchAsync(query, cancellationToken).ConfigureAwait(false);
|
2025-02-23 22:17:40 +01:00
|
|
|
Dictionary<string, UniqueId> searchResultsIdsInFolder = [];
|
2025-02-22 00:22:00 +01:00
|
|
|
|
|
|
|
|
foreach (var searchResultId in searchResultsInFolder)
|
|
|
|
|
{
|
|
|
|
|
var folderMailUid = MailkitClientExtensions.CreateUid(folder.Id, searchResultId.Id);
|
|
|
|
|
searchResultFolderMailUids.Add(folderMailUid);
|
2025-02-23 22:17:40 +01:00
|
|
|
searchResultsIdsInFolder.Add(folderMailUid, searchResultId);
|
|
|
|
|
}
|
2025-02-22 00:22:00 +01:00
|
|
|
|
2025-02-23 22:17:40 +01:00
|
|
|
// Populate no foundIds
|
|
|
|
|
var foundIds = await _imapChangeProcessor.AreMailsExistsAsync(searchResultsIdsInFolder.Select(a => a.Key));
|
|
|
|
|
var notFoundIds = searchResultsIdsInFolder.Keys.Except(foundIds);
|
2025-02-22 00:22:00 +01:00
|
|
|
|
2025-02-23 22:17:40 +01:00
|
|
|
List<UniqueId> nonExistingUniqueIds = [];
|
|
|
|
|
foreach (var nonExistingId in notFoundIds)
|
|
|
|
|
{
|
|
|
|
|
nonExistingUniqueIds.Add(searchResultsIdsInFolder[nonExistingId]);
|
2025-02-22 00:22:00 +01:00
|
|
|
}
|
|
|
|
|
|
2025-02-23 22:17:40 +01:00
|
|
|
if (nonExistingUniqueIds.Count != 0)
|
2025-02-22 00:22:00 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
await _unifiedSynchronizer
|
|
|
|
|
.DownloadMessagesByUidsAsync(client, remoteFolder, localFolder, nonExistingUniqueIds, this, cancellationToken)
|
|
|
|
|
.ConfigureAwait(false);
|
2025-02-23 22:17:40 +01:00
|
|
|
}
|
2025-02-22 00:22:00 +01:00
|
|
|
|
2025-02-23 22:17:40 +01:00
|
|
|
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
2025-02-22 00:22:00 +01:00
|
|
|
}
|
|
|
|
|
|
2025-02-23 22:17:40 +01:00
|
|
|
return await _imapChangeProcessor.GetMailCopiesAsync(searchResultFolderMailUids);
|
2025-02-22 00:22:00 +01:00
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Log.Error(ex, "Failed to perform online imap search.");
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
_clientPool.Release(client);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Whether the local folder should be updated with the remote folder.
|
|
|
|
|
/// IMAP only compares folder name for now.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="remoteFolder">Remote folder</param>
|
|
|
|
|
/// <param name="localFolder">Local folder.</param>
|
|
|
|
|
public bool ShouldUpdateFolder(IMailFolder remoteFolder, MailItemFolder localFolder)
|
|
|
|
|
=> !localFolder.FolderName.Equals(remoteFolder.Name, StringComparison.OrdinalIgnoreCase);
|
2025-02-16 11:35:43 +01:00
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
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);
|
|
|
|
|
|
2026-02-15 19:57:48 +01:00
|
|
|
if (options?.Type == CalendarSynchronizationType.CalendarMetadata)
|
|
|
|
|
return CalendarSynchronizationResult.Empty;
|
|
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
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);
|
|
|
|
|
|
2026-04-11 12:57:51 +02:00
|
|
|
var totalCalendars = localCalendars.Count;
|
|
|
|
|
if (totalCalendars > 0)
|
2026-02-15 02:20:18 +01:00
|
|
|
{
|
2026-04-11 12:57:51 +02:00
|
|
|
UpdateSyncProgress(totalCalendars, totalCalendars, Translator.SyncAction_SynchronizingCalendarEvents);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < totalCalendars; i++)
|
|
|
|
|
{
|
|
|
|
|
var localCalendar = localCalendars[i];
|
|
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
if (!remoteCalendarsById.TryGetValue(localCalendar.RemoteCalendarId, out var remoteCalendar))
|
|
|
|
|
continue;
|
|
|
|
|
|
2026-02-18 20:43:10 +01:00
|
|
|
var remoteToken = BuildCalendarDeltaToken(remoteCalendar);
|
2026-02-15 02:20:18 +01:00
|
|
|
|
|
|
|
|
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);
|
2026-02-18 20:43:10 +01:00
|
|
|
var remoteEventIds = new HashSet<string>(
|
|
|
|
|
remoteEvents
|
|
|
|
|
.Where(e => !string.IsNullOrWhiteSpace(e.RemoteEventId))
|
|
|
|
|
.Select(e => e.RemoteEventId),
|
|
|
|
|
StringComparer.OrdinalIgnoreCase);
|
2026-02-15 02:20:18 +01:00
|
|
|
|
|
|
|
|
foreach (var remoteEvent in remoteEvents)
|
|
|
|
|
{
|
2026-02-18 20:43:10 +01:00
|
|
|
var existingLocalItem = await _imapChangeProcessor
|
|
|
|
|
.GetCalendarItemAsync(localCalendar.Id, remoteEvent.RemoteEventId)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
var shouldSkipUnchangedEvent = await ShouldSkipUnchangedCalDavEventAsync(
|
|
|
|
|
localCalendar,
|
|
|
|
|
existingLocalItem,
|
|
|
|
|
remoteEvent).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (shouldSkipUnchangedEvent)
|
|
|
|
|
continue;
|
|
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
await _imapChangeProcessor
|
|
|
|
|
.ManageCalendarEventAsync(remoteEvent, localCalendar, Account)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(remoteEvent.IcsContent))
|
|
|
|
|
continue;
|
|
|
|
|
|
2026-02-18 20:43:10 +01:00
|
|
|
var localItem = existingLocalItem ?? await _imapChangeProcessor
|
2026-02-15 02:20:18 +01:00
|
|
|
.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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 20:43:10 +01:00
|
|
|
await ReconcileDeletedCalendarItemsAsync(localCalendar, periodStartUtc, periodEndUtc, remoteEventIds)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
localCalendar.SynchronizationDeltaToken = remoteToken;
|
|
|
|
|
await _imapChangeProcessor.UpdateAccountCalendarAsync(localCalendar).ConfigureAwait(false);
|
2026-04-11 12:57:51 +02:00
|
|
|
UpdateSyncProgress(totalCalendars, totalCalendars - (i + 1), Translator.SyncAction_SynchronizingCalendarEvents);
|
2026-02-15 02:20:18 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return CalendarSynchronizationResult.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 20:43:10 +01:00
|
|
|
private async Task<bool> ShouldSkipUnchangedCalDavEventAsync(
|
|
|
|
|
AccountCalendar localCalendar,
|
|
|
|
|
CalendarItem existingLocalItem,
|
|
|
|
|
CalDavCalendarEvent remoteEvent)
|
|
|
|
|
{
|
|
|
|
|
if (localCalendar == null || existingLocalItem == null || remoteEvent == null)
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
// Ensure unresolved parent-child linkage still gets corrected when required.
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(remoteEvent.SeriesMasterRemoteEventId) &&
|
|
|
|
|
existingLocalItem.RecurringCalendarItemId == null)
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(remoteEvent.ETag))
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
var savedETag = await _imapChangeProcessor
|
|
|
|
|
.GetCalendarItemIcsETagAsync(Account.Id, localCalendar.Id, existingLocalItem.Id)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(savedETag))
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
return string.Equals(savedETag.Trim(), remoteEvent.ETag.Trim(), StringComparison.Ordinal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task ReconcileDeletedCalendarItemsAsync(
|
|
|
|
|
AccountCalendar localCalendar,
|
|
|
|
|
DateTimeOffset periodStartUtc,
|
|
|
|
|
DateTimeOffset periodEndUtc,
|
|
|
|
|
HashSet<string> remoteEventIds)
|
|
|
|
|
{
|
|
|
|
|
var syncPeriod = new TimeRange(periodStartUtc.UtcDateTime, periodEndUtc.UtcDateTime);
|
|
|
|
|
var localEventsInWindow = await _calendarService
|
|
|
|
|
.GetCalendarEventsAsync(localCalendar, syncPeriod)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
foreach (var localEvent in localEventsInWindow)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(localEvent.RemoteEventId))
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
if (remoteEventIds.Contains(localEvent.RemoteEventId))
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
await _imapChangeProcessor.DeleteCalendarItemAsync(localEvent.Id).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string BuildCalendarDeltaToken(CalDavCalendar calendar)
|
|
|
|
|
{
|
|
|
|
|
if (calendar == null)
|
|
|
|
|
return string.Empty;
|
|
|
|
|
|
|
|
|
|
var syncToken = calendar.SyncToken?.Trim() ?? string.Empty;
|
|
|
|
|
var ctag = calendar.CTag?.Trim() ?? string.Empty;
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(syncToken) && !string.IsNullOrWhiteSpace(ctag))
|
|
|
|
|
return $"{syncToken}|{ctag}";
|
|
|
|
|
|
|
|
|
|
return !string.IsNullOrWhiteSpace(syncToken) ? syncToken : ctag;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 02:20:18 +01:00
|
|
|
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);
|
2026-04-04 20:23:20 +02:00
|
|
|
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
2026-02-15 02:20:18 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-02-15 11:27:30 +01:00
|
|
|
BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors),
|
2026-02-15 02:20:18 +01:00
|
|
|
TimeZone = "UTC",
|
|
|
|
|
SynchronizationDeltaToken = string.Empty
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-04 20:23:20 +02:00
|
|
|
newCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(newCalendar.BackgroundColorHex);
|
2026-02-15 11:27:30 +01:00
|
|
|
usedCalendarColors.Add(newCalendar.BackgroundColorHex);
|
2026-02-15 02:20:18 +01:00
|
|
|
await _imapChangeProcessor.InsertAccountCalendarAsync(newCalendar).ConfigureAwait(false);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:23:20 +02:00
|
|
|
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocal.BackgroundColorHex);
|
2026-02-15 02:20:18 +01:00
|
|
|
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|
2026-04-04 20:23:20 +02:00
|
|
|
|| existingLocal.IsPrimary != isPrimary
|
|
|
|
|
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase);
|
2026-02-15 02:20:18 +01:00
|
|
|
|
|
|
|
|
if (!shouldUpdate)
|
2026-04-04 20:23:20 +02:00
|
|
|
{
|
|
|
|
|
usedCalendarColors.Add(resolvedColor);
|
2026-02-15 02:20:18 +01:00
|
|
|
continue;
|
2026-04-04 20:23:20 +02:00
|
|
|
}
|
2026-02-15 02:20:18 +01:00
|
|
|
|
|
|
|
|
existingLocal.Name = remoteCalendar.Name;
|
|
|
|
|
existingLocal.IsPrimary = isPrimary;
|
2026-04-04 20:23:20 +02:00
|
|
|
existingLocal.BackgroundColorHex = resolvedColor;
|
|
|
|
|
existingLocal.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocal.BackgroundColorHex);
|
|
|
|
|
usedCalendarColors.Add(existingLocal.BackgroundColorHex);
|
2026-02-15 02:20:18 +01:00
|
|
|
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-02-16 11:35:43 +01:00
|
|
|
|
2026-02-15 11:27:30 +01:00
|
|
|
private interface IImapCalendarOperationHandler
|
|
|
|
|
{
|
|
|
|
|
bool RequiresConnectedClient { get; }
|
|
|
|
|
Task CreateCalendarEventAsync(CreateCalendarEventRequest request);
|
|
|
|
|
Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request);
|
|
|
|
|
Task DeleteCalendarEventAsync(DeleteCalendarEventRequest request);
|
|
|
|
|
Task AcceptEventAsync(AcceptEventRequest request);
|
|
|
|
|
Task DeclineEventAsync(DeclineEventRequest request);
|
|
|
|
|
Task TentativeEventAsync(TentativeEventRequest request);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private class LocalCalendarOperationHandler : IImapCalendarOperationHandler
|
|
|
|
|
{
|
|
|
|
|
private readonly MailAccount _account;
|
|
|
|
|
private readonly IImapChangeProcessor _changeProcessor;
|
|
|
|
|
private readonly ICalendarService _calendarService;
|
2026-03-07 17:13:48 +01:00
|
|
|
private readonly string _applicationDataFolderPath;
|
2026-02-15 11:27:30 +01:00
|
|
|
private readonly string _resourceScheme;
|
|
|
|
|
|
|
|
|
|
public bool RequiresConnectedClient => false;
|
|
|
|
|
|
2026-03-07 17:13:48 +01:00
|
|
|
public LocalCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService, string applicationDataFolderPath, string resourceScheme)
|
2026-02-15 11:27:30 +01:00
|
|
|
{
|
|
|
|
|
_account = account;
|
|
|
|
|
_changeProcessor = changeProcessor;
|
|
|
|
|
_calendarService = calendarService;
|
2026-03-07 17:13:48 +01:00
|
|
|
_applicationDataFolderPath = applicationDataFolderPath;
|
2026-02-15 11:27:30 +01:00
|
|
|
_resourceScheme = resourceScheme;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task CreateCalendarEventAsync(CreateCalendarEventRequest request)
|
|
|
|
|
{
|
2026-03-07 17:13:48 +01:00
|
|
|
var item = request.PreparedItem;
|
|
|
|
|
var attendees = request.PreparedEvent.Attendees;
|
|
|
|
|
var reminders = request.PreparedEvent.Reminders;
|
2026-02-15 11:27:30 +01:00
|
|
|
EnsureCalendarItemDefaults(item, _account, "local");
|
|
|
|
|
item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
var existing = await _calendarService.GetCalendarItemAsync(item.Id).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (existing == null)
|
2026-03-07 17:13:48 +01:00
|
|
|
await _calendarService.CreateNewCalendarItemAsync(item, attendees).ConfigureAwait(false);
|
2026-02-15 11:27:30 +01:00
|
|
|
else
|
2026-03-07 17:13:48 +01:00
|
|
|
await _calendarService.UpdateCalendarItemAsync(item, attendees).ConfigureAwait(false);
|
2026-02-15 11:27:30 +01:00
|
|
|
|
2026-03-07 17:13:48 +01:00
|
|
|
await _calendarService.SaveRemindersAsync(item.Id, reminders).ConfigureAwait(false);
|
|
|
|
|
await SaveAttachmentsAsync(request.ComposeResult, item.Id).ConfigureAwait(false);
|
|
|
|
|
await PersistIcsAsync(item, attendees).ConfigureAwait(false);
|
2026-02-15 11:27:30 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
var item = request.Item;
|
|
|
|
|
EnsureCalendarItemDefaults(item, _account, "local");
|
|
|
|
|
item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
var attendees = request.Attendees ?? await _calendarService.GetAttendeesAsync(item.Id).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
await _calendarService.UpdateCalendarItemAsync(item, attendees).ConfigureAwait(false);
|
|
|
|
|
await PersistIcsAsync(item, attendees).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task DeleteCalendarEventAsync(DeleteCalendarEventRequest request)
|
|
|
|
|
=> _changeProcessor.DeleteCalendarItemAsync(request.Item.Id);
|
|
|
|
|
|
|
|
|
|
public async Task AcceptEventAsync(AcceptEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
request.Item.Status = CalendarItemStatus.Accepted;
|
|
|
|
|
await UpdateStatusAsync(request.Item).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task DeclineEventAsync(DeclineEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
request.Item.Status = CalendarItemStatus.Cancelled;
|
|
|
|
|
await UpdateStatusAsync(request.Item).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task TentativeEventAsync(TentativeEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
request.Item.Status = CalendarItemStatus.Tentative;
|
|
|
|
|
await UpdateStatusAsync(request.Item).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task UpdateStatusAsync(CalendarItem item)
|
|
|
|
|
{
|
|
|
|
|
EnsureCalendarItemDefaults(item, _account, "local");
|
|
|
|
|
item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
var attendees = await _calendarService.GetAttendeesAsync(item.Id).ConfigureAwait(false);
|
|
|
|
|
await _calendarService.UpdateCalendarItemAsync(item, attendees).ConfigureAwait(false);
|
|
|
|
|
await PersistIcsAsync(item, attendees).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task PersistIcsAsync(CalendarItem item, List<CalendarEventAttendee> attendees)
|
|
|
|
|
{
|
|
|
|
|
var resourceHref = $"{_resourceScheme}://calendar/{item.CalendarId:N}/{item.Id:N}";
|
|
|
|
|
var icsContent = BuildIcsContent(item, attendees);
|
|
|
|
|
|
|
|
|
|
return _changeProcessor.SaveCalendarItemIcsAsync(
|
|
|
|
|
_account.Id,
|
|
|
|
|
item.CalendarId,
|
|
|
|
|
item.Id,
|
|
|
|
|
item.RemoteEventId,
|
|
|
|
|
resourceHref,
|
|
|
|
|
DateTimeOffset.UtcNow.ToString("O"),
|
|
|
|
|
icsContent);
|
|
|
|
|
}
|
2026-03-07 17:13:48 +01:00
|
|
|
|
|
|
|
|
private async Task SaveAttachmentsAsync(CalendarEventComposeResult composeResult, Guid calendarItemId)
|
|
|
|
|
{
|
|
|
|
|
await _calendarService.DeleteAttachmentsAsync(calendarItemId).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
var attachments = composeResult?.Attachments;
|
|
|
|
|
if (attachments == null || attachments.Count == 0)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
var attachmentsRoot = Path.Combine(_applicationDataFolderPath, "CalendarAttachments", calendarItemId.ToString("N"));
|
|
|
|
|
Directory.CreateDirectory(attachmentsRoot);
|
|
|
|
|
|
|
|
|
|
var storedAttachments = new List<CalendarAttachment>();
|
|
|
|
|
|
|
|
|
|
foreach (var attachment in attachments.Where(a => !string.IsNullOrWhiteSpace(a.FilePath) && File.Exists(a.FilePath)))
|
|
|
|
|
{
|
|
|
|
|
var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? Path.GetFileName(attachment.FilePath) : attachment.FileName;
|
|
|
|
|
var destinationPath = Path.Combine(attachmentsRoot, fileName);
|
|
|
|
|
File.Copy(attachment.FilePath, destinationPath, overwrite: true);
|
|
|
|
|
|
|
|
|
|
storedAttachments.Add(new CalendarAttachment
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
CalendarItemId = calendarItemId,
|
|
|
|
|
RemoteAttachmentId = attachment.Id.ToString("N"),
|
|
|
|
|
FileName = fileName,
|
|
|
|
|
Size = attachment.Size,
|
|
|
|
|
ContentType = MimeTypes.GetMimeType(fileName),
|
|
|
|
|
IsDownloaded = true,
|
|
|
|
|
LocalFilePath = destinationPath,
|
|
|
|
|
LastModified = DateTimeOffset.UtcNow
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (storedAttachments.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
await _calendarService.InsertOrReplaceAttachmentsAsync(storedAttachments).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 11:27:30 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-18 20:43:10 +01:00
|
|
|
private sealed class CalDavCalendarOperationHandler : IImapCalendarOperationHandler
|
2026-02-15 11:27:30 +01:00
|
|
|
{
|
2026-02-18 20:43:10 +01:00
|
|
|
private readonly ImapSynchronizer _owner;
|
|
|
|
|
private readonly MailAccount _account;
|
|
|
|
|
private readonly ICalendarService _calendarService;
|
|
|
|
|
private readonly ICalDavClient _calDavClient;
|
|
|
|
|
|
|
|
|
|
public bool RequiresConnectedClient => false;
|
|
|
|
|
|
|
|
|
|
public CalDavCalendarOperationHandler(
|
|
|
|
|
ImapSynchronizer owner,
|
|
|
|
|
MailAccount account,
|
|
|
|
|
ICalendarService calendarService,
|
|
|
|
|
ICalDavClient calDavClient)
|
|
|
|
|
{
|
|
|
|
|
_owner = owner;
|
|
|
|
|
_account = account;
|
|
|
|
|
_calendarService = calendarService;
|
|
|
|
|
_calDavClient = calDavClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task CreateCalendarEventAsync(CreateCalendarEventRequest request)
|
2026-03-07 17:13:48 +01:00
|
|
|
=> UpsertCalendarEventAsync(request.PreparedItem, request.PreparedEvent.Attendees);
|
2026-02-18 20:43:10 +01:00
|
|
|
|
|
|
|
|
public Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request)
|
|
|
|
|
=> UpsertCalendarEventAsync(request.Item, request.Attendees);
|
|
|
|
|
|
|
|
|
|
public async Task DeleteCalendarEventAsync(DeleteCalendarEventRequest request)
|
2026-02-15 11:27:30 +01:00
|
|
|
{
|
2026-02-18 20:43:10 +01:00
|
|
|
var (connection, calendar) = await ResolveCalDavContextAsync(request.Item.CalendarId).ConfigureAwait(false);
|
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Item?.RemoteEventId))
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException("Cannot delete CalDAV event because remote event ID is missing.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await _calDavClient
|
2026-03-07 17:13:48 +01:00
|
|
|
.DeleteCalendarEventAsync(connection, calendar, request.Item.RemoteEventId.GetProviderRemoteEventId())
|
2026-02-18 20:43:10 +01:00
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task AcceptEventAsync(AcceptEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
request.Item.Status = CalendarItemStatus.Accepted;
|
|
|
|
|
return UpsertCalendarEventAsync(request.Item, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task DeclineEventAsync(DeclineEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
request.Item.Status = CalendarItemStatus.Cancelled;
|
|
|
|
|
return UpsertCalendarEventAsync(request.Item, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task TentativeEventAsync(TentativeEventRequest request)
|
|
|
|
|
{
|
|
|
|
|
request.Item.Status = CalendarItemStatus.Tentative;
|
|
|
|
|
return UpsertCalendarEventAsync(request.Item, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task UpsertCalendarEventAsync(CalendarItem item, List<CalendarEventAttendee> attendees)
|
|
|
|
|
{
|
|
|
|
|
EnsureCalendarItemDefaults(item, _account, "caldav");
|
|
|
|
|
|
|
|
|
|
if (attendees == null)
|
|
|
|
|
{
|
|
|
|
|
attendees = await _calendarService.GetAttendeesAsync(item.Id).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var (connection, calendar) = await ResolveCalDavContextAsync(item.CalendarId).ConfigureAwait(false);
|
|
|
|
|
var icsContent = BuildIcsContent(item, attendees);
|
|
|
|
|
|
|
|
|
|
await _calDavClient
|
2026-03-07 17:13:48 +01:00
|
|
|
.UpsertCalendarEventAsync(connection, calendar, item.RemoteEventId.GetProviderRemoteEventId(), icsContent)
|
2026-02-18 20:43:10 +01:00
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<(CalDavConnectionSettings Connection, CalDavCalendar Calendar)> ResolveCalDavContextAsync(Guid calendarId)
|
|
|
|
|
{
|
|
|
|
|
var assignedCalendar = await _calendarService.GetAccountCalendarAsync(calendarId).ConfigureAwait(false);
|
|
|
|
|
if (assignedCalendar == null || string.IsNullOrWhiteSpace(assignedCalendar.RemoteCalendarId))
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException("Cannot execute CalDAV operation because the target calendar has no remote ID.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var serviceUri = await _owner.ResolveCalDavServiceUriAsync(CancellationToken.None).ConfigureAwait(false);
|
|
|
|
|
if (serviceUri == null)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException("Cannot execute CalDAV operation because no CalDAV service URI is configured.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var username = _owner.ResolveCalDavUsername();
|
|
|
|
|
var password = _owner.ResolveCalDavPassword();
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException("Cannot execute CalDAV operation because credentials are missing.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var connection = new CalDavConnectionSettings
|
|
|
|
|
{
|
|
|
|
|
ServiceUri = serviceUri,
|
|
|
|
|
Username = username,
|
|
|
|
|
Password = password
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var remoteCalendar = new CalDavCalendar
|
|
|
|
|
{
|
|
|
|
|
RemoteCalendarId = assignedCalendar.RemoteCalendarId,
|
|
|
|
|
Name = assignedCalendar.Name
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (connection, remoteCalendar);
|
2026-02-15 11:27:30 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void EnsureCalendarItemDefaults(CalendarItem item, MailAccount account, string idPrefix)
|
|
|
|
|
{
|
|
|
|
|
if (item == null)
|
|
|
|
|
throw new ArgumentNullException(nameof(item));
|
|
|
|
|
|
|
|
|
|
if (item.Id == Guid.Empty)
|
|
|
|
|
item.Id = Guid.NewGuid();
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(item.RemoteEventId))
|
|
|
|
|
item.RemoteEventId = $"{idPrefix}-{item.Id:N}";
|
|
|
|
|
|
|
|
|
|
if (item.CreatedAt == default)
|
|
|
|
|
item.CreatedAt = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
item.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
|
item.OrganizerDisplayName ??= account?.SenderName ?? string.Empty;
|
|
|
|
|
item.OrganizerEmail ??= account?.Address ?? string.Empty;
|
|
|
|
|
item.StartTimeZone ??= TimeZoneInfo.Local.Id;
|
|
|
|
|
item.EndTimeZone ??= item.StartTimeZone;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string BuildIcsContent(CalendarItem item, List<CalendarEventAttendee> attendees)
|
|
|
|
|
{
|
|
|
|
|
var uid = item.RemoteEventId?.Split(new[] { "::" }, StringSplitOptions.None)[0] ?? item.Id.ToString("N");
|
|
|
|
|
var dtStamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'");
|
|
|
|
|
|
|
|
|
|
var lines = new List<string>
|
|
|
|
|
{
|
|
|
|
|
"BEGIN:VCALENDAR",
|
|
|
|
|
"VERSION:2.0",
|
|
|
|
|
"PRODID:-//Wino Mail//Calendar//EN",
|
|
|
|
|
"CALSCALE:GREGORIAN",
|
|
|
|
|
"BEGIN:VEVENT",
|
|
|
|
|
$"UID:{EscapeIcs(uid)}",
|
|
|
|
|
$"DTSTAMP:{dtStamp}",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (item.IsAllDayEvent)
|
|
|
|
|
{
|
|
|
|
|
lines.Add($"DTSTART;VALUE=DATE:{item.StartDate:yyyyMMdd}");
|
|
|
|
|
lines.Add($"DTEND;VALUE=DATE:{item.EndDate:yyyyMMdd}");
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2026-02-19 02:09:36 +01:00
|
|
|
var startUtc = ConvertEventTimeToUtc(item.StartDate, item.StartTimeZone);
|
|
|
|
|
var endUtc = ConvertEventTimeToUtc(item.EndDate, item.EndTimeZone ?? item.StartTimeZone);
|
|
|
|
|
|
|
|
|
|
lines.Add($"DTSTART:{startUtc:yyyyMMdd'T'HHmmss'Z'}");
|
|
|
|
|
lines.Add($"DTEND:{endUtc:yyyyMMdd'T'HHmmss'Z'}");
|
2026-02-15 11:27:30 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(item.Title))
|
|
|
|
|
lines.Add($"SUMMARY:{EscapeIcs(item.Title)}");
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(item.Description))
|
|
|
|
|
lines.Add($"DESCRIPTION:{EscapeIcs(item.Description)}");
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(item.Location))
|
|
|
|
|
lines.Add($"LOCATION:{EscapeIcs(item.Location)}");
|
|
|
|
|
|
|
|
|
|
lines.Add($"STATUS:{MapStatus(item.Status)}");
|
|
|
|
|
lines.Add($"TRANSP:{(item.ShowAs == CalendarItemShowAs.Free ? "TRANSPARENT" : "OPAQUE")}");
|
|
|
|
|
lines.Add($"CLASS:{MapVisibility(item.Visibility)}");
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(item.Recurrence))
|
|
|
|
|
{
|
|
|
|
|
var recurrenceLines = item.Recurrence
|
|
|
|
|
.Split(Wino.Core.Domain.Constants.CalendarEventRecurrenceRuleSeperator, StringSplitOptions.RemoveEmptyEntries)
|
|
|
|
|
.Select(l => l.Trim())
|
|
|
|
|
.Where(l => !string.IsNullOrWhiteSpace(l));
|
|
|
|
|
|
|
|
|
|
lines.AddRange(recurrenceLines);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(item.OrganizerEmail))
|
|
|
|
|
{
|
|
|
|
|
var organizerName = string.IsNullOrWhiteSpace(item.OrganizerDisplayName)
|
|
|
|
|
? item.OrganizerEmail
|
|
|
|
|
: item.OrganizerDisplayName;
|
|
|
|
|
lines.Add($"ORGANIZER;CN={EscapeIcs(organizerName)}:mailto:{EscapeIcs(item.OrganizerEmail)}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (attendees != null)
|
|
|
|
|
{
|
|
|
|
|
foreach (var attendee in attendees.Where(a => !string.IsNullOrWhiteSpace(a.Email)))
|
|
|
|
|
{
|
|
|
|
|
var role = attendee.IsOptionalAttendee ? "OPT-PARTICIPANT" : "REQ-PARTICIPANT";
|
|
|
|
|
var partStat = attendee.AttendenceStatus switch
|
|
|
|
|
{
|
|
|
|
|
AttendeeStatus.Accepted => "ACCEPTED",
|
|
|
|
|
AttendeeStatus.Declined => "DECLINED",
|
|
|
|
|
AttendeeStatus.Tentative => "TENTATIVE",
|
|
|
|
|
_ => "NEEDS-ACTION"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var cn = string.IsNullOrWhiteSpace(attendee.Name) ? attendee.Email : attendee.Name;
|
|
|
|
|
lines.Add($"ATTENDEE;CN={EscapeIcs(cn)};ROLE={role};PARTSTAT={partStat}:mailto:{EscapeIcs(attendee.Email)}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lines.Add("END:VEVENT");
|
|
|
|
|
lines.Add("END:VCALENDAR");
|
|
|
|
|
|
|
|
|
|
return string.Join(Environment.NewLine, lines);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 02:09:36 +01:00
|
|
|
private static DateTime ConvertEventTimeToUtc(DateTime eventDateTime, string eventTimeZoneId)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(eventTimeZoneId))
|
|
|
|
|
return eventDateTime.ToUniversalTime();
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var eventTimeZone = TimeZoneInfo.FindSystemTimeZoneById(eventTimeZoneId);
|
|
|
|
|
var unspecifiedDateTime = DateTime.SpecifyKind(eventDateTime, DateTimeKind.Unspecified);
|
|
|
|
|
return TimeZoneInfo.ConvertTimeToUtc(unspecifiedDateTime, eventTimeZone);
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
return eventDateTime.ToUniversalTime();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 11:27:30 +01:00
|
|
|
private static string EscapeIcs(string value)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(value))
|
|
|
|
|
return string.Empty;
|
|
|
|
|
|
|
|
|
|
return value
|
|
|
|
|
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
|
|
|
|
.Replace(";", "\\;", StringComparison.Ordinal)
|
|
|
|
|
.Replace(",", "\\,", StringComparison.Ordinal)
|
|
|
|
|
.Replace("\r\n", "\\n", StringComparison.Ordinal)
|
|
|
|
|
.Replace("\n", "\\n", StringComparison.Ordinal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string MapStatus(CalendarItemStatus status)
|
|
|
|
|
{
|
|
|
|
|
return status switch
|
|
|
|
|
{
|
|
|
|
|
CalendarItemStatus.Cancelled => "CANCELLED",
|
|
|
|
|
CalendarItemStatus.Tentative => "TENTATIVE",
|
|
|
|
|
_ => "CONFIRMED"
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string MapVisibility(CalendarItemVisibility visibility)
|
|
|
|
|
{
|
|
|
|
|
return visibility switch
|
|
|
|
|
{
|
|
|
|
|
CalendarItemVisibility.Public => "PUBLIC",
|
|
|
|
|
CalendarItemVisibility.Private => "PRIVATE",
|
|
|
|
|
CalendarItemVisibility.Confidential => "CONFIDENTIAL",
|
|
|
|
|
_ => "PUBLIC"
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
public Task StartIdleClientAsync()
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
if (IsDisposing)
|
|
|
|
|
return Task.CompletedTask;
|
2025-02-16 11:35:43 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
if (_idleLoopTask != null && !_idleLoopTask.IsCompleted)
|
|
|
|
|
return Task.CompletedTask;
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
_idleLoopCancellationTokenSource = new CancellationTokenSource();
|
|
|
|
|
_idleLoopTask = RunIdleLoopAsync(_idleLoopCancellationTokenSource.Token);
|
|
|
|
|
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task RunIdleLoopAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
int reconnectAttempt = 0;
|
|
|
|
|
|
|
|
|
|
while (!cancellationToken.IsCancellationRequested && !IsDisposing)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
IImapClient idleClient = null;
|
|
|
|
|
IMailFolder inboxFolder = null;
|
|
|
|
|
bool shouldReconnect = false;
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
try
|
2025-02-15 12:53:32 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
idleClient = await _clientPool.GetIdleClientAsync(cancellationToken).ConfigureAwait(false);
|
2025-02-16 11:54:23 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
if (idleClient == null)
|
|
|
|
|
{
|
|
|
|
|
_logger.Warning("Dedicated IDLE client could not be allocated for {AccountName}.", Account.Name);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
if (!idleClient.Capabilities.HasFlag(ImapCapabilities.Idle))
|
|
|
|
|
{
|
|
|
|
|
_logger.Information("{AccountName} does not support IMAP IDLE. Automatic updates rely on global sync interval.", Account.Name);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
if (idleClient.Inbox == null)
|
|
|
|
|
{
|
|
|
|
|
_logger.Warning("{AccountName} does not expose Inbox for IDLE listening.", Account.Name);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
inboxFolder = idleClient.Inbox;
|
2025-02-16 11:54:23 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
await inboxFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
2025-02-16 11:54:23 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
_lastIdleInboxCount = inboxFolder.Count;
|
|
|
|
|
inboxFolder.CountChanged += IdleInboxCountChanged;
|
2025-02-16 11:54:23 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
reconnectAttempt = 0;
|
|
|
|
|
_logger.Debug("Started dedicated IDLE loop for {AccountName}.", Account.Name);
|
2025-02-16 11:54:23 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
while (!cancellationToken.IsCancellationRequested && !IsDisposing && idleClient.IsConnected)
|
|
|
|
|
{
|
|
|
|
|
using var idleDoneTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(9));
|
|
|
|
|
await idleClient.IdleAsync(idleDoneTokenSource.Token, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (ImapProtocolException protocolException)
|
2025-02-16 11:35:43 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
_logger.Information(protocolException, "Idle client received protocol exception for {AccountName}.", Account.Name);
|
|
|
|
|
shouldReconnect = true;
|
2025-02-16 11:35:43 +01:00
|
|
|
}
|
2026-02-14 12:52:17 +01:00
|
|
|
catch (IOException ioException)
|
2025-02-16 11:35:43 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
_logger.Information(ioException, "Idle client received IO exception for {AccountName}.", Account.Name);
|
|
|
|
|
shouldReconnect = true;
|
2025-02-16 11:35:43 +01:00
|
|
|
}
|
2026-02-14 12:52:17 +01:00
|
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested || IsDisposing)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
shouldReconnect = true;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Error(ex, "Idle client loop failed for {AccountName}.", Account.Name);
|
|
|
|
|
shouldReconnect = true;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (inboxFolder != null)
|
|
|
|
|
{
|
|
|
|
|
inboxFolder.CountChanged -= IdleInboxCountChanged;
|
|
|
|
|
|
|
|
|
|
if (inboxFolder.IsOpen && !cancellationToken.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
await inboxFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
_clientPool.ReleaseIdleClient(isFaulted: shouldReconnect);
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
if (!shouldReconnect)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
break;
|
|
|
|
|
}
|
2025-02-16 11:43:30 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
reconnectAttempt++;
|
|
|
|
|
var reconnectDelay = GetIdleReconnectDelay(reconnectAttempt);
|
|
|
|
|
_logger.Information("Reconnecting IDLE client for {AccountName} in {Delay}.", Account.Name, reconnectDelay);
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await Task.Delay(reconnectDelay, cancellationToken).ConfigureAwait(false);
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2026-02-14 12:52:17 +01:00
|
|
|
catch (OperationCanceledException)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
break;
|
2025-02-15 12:53:32 +01:00
|
|
|
}
|
|
|
|
|
}
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
private static TimeSpan GetIdleReconnectDelay(int attempt)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
var backoffSeconds = Math.Min(60, Math.Pow(2, Math.Min(attempt, 6)));
|
|
|
|
|
int jitterMs;
|
|
|
|
|
|
|
|
|
|
lock (IdleReconnectJitter)
|
|
|
|
|
{
|
|
|
|
|
jitterMs = IdleReconnectJitter.Next(250, 1250);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return TimeSpan.FromSeconds(backoffSeconds) + TimeSpan.FromMilliseconds(jitterMs);
|
|
|
|
|
}
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
private void RequestIdleChangeSynchronization()
|
|
|
|
|
{
|
|
|
|
|
if (!ShouldTriggerIdleSynchronization(DateTime.UtcNow))
|
|
|
|
|
return;
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
var options = new MailSynchronizationOptions()
|
|
|
|
|
{
|
|
|
|
|
AccountId = Account.Id,
|
|
|
|
|
Type = MailSynchronizationType.IMAPIdle
|
|
|
|
|
};
|
2024-04-18 01:44:37 +02:00
|
|
|
|
2025-10-12 16:23:33 +02:00
|
|
|
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options));
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2024-07-09 01:05:16 +02:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
internal bool ShouldTriggerIdleSynchronization(DateTime nowUtc)
|
|
|
|
|
{
|
|
|
|
|
lock (_idleDebounceLock)
|
|
|
|
|
{
|
|
|
|
|
if (nowUtc - _lastIdleSyncRequestUtc < _idleSyncDebounceWindow)
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_lastIdleSyncRequestUtc = nowUtc;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-07-09 01:05:16 +02:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
private void IdleInboxCountChanged(object sender, EventArgs e)
|
2025-02-16 11:54:23 +01:00
|
|
|
{
|
2026-02-14 12:52:17 +01:00
|
|
|
if (sender is not IMailFolder inboxFolder)
|
|
|
|
|
return;
|
2024-12-24 18:30:25 +01:00
|
|
|
|
2026-02-14 12:52:17 +01:00
|
|
|
var currentCount = inboxFolder.Count;
|
|
|
|
|
var previousCount = _lastIdleInboxCount;
|
|
|
|
|
_lastIdleInboxCount = currentCount;
|
|
|
|
|
|
|
|
|
|
if (currentCount > previousCount)
|
|
|
|
|
{
|
|
|
|
|
RequestIdleChangeSynchronization();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task StopIdleClientAsync()
|
|
|
|
|
{
|
|
|
|
|
if (_idleLoopCancellationTokenSource != null)
|
|
|
|
|
{
|
|
|
|
|
_idleLoopCancellationTokenSource.Cancel();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_idleLoopTask != null)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await _idleLoopTask.ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
// no-op
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_idleLoopCancellationTokenSource?.Dispose();
|
|
|
|
|
_idleLoopCancellationTokenSource = null;
|
|
|
|
|
_idleLoopTask = null;
|
2025-02-16 11:54:23 +01:00
|
|
|
}
|
2025-02-15 12:53:32 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
public override async Task KillSynchronizerAsync()
|
|
|
|
|
{
|
|
|
|
|
await base.KillSynchronizerAsync();
|
|
|
|
|
await StopIdleClientAsync();
|
2025-02-15 12:53:32 +01:00
|
|
|
|
2025-02-16 11:54:23 +01:00
|
|
|
// Make sure the client pool safely disconnects all ImapClients.
|
|
|
|
|
_clientPool.Dispose();
|
2024-04-18 01:44:37 +02:00
|
|
|
}
|
2025-02-26 23:13:05 +01:00
|
|
|
|
|
|
|
|
public Task PreWarmClientPoolAsync() => _clientPool.PreWarmPoolAsync();
|
2024-04-18 01:44:37 +02:00
|
|
|
}
|
2026-02-14 12:52:17 +01:00
|
|
|
|
|
|
|
|
|