IMAP Improvements (#558)

* Fixing an issue where scrollviewer overrides a part of template in mail list. Adjusted zoomed out header grid's corner radius.

* IDLE implementation, imap synchronization strategies basics and condstore synchronization.

* Adding iCloud and Yahoo as special IMAP handling scenario.

* iCloud special imap handling.

* Support for killing synchronizers.

* Update privacy policy url.

* Batching condstore downloads into 50, using SORT extension for searches if supported.

* Bumping some nugets. More on the imap synchronizers.

* Delegating idle synchronizations to server to post-sync operations.

* Update mailkit to resolve qresync bug with iCloud.

* Fixing remote highest mode seq checks for qresync and condstore synchronizers.

* Yahoo custom settings.

* Bump google sdk package.

* Fixing the build issue....

* NRE on canceled token accounts during setup.

* Server crash handlers.

* Remove ARM32. Upgrade server to .NET 9.

* Fix icons for yahoo and apple.

* Fixed an issue where disabled folders causing an exception on forced sync.

* Remove smtp encoding constraint.

* Remove commented code.

* Fixing merge conflict

* Addressing double registrations for mailkit remote folder events in synchronizers.

* Making sure idle canceled result is not reported.

* Fixing custom imap server dialog opening.

* Fixing the issue with account creation making the previously selected account as selected as well.

* Fixing app close behavior and logging app close.
This commit is contained in:
Burak Kaan Köse
2025-02-15 12:53:32 +01:00
committed by GitHub
parent 30f1257983
commit ee9e41c5a7
108 changed files with 2092 additions and 1166 deletions

View File

@@ -4,6 +4,7 @@ using Wino.Authentication;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration.Processors;
using Wino.Core.Services;
using Wino.Core.Synchronizers.ImapSync;
namespace Wino.Core
{
@@ -28,6 +29,11 @@ namespace Wino.Core
services.AddTransient<IUnsubscriptionService, UnsubscriptionService>();
services.AddTransient<IOutlookAuthenticator, OutlookAuthenticator>();
services.AddTransient<IGmailAuthenticator, GmailAuthenticator>();
services.AddTransient<IImapSynchronizationStrategyProvider, ImapSynchronizationStrategyProvider>();
services.AddTransient<CondstoreSynchronizer>();
services.AddTransient<QResyncSynchronizer>();
services.AddTransient<UidBasedSynchronizer>();
}
}
}

View File

@@ -25,8 +25,6 @@ namespace Wino.Core.Integration
/// Provides a pooling mechanism for ImapClient.
/// Makes sure that we don't have too many connections to the server.
/// Rents a connected & authenticated client from the pool all the time.
/// TODO: Keeps the clients alive by sending NOOP command periodically.
/// TODO: Listens to the Inbox folder for new messages.
/// </summary>
/// <param name="customServerInformation">Connection/Authentication info to be used to configure ImapClient.</param>
public class ImapClientPool : IDisposable
@@ -48,14 +46,16 @@ namespace Wino.Core.Integration
public bool ThrowOnSSLHandshakeCallback { get; set; }
public ImapClientPoolOptions ImapClientPoolOptions { get; }
internal WinoImapClient IdleClient { get; set; }
private readonly int MinimumPoolSize = 5;
private readonly ConcurrentStack<ImapClient> _clients = [];
private readonly ConcurrentStack<IImapClient> _clients = [];
private readonly SemaphoreSlim _semaphore;
private readonly CustomServerInformation _customServerInformation;
private readonly Stream _protocolLogStream;
private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
private bool _disposedValue;
public ImapClientPool(ImapClientPoolOptions imapClientPoolOptions)
{
@@ -74,7 +74,7 @@ namespace Wino.Core.Integration
/// Reconnects and reauthenticates if necessary.
/// </summary>
/// <param name="isCreatedNew">Whether the client has been newly created.</param>
private async Task EnsureCapabilitiesAsync(ImapClient client, bool isCreatedNew)
private async Task EnsureCapabilitiesAsync(IImapClient client, bool isCreatedNew)
{
try
{
@@ -84,6 +84,9 @@ namespace Wino.Core.Integration
if ((isCreatedNew || isReconnected) && client.IsConnected)
{
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
await client.CompressAsync();
// Identify if the server supports ID extension.
// Some servers require it pre-authentication, some post-authentication.
// We'll observe the response here and do it after authentication if needed.
@@ -113,10 +116,11 @@ namespace Wino.Core.Integration
// Activate post-auth capabilities.
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
await client.EnableQuickResyncAsync();
{
await client.EnableQuickResyncAsync().ConfigureAwait(false);
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
await client.CompressAsync();
if (client is WinoImapClient winoImapClient) winoImapClient.IsQResyncEnabled = true;
}
}
}
catch (Exception ex)
@@ -145,11 +149,11 @@ namespace Wino.Core.Integration
return reader.ReadToEnd();
}
public async Task<ImapClient> GetClientAsync()
public async Task<IImapClient> GetClientAsync()
{
await _semaphore.WaitAsync();
if (_clients.TryPop(out ImapClient item))
if (_clients.TryPop(out IImapClient item))
{
await EnsureCapabilitiesAsync(item, false);
@@ -163,20 +167,24 @@ namespace Wino.Core.Integration
return client;
}
public void Release(ImapClient item, bool destroyClient = false)
public void Release(IImapClient item, bool destroyClient = false)
{
if (item != null)
{
if (destroyClient)
{
lock (item.SyncRoot)
if (item.IsConnected)
{
item.Disconnect(true);
lock (item.SyncRoot)
{
item.Disconnect(quit: true);
}
}
_clients.TryPop(out _);
item.Dispose();
}
else
else if (!_disposedValue)
{
_clients.Push(item);
}
@@ -185,23 +193,15 @@ namespace Wino.Core.Integration
}
}
public void DestroyClient(ImapClient client)
private IImapClient CreateNewClient()
{
if (client == null) return;
client.Disconnect(true);
client.Dispose();
}
private ImapClient CreateNewClient()
{
ImapClient client = null;
WinoImapClient client = null;
// Make sure to create a ImapClient with a protocol logger if enabled.
client = _protocolLogStream != null
? new ImapClient(new ProtocolLogger(_protocolLogStream))
: new ImapClient();
? new WinoImapClient(new ProtocolLogger(_protocolLogStream))
: new WinoImapClient();
HttpProxyClient proxyClient = null;
@@ -213,7 +213,7 @@ namespace Wino.Core.Integration
client.ProxyClient = proxyClient;
_logger.Debug("Created new ImapClient. Current clients: {Count}", _clients.Count);
_logger.Debug("Creating new ImapClient. Current clients: {Count}", _clients.Count);
return client;
}
@@ -229,7 +229,7 @@ namespace Wino.Core.Integration
};
/// <returns>True if the connection is newly established.</returns>
public async Task<bool> EnsureConnectedAsync(ImapClient client)
public async Task<bool> EnsureConnectedAsync(IImapClient client)
{
if (client.IsConnected) return false;
@@ -263,8 +263,20 @@ namespace Wino.Core.Integration
{
if (_protocolLogStream == null) return;
var messageBytes = Encoding.UTF8.GetBytes($"W: {message}\n");
_protocolLogStream.Write(messageBytes, 0, messageBytes.Length);
try
{
var messageBytes = Encoding.UTF8.GetBytes($"W: {message}\n");
_protocolLogStream.Write(messageBytes, 0, messageBytes.Length);
}
catch (ObjectDisposedException)
{
Log.Warning($"Protocol log stream is disposed. Cannot write to it.");
}
catch (Exception)
{
throw;
}
}
bool MyServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
@@ -281,7 +293,7 @@ namespace Wino.Core.Integration
return true;
}
public async Task EnsureAuthenticatedAsync(ImapClient client)
public async Task EnsureAuthenticatedAsync(IImapClient client)
{
if (client.IsAuthenticated) return;
@@ -319,27 +331,38 @@ namespace Wino.Core.Integration
};
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_clients.ForEach(client =>
{
lock (client.SyncRoot)
{
client.Disconnect(true);
}
});
_clients.ForEach(client =>
{
client.Dispose();
});
_clients.Clear();
_protocolLogStream?.Dispose();
}
_disposedValue = true;
}
}
public void Dispose()
{
_clients.ForEach(client =>
{
lock (client.SyncRoot)
{
client.Disconnect(true);
}
});
_clients.ForEach(client =>
{
client.Dispose();
});
_clients.Clear();
if (_protocolLogStream != null)
{
_protocolLogStream.Dispose();
}
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@@ -0,0 +1,60 @@
using MailKit;
using MailKit.Net.Imap;
using Serilog;
namespace Wino.Core.Integration
{
/// <summary>
/// Extended class for ImapClient that is used in Wino.
/// </summary>
internal class WinoImapClient : ImapClient
{
/// <summary>
/// Gets or internally sets whether the QRESYNC extension is enabled.
/// It is set by ImapClientPool immidiately after the authentication.
/// </summary>
public bool IsQResyncEnabled { get; internal set; }
public WinoImapClient()
{
HookEvents();
}
public WinoImapClient(IProtocolLogger protocolLogger) : base(protocolLogger)
{
HookEvents();
}
private void HookEvents()
{
Disconnected += ClientDisconnected;
}
private void UnhookEvents()
{
Disconnected -= ClientDisconnected;
}
private void ClientDisconnected(object sender, DisconnectedEventArgs e)
{
if (e.IsRequested)
{
Log.Debug("Imap client is disconnected on request.");
}
else
{
Log.Debug("Imap client connection is dropped by server.");
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
UnhookEvents();
}
}
}
}

View File

@@ -1,9 +0,0 @@
using MailKit;
namespace Wino.Core.Mime
{
/// <summary>
/// Encapsulates all required information to create a MimeMessage for IMAP synchronizer.
/// </summary>
public record ImapMessageCreationPackage(IMessageSummary MessageSummary, IMailFolder MailFolder);
}

View File

@@ -7,10 +7,10 @@ namespace Wino.Core.Requests.Bundles
{
public class ImapRequest
{
public Func<ImapClient, IRequestBase, Task> IntegratorTask { get; }
public Func<IImapClient, IRequestBase, Task> IntegratorTask { get; }
public IRequestBase Request { get; }
public ImapRequest(Func<ImapClient, IRequestBase, Task> integratorTask, IRequestBase request)
public ImapRequest(Func<IImapClient, IRequestBase, Task> integratorTask, IRequestBase request)
{
IntegratorTask = integratorTask;
Request = request;
@@ -19,7 +19,7 @@ namespace Wino.Core.Requests.Bundles
public class ImapRequest<TRequestBaseType> : ImapRequest where TRequestBaseType : IRequestBase
{
public ImapRequest(Func<ImapClient, TRequestBaseType, Task> integratorTask, TRequestBaseType request)
public ImapRequest(Func<IImapClient, TRequestBaseType, Task> integratorTask, TRequestBaseType request)
: base((client, request) => integratorTask(client, (TRequestBaseType)request), request)
{
}

View File

@@ -11,6 +11,7 @@ namespace Wino.Core.Requests.Folder
{
public record EmptyFolderRequest(MailItemFolder Folder, List<MailCopy> MailsToDelete) : FolderRequestBase(Folder, FolderSynchronizerOperation.EmptyFolder), ICustomFolderSynchronizationRequest
{
public bool ExcludeMustHaveFolders => false;
public override void ApplyUIChanges()
{
foreach (var item in MailsToDelete)

View File

@@ -32,5 +32,7 @@ namespace Wino.Core.Requests.Folder
}
public List<Guid> SynchronizationFolderIds => [Folder.Id];
public bool ExcludeMustHaveFolders => true;
}
}

View File

@@ -21,6 +21,7 @@ namespace Wino.Core.Requests.Mail
public record ArchiveRequest(bool IsArchiving, MailCopy Item, MailItemFolder FromFolder, MailItemFolder ToFolder = null)
: MailRequestBase(Item), ICustomFolderSynchronizationRequest
{
public bool ExcludeMustHaveFolders => false;
public List<Guid> SynchronizationFolderIds
{
get

View File

@@ -14,6 +14,8 @@ namespace Wino.Core.Requests.Mail
{
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
public bool ExcludeMustHaveFolders => true;
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.ChangeFlag;
public override void ApplyUIChanges()

View File

@@ -13,6 +13,8 @@ namespace Wino.Core.Requests.Mail
: MailRequestBase(DraftPreperationRequest.CreatedLocalDraftCopy),
ICustomFolderSynchronizationRequest
{
public bool ExcludeMustHaveFolders => false;
public List<Guid> SynchronizationFolderIds =>
[
DraftPreperationRequest.CreatedLocalDraftCopy.AssignedFolder.Id

View File

@@ -17,7 +17,7 @@ namespace Wino.Core.Requests.Mail
ICustomFolderSynchronizationRequest
{
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
public bool ExcludeMustHaveFolders => false;
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Delete;
public override void ApplyUIChanges()

View File

@@ -15,6 +15,8 @@ namespace Wino.Core.Requests.Mail
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.MarkRead;
public bool ExcludeMustHaveFolders => true;
public override void ApplyUIChanges()
{
Item.IsRead = IsRead;

View File

@@ -13,7 +13,7 @@ namespace Wino.Core.Requests.Mail
: MailRequestBase(Item), ICustomFolderSynchronizationRequest
{
public List<Guid> SynchronizationFolderIds => new() { FromFolder.Id, ToFolder.Id };
public bool ExcludeMustHaveFolders => false;
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Move;
public override void ApplyUIChanges()

View File

@@ -28,6 +28,8 @@ namespace Wino.Core.Requests.Mail
}
}
public bool ExcludeMustHaveFolders => false;
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Send;
public override void ApplyUIChanges()

View File

@@ -28,7 +28,6 @@ namespace Wino.Core.Services
return providerType switch
{
MailProviderType.Outlook => new OutlookAuthenticator(_nativeAppService, _applicationConfiguration, _authenticatorConfig),
MailProviderType.Office365 => new Office365Authenticator(_nativeAppService, _applicationConfiguration, _authenticatorConfig),
MailProviderType.Gmail => new GmailAuthenticator(_authenticatorConfig),
_ => throw new ArgumentException(Translator.Exception_UnsupportedProvider),
};

View File

@@ -13,6 +13,7 @@ namespace Wino.Core.Services
private bool isInitialized = false;
private readonly IAccountService _accountService;
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
private readonly IApplicationConfiguration _applicationConfiguration;
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
private readonly IGmailChangeProcessor _gmailChangeProcessor;
@@ -28,6 +29,7 @@ namespace Wino.Core.Services
IOutlookAuthenticator outlookAuthenticator,
IGmailAuthenticator gmailAuthenticator,
IAccountService accountService,
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
IApplicationConfiguration applicationConfiguration)
{
_outlookChangeProcessor = outlookChangeProcessor;
@@ -36,6 +38,7 @@ namespace Wino.Core.Services
_outlookAuthenticator = outlookAuthenticator;
_gmailAuthenticator = gmailAuthenticator;
_accountService = accountService;
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
_applicationConfiguration = applicationConfiguration;
}
@@ -51,6 +54,7 @@ namespace Wino.Core.Services
{
synchronizer = CreateNewSynchronizer(account);
return await GetAccountSynchronizerAsync(accountId);
}
}
@@ -65,12 +69,11 @@ namespace Wino.Core.Services
switch (providerType)
{
case Domain.Enums.MailProviderType.Outlook:
case Domain.Enums.MailProviderType.Office365:
return new OutlookSynchronizer(mailAccount, _outlookAuthenticator, _outlookChangeProcessor);
case Domain.Enums.MailProviderType.Gmail:
return new GmailSynchronizer(mailAccount, _gmailAuthenticator, _gmailChangeProcessor);
case Domain.Enums.MailProviderType.IMAP4:
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration);
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _imapSynchronizationStrategyProvider, _applicationConfiguration);
default:
break;
}
@@ -82,6 +85,12 @@ namespace Wino.Core.Services
{
var synchronizer = CreateIntegratorWithDefaultProcessor(account);
if (synchronizer is IImapSynchronizer imapSynchronizer)
{
// Start the idle client for IMAP synchronizer.
_ = imapSynchronizer.StartIdleClientAsync();
}
synchronizerCache.Add(synchronizer);
return synchronizer;
@@ -100,5 +109,18 @@ namespace Wino.Core.Services
isInitialized = true;
}
public async Task DeleteSynchronizerAsync(Guid accountId)
{
var synchronizer = synchronizerCache.Find(a => a.Account.Id == accountId);
if (synchronizer != null)
{
// Stop the current synchronization.
await synchronizer.KillSynchronizerAsync();
synchronizerCache.Remove(synchronizer);
}
}
}
}

View File

@@ -23,7 +23,7 @@ namespace Wino.Core.Services
{
return mailProviderType switch
{
MailProviderType.Outlook or MailProviderType.Office365 => _outlookThreadingStrategy,
MailProviderType.Outlook => _outlookThreadingStrategy,
MailProviderType.Gmail => _gmailThreadingStrategy,
_ => _imapThreadStrategy,
};

View File

@@ -163,8 +163,7 @@ namespace Wino.Core.Services
MailItemFolder archiveFolder = null;
bool shouldRequireArchiveFolder = mailItem.AssignedAccount.ProviderType == MailProviderType.Outlook
|| mailItem.AssignedAccount.ProviderType == MailProviderType.IMAP4
|| mailItem.AssignedAccount.ProviderType == MailProviderType.Office365;
|| mailItem.AssignedAccount.ProviderType == MailProviderType.IMAP4;
if (shouldRequireArchiveFolder)
{

View File

@@ -51,9 +51,6 @@ namespace Wino.Core.Synchronizers
/// <param name="cancellationToken">Cancellation token</param>
public abstract Task ExecuteNativeRequestsAsync(List<IRequestBundle<TBaseRequest>> batchedRequests, CancellationToken cancellationToken = default);
// TODO: What if account is deleted during synchronization?
public bool CancelActiveSynchronization() => true;
/// <summary>
/// Refreshes remote mail account profile if possible.
/// Profile picture, sender name and mailbox settings (todo) will be handled in this step.

View File

@@ -1193,5 +1193,15 @@ namespace Wino.Core.Synchronizers.Mail
}
#endregion
public override async Task KillSynchronizerAsync()
{
await base.KillSynchronizerAsync();
_gmailService.Dispose();
_peopleService.Dispose();
_calendarService.Dispose();
_googleHttpClient.Dispose();
}
}
}

View File

@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration;
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
namespace Wino.Core.Synchronizers.ImapSync
{
/// <summary>
/// RFC 4551 CONDSTORE IMAP Synchronization strategy.
/// </summary>
internal class CondstoreSynchronizer : ImapSynchronizationStrategyBase
{
public CondstoreSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService)
{
}
public async override Task<List<string>> HandleSynchronizationAsync(IImapClient client,
MailItemFolder folder,
IImapSynchronizer synchronizer,
CancellationToken cancellationToken = default)
{
if (client is not WinoImapClient winoClient)
throw new ArgumentException("Client must be of type WinoImapClient.", nameof(client));
if (!client.Capabilities.HasFlag(ImapCapabilities.CondStore))
throw new ImapSynchronizerStrategyException("Server does not support CONDSTORE.");
IMailFolder remoteFolder = null;
var downloadedMessageIds = new List<string>();
try
{
remoteFolder = await winoClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
var localHighestModSeq = (ulong)folder.HighestModeSeq;
bool isInitialSynchronization = localHighestModSeq == 0;
// There are some changes on new messages or flag changes.
// Deletions are tracked separately because some servers do not increase
// the MODSEQ value for deleted messages.
if (remoteFolder.HighestModSeq > localHighestModSeq)
{
var changedUids = await GetChangedUidsAsync(client, remoteFolder, synchronizer, cancellationToken).ConfigureAwait(false);
// Get locally exists mails for the returned UIDs.
downloadedMessageIds = await HandleChangedUIdsAsync(synchronizer, remoteFolder, changedUids, cancellationToken).ConfigureAwait(false);
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
await FolderService.UpdateFolderAsync(folder).ConfigureAwait(false);
}
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
return downloadedMessageIds;
}
catch (FolderNotFoundException)
{
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
return default;
}
catch (Exception)
{
throw;
}
finally
{
if (!cancellationToken.IsCancellationRequested)
{
if (remoteFolder != null)
{
if (remoteFolder.IsOpen)
{
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
}
}
internal override async Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient winoClient, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
{
var localHighestModSeq = (ulong)Folder.HighestModeSeq;
var remoteHighestModSeq = remoteFolder.HighestModSeq;
// Search for emails with a MODSEQ greater than the last known value.
// Use SORT extension if server supports.
IList<UniqueId> changedUids = null;
if (winoClient.Capabilities.HasFlag(ImapCapabilities.Sort))
{
// Highest mod seq must be greater than 0 for SORT.
changedUids = await remoteFolder.SortAsync(SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)), [OrderBy.ReverseDate], cancellationToken).ConfigureAwait(false);
}
else
{
changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
}
changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
// For initial synchronizations, take the first allowed number of items.
// For consequtive synchronizations, take all the items. We don't want to miss any changes.
// Smaller uid means newer message. For initial sync, we need start taking items from the top.
bool isInitialSynchronization = localHighestModSeq == 0;
if (isInitialSynchronization)
{
changedUids = changedUids.OrderByDescending(a => a.Id).Take((int)synchronizer.InitialMessageDownloadCountPerFolder).ToList();
}
return changedUids;
}
}
}

View File

@@ -0,0 +1,185 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using MoreLinq;
using Serilog;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Services.Extensions;
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
namespace Wino.Core.Synchronizers.ImapSync
{
public abstract class ImapSynchronizationStrategyBase : IImapSynchronizerStrategy
{
// Minimum summary items to Fetch for mail synchronization from IMAP.
protected readonly MessageSummaryItems MailSynchronizationFlags =
MessageSummaryItems.Flags |
MessageSummaryItems.UniqueId |
MessageSummaryItems.ThreadId |
MessageSummaryItems.EmailId |
MessageSummaryItems.Headers |
MessageSummaryItems.PreviewText |
MessageSummaryItems.GMailThreadId |
MessageSummaryItems.References |
MessageSummaryItems.ModSeq;
protected IFolderService FolderService { get; }
protected IMailService MailService { get; }
protected MailItemFolder Folder { get; set; }
protected ImapSynchronizationStrategyBase(IFolderService folderService, IMailService mailService)
{
FolderService = folderService;
MailService = mailService;
}
public abstract Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
internal abstract Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
protected async Task<List<string>> HandleChangedUIdsAsync(IImapSynchronizer synchronizer, IMailFolder remoteFolder, IList<UniqueId> changedUids, CancellationToken cancellationToken)
{
List<string> downloadedMessageIds = new();
var existingMails = await MailService.GetExistingMailsAsync(Folder.Id, changedUids).ConfigureAwait(false);
var existingMailUids = existingMails.Select(m => MailkitClientExtensions.ResolveUidStruct(m.Id)).ToArray();
// These are the non-existing mails. They will be downloaded + processed.
var newMessageIds = changedUids.Except(existingMailUids).ToList();
var deletedMessageIds = existingMailUids.Except(changedUids).ToList();
// Fetch minimum data for the existing mails in one query.
var existingFlagData = await remoteFolder.FetchAsync(existingMailUids, MessageSummaryItems.Flags | MessageSummaryItems.UniqueId).ConfigureAwait(false);
foreach (var update in existingFlagData)
{
if (update.UniqueId == null)
{
Log.Warning($"Couldn't fetch UniqueId for the mail. FetchAsync failed.");
continue;
}
if (update.Flags == null)
{
Log.Warning($"Couldn't fetch flags for the mail with UID {update.UniqueId.Id}. FetchAsync failed.");
continue;
}
var existingMail = existingMails.FirstOrDefault(m => MailkitClientExtensions.ResolveUidStruct(m.Id).Id == update.UniqueId.Id);
if (existingMail == null)
{
Log.Warning($"Couldn't find the mail with UID {update.UniqueId.Id} in the local database. Flag update is ignored.");
continue;
}
await HandleMessageFlagsChangeAsync(existingMail, update.Flags.Value).ConfigureAwait(false);
}
// Fetch the new mails in batch.
var batchedMessageIds = newMessageIds.Batch(50);
foreach (var group in batchedMessageIds)
{
var summaries = await remoteFolder.FetchAsync(group, MailSynchronizationFlags, cancellationToken).ConfigureAwait(false);
foreach (var summary in summaries)
{
var mimeMessage = await remoteFolder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false);
var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage);
var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, Folder, cancellationToken).ConfigureAwait(false);
if (mailPackages != null)
{
foreach (var package in mailPackages)
{
// Local draft is mapped. We don't need to create a new mail copy.
if (package == null) continue;
bool isCreatedNew = await MailService.CreateMailAsync(Folder.MailAccountId, package).ConfigureAwait(false);
// This is upsert. We are not interested in updated mails.
if (isCreatedNew) downloadedMessageIds.Add(package.Copy.Id);
}
}
}
}
return downloadedMessageIds;
}
protected async Task HandleMessageFlagsChangeAsync(UniqueId? uniqueId, MessageFlags flags)
{
if (Folder == null) return;
if (uniqueId == null) return;
var localMailCopyId = MailkitClientExtensions.CreateUid(Folder.Id, uniqueId.Value.Id);
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
var isRead = MailkitClientExtensions.GetIsRead(flags);
await MailService.ChangeReadStatusAsync(localMailCopyId, isRead).ConfigureAwait(false);
await MailService.ChangeFlagStatusAsync(localMailCopyId, isFlagged).ConfigureAwait(false);
}
protected async Task HandleMessageFlagsChangeAsync(MailCopy mailCopy, MessageFlags flags)
{
if (mailCopy == null) return;
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
var isRead = MailkitClientExtensions.GetIsRead(flags);
if (isFlagged != mailCopy.IsFlagged)
{
await MailService.ChangeFlagStatusAsync(mailCopy.Id, isFlagged).ConfigureAwait(false);
}
if (isRead != mailCopy.IsRead)
{
await MailService.ChangeReadStatusAsync(mailCopy.Id, isRead).ConfigureAwait(false);
}
}
protected async Task HandleMessageDeletedAsync(IList<UniqueId> uniqueIds)
{
if (Folder == null) return;
if (uniqueIds == null || uniqueIds.Count == 0) return;
foreach (var uniqueId in uniqueIds)
{
if (uniqueId == null) continue;
var localMailCopyId = MailkitClientExtensions.CreateUid(Folder.Id, uniqueId.Id);
await MailService.DeleteMailAsync(Folder.MailAccountId, localMailCopyId).ConfigureAwait(false);
}
}
protected void OnMessagesVanished(object sender, MessagesVanishedEventArgs args)
=> HandleMessageDeletedAsync(args.UniqueIds).ConfigureAwait(false);
protected void OnMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs args)
=> HandleMessageFlagsChangeAsync(args.UniqueId, args.Flags).ConfigureAwait(false);
protected async Task ManageUUIdBasedDeletedMessagesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken = default)
{
var allUids = (await FolderService.GetKnownUidsForFolderAsync(localFolder.Id)).Select(a => new UniqueId(a)).ToList();
if (allUids.Count > 0)
{
var remoteAllUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken);
var deletedUids = allUids.Except(remoteAllUids).ToList();
await HandleMessageDeletedAsync(deletedUids).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,31 @@
using MailKit.Net.Imap;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration;
namespace Wino.Core.Synchronizers.ImapSync
{
internal class ImapSynchronizationStrategyProvider : IImapSynchronizationStrategyProvider
{
private readonly QResyncSynchronizer _qResyncSynchronizer;
private readonly CondstoreSynchronizer _condstoreSynchronizer;
private readonly UidBasedSynchronizer _uidBasedSynchronizer;
public ImapSynchronizationStrategyProvider(QResyncSynchronizer qResyncSynchronizer, CondstoreSynchronizer condstoreSynchronizer, UidBasedSynchronizer uidBasedSynchronizer)
{
_qResyncSynchronizer = qResyncSynchronizer;
_condstoreSynchronizer = condstoreSynchronizer;
_uidBasedSynchronizer = uidBasedSynchronizer;
}
public IImapSynchronizerStrategy GetSynchronizationStrategy(IImapClient client)
{
if (client is not WinoImapClient winoImapClient)
throw new System.ArgumentException("Client must be of type WinoImapClient.", nameof(client));
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync) && winoImapClient.IsQResyncEnabled) return _qResyncSynchronizer;
if (client.Capabilities.HasFlag(ImapCapabilities.CondStore)) return _condstoreSynchronizer;
return _uidBasedSynchronizer;
}
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration;
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
namespace Wino.Core.Synchronizers.ImapSync
{
/// <summary>
/// RFC 5162 QRESYNC IMAP Synchronization strategy.
/// </summary>
internal class QResyncSynchronizer : ImapSynchronizationStrategyBase
{
public QResyncSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService)
{
}
public override async Task<List<string>> HandleSynchronizationAsync(IImapClient client,
MailItemFolder folder,
IImapSynchronizer synchronizer,
CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
if (client is not WinoImapClient winoClient)
throw new ImapSynchronizerStrategyException("Client must be of type WinoImapClient.");
if (!client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
throw new ImapSynchronizerStrategyException("Server does not support QRESYNC.");
if (!winoClient.IsQResyncEnabled)
throw new ImapSynchronizerStrategyException("QRESYNC is not enabled for WinoImapClient.");
// Ready to implement QRESYNC synchronization.
IMailFolder remoteFolder = null;
Folder = folder;
try
{
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
// Check the Uid validity first.
// If they don't match, clear all the local data and perform full-resync.
bool isCacheValid = remoteFolder.UidValidity == folder.UidValidity;
if (!isCacheValid)
{
// TODO: Remove all local data.
}
// Perform QRESYNC synchronization.
var localHighestModSeq = (ulong)folder.HighestModeSeq;
remoteFolder.MessagesVanished += OnMessagesVanished;
remoteFolder.MessageFlagsChanged += OnMessageFlagsChanged;
var allUids = await FolderService.GetKnownUidsForFolderAsync(folder.Id);
var allUniqueIds = allUids.Select(a => new UniqueId(a)).ToList();
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, allUniqueIds).ConfigureAwait(false);
var changedUids = await GetChangedUidsAsync(client, remoteFolder, synchronizer, cancellationToken).ConfigureAwait(false);
downloadedMessageIds = await HandleChangedUIdsAsync(synchronizer, remoteFolder, changedUids, cancellationToken).ConfigureAwait(false);
// Update the local folder with the new highest mod-seq and validity.
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
folder.UidValidity = remoteFolder.UidValidity;
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
await FolderService.UpdateFolderAsync(folder).ConfigureAwait(false);
}
catch (FolderNotFoundException)
{
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
return default;
}
catch (Exception)
{
throw;
}
finally
{
if (!cancellationToken.IsCancellationRequested)
{
if (remoteFolder != null)
{
remoteFolder.MessagesVanished -= OnMessagesVanished;
remoteFolder.MessageFlagsChanged -= OnMessageFlagsChanged;
if (remoteFolder.IsOpen)
{
await remoteFolder.CloseAsync();
}
}
}
}
return downloadedMessageIds;
}
internal override async Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
{
var localHighestModSeq = (ulong)Folder.HighestModeSeq;
return await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration;
namespace Wino.Core.Synchronizers.ImapSync
{
/// <summary>
/// Uid based IMAP Synchronization strategy.
/// </summary>
internal class UidBasedSynchronizer : ImapSynchronizationStrategyBase
{
public UidBasedSynchronizer(IFolderService folderService, Domain.Interfaces.IMailService mailService) : base(folderService, mailService)
{
}
public override async Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
{
if (client is not WinoImapClient winoClient)
throw new ArgumentException("Client must be of type WinoImapClient.", nameof(client));
Folder = folder;
var downloadedMessageIds = new List<string>();
IMailFolder remoteFolder = null;
try
{
remoteFolder = await winoClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
// Fetch UIDs from the remote folder
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
remoteUids = remoteUids.OrderByDescending(a => a.Id).Take((int)synchronizer.InitialMessageDownloadCountPerFolder).ToList();
await HandleChangedUIdsAsync(synchronizer, remoteFolder, remoteUids, cancellationToken).ConfigureAwait(false);
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
}
catch (FolderNotFoundException)
{
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
return default;
}
catch (Exception)
{
throw;
}
finally
{
if (!cancellationToken.IsCancellationRequested)
{
if (remoteFolder != null)
{
if (remoteFolder.IsOpen)
{
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
}
return downloadedMessageIds;
}
internal override Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
@@ -7,8 +8,6 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using MimeKit;
using MoreLinq;
using Serilog;
using Wino.Core.Domain.Entities.Mail;
@@ -22,72 +21,55 @@ using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Extensions;
using Wino.Core.Integration;
using Wino.Core.Integration.Processors;
using Wino.Core.Mime;
using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail;
using Wino.Messaging.Server;
using Wino.Messaging.UI;
using Wino.Services.Extensions;
namespace Wino.Core.Synchronizers.Mail
{
public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreationPackage, object>
public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreationPackage, object>, IImapSynchronizer
{
private CancellationTokenSource idleDoneToken;
private CancellationTokenSource cancelInboxListeningToken = new CancellationTokenSource();
[Obsolete("N/A")]
public override uint BatchModificationSize => 1000;
public override uint InitialMessageDownloadCountPerFolder => 500;
private IMailFolder inboxFolder;
#region Idle Implementation
private CancellationTokenSource idleCancellationTokenSource;
private CancellationTokenSource idleDoneTokenSource;
#endregion
private readonly ILogger _logger = Log.ForContext<ImapSynchronizer>();
private readonly ImapClientPool _clientPool;
private readonly IImapChangeProcessor _imapChangeProcessor;
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
private readonly IApplicationConfiguration _applicationConfiguration;
// Minimum summary items to Fetch for mail synchronization from IMAP.
private readonly MessageSummaryItems mailSynchronizationFlags =
MessageSummaryItems.Flags |
MessageSummaryItems.UniqueId |
MessageSummaryItems.ThreadId |
MessageSummaryItems.EmailId |
MessageSummaryItems.Headers |
MessageSummaryItems.PreviewText |
MessageSummaryItems.GMailThreadId |
MessageSummaryItems.References |
MessageSummaryItems.ModSeq;
/// <summary>
/// Timer that keeps the <see cref="InboxClient"/> alive for the lifetime of the pool.
/// Sends NOOP command to the server periodically.
/// </summary>
private Timer _noOpTimer;
/// <summary>
/// ImapClient that keeps the Inbox folder opened all the time for listening notifications.
/// </summary>
private ImapClient _inboxIdleClient;
public override uint BatchModificationSize => 1000;
public override uint InitialMessageDownloadCountPerFolder => 250;
public ImapSynchronizer(MailAccount account,
IImapChangeProcessor imapChangeProcessor,
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
IApplicationConfiguration applicationConfiguration) : base(account)
{
// Create client pool with account protocol log.
_imapChangeProcessor = imapChangeProcessor;
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
_applicationConfiguration = applicationConfiguration;
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, CreateAccountProtocolLogFileStream());
var protocolLogStream = CreateAccountProtocolLogFileStream();
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
_clientPool = new ImapClientPool(poolOptions);
idleDoneToken = new CancellationTokenSource();
}
private Stream CreateAccountProtocolLogFileStream()
{
if (Account == null) throw new ArgumentNullException(nameof(Account));
var logFile = Path.Combine(_applicationConfiguration.ApplicationDataFolderPath, $"Protocol_{Account.Address}.log");
var logFile = Path.Combine(_applicationConfiguration.ApplicationDataFolderPath, $"Protocol_{Account.Address}_{Account.Id}.log");
// Each session should start a new log.
if (File.Exists(logFile)) File.Delete(logFile);
@@ -95,136 +77,10 @@ namespace Wino.Core.Synchronizers.Mail
return new FileStream(logFile, FileMode.CreateNew);
}
// TODO
// private async void NoOpTimerTriggered(object state) => await AwaitInboxIdleAsync();
private async Task AwaitInboxIdleAsync()
{
if (_inboxIdleClient == null)
{
_logger.Warning("InboxClient is null. Cannot send NOOP command.");
return;
}
await _clientPool.EnsureConnectedAsync(_inboxIdleClient);
await _clientPool.EnsureAuthenticatedAsync(_inboxIdleClient);
try
{
if (inboxFolder == null)
{
inboxFolder = _inboxIdleClient.Inbox;
await inboxFolder.OpenAsync(FolderAccess.ReadOnly, cancelInboxListeningToken.Token);
}
idleDoneToken = new CancellationTokenSource();
await _inboxIdleClient.IdleAsync(idleDoneToken.Token, cancelInboxListeningToken.Token);
}
finally
{
idleDoneToken.Dispose();
idleDoneToken = null;
}
}
private async Task StopInboxListeningAsync()
{
if (inboxFolder != null)
{
inboxFolder.CountChanged -= InboxFolderCountChanged;
inboxFolder.MessageExpunged -= InboxFolderMessageExpunged;
inboxFolder.MessageFlagsChanged -= InboxFolderMessageFlagsChanged;
}
if (_noOpTimer != null)
{
_noOpTimer.Dispose();
_noOpTimer = null;
}
if (idleDoneToken != null)
{
idleDoneToken.Cancel();
idleDoneToken.Dispose();
idleDoneToken = null;
}
if (_inboxIdleClient != null)
{
await _inboxIdleClient.DisconnectAsync(true);
_inboxIdleClient.Dispose();
_inboxIdleClient = null;
}
}
/// <summary>
/// Tries to connect & authenticate with the given credentials.
/// Prepares synchronizer for active listening of Inbox folder.
/// </summary>
public async Task StartInboxListeningAsync()
{
_inboxIdleClient = await _clientPool.GetClientAsync();
// Run it every 8 minutes after 1 minute delay.
// _noOpTimer = new Timer(NoOpTimerTriggered, null, 60000, 8 * 60 * 1000);
await _clientPool.EnsureConnectedAsync(_inboxIdleClient);
await _clientPool.EnsureAuthenticatedAsync(_inboxIdleClient);
if (!_inboxIdleClient.Capabilities.HasFlag(ImapCapabilities.Idle))
{
_logger.Information("Imap server does not support IDLE command. Listening live changes is not supported for {Name}", Account.Name);
return;
}
inboxFolder = _inboxIdleClient.Inbox;
if (inboxFolder == null)
{
_logger.Information("Inbox folder is null. Cannot listen for changes.");
return;
}
inboxFolder.CountChanged += InboxFolderCountChanged;
inboxFolder.MessageExpunged += InboxFolderMessageExpunged;
inboxFolder.MessageFlagsChanged += InboxFolderMessageFlagsChanged;
while (!cancelInboxListeningToken.IsCancellationRequested)
{
await AwaitInboxIdleAsync();
}
await StopInboxListeningAsync();
}
private void InboxFolderMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs e)
{
Console.WriteLine("Flags have changed for message #{0} ({1}).", e.Index, e.Flags);
}
private void InboxFolderMessageExpunged(object sender, MessageEventArgs e)
{
_logger.Information("Inbox folder message expunged");
}
private void InboxFolderCountChanged(object sender, EventArgs e)
{
_logger.Information("Inbox folder count changed.");
}
/// <summary>
/// Parses List of string of mail copy ids and return valid uIds.
/// Follow the rules for creating arbitrary unique id for mail copies.
/// </summary>
private UniqueIdSet GetUniqueIds(IEnumerable<string> mailCopyIds)
=> new(mailCopyIds.Select(a => new UniqueId(MailkitClientExtensions.ResolveUid(a))));
/// <summary>
/// Returns UniqueId for the given mail copy id.
/// </summary>
private UniqueId GetUniqueId(string mailCopyId)
=> new(MailkitClientExtensions.ResolveUid(mailCopyId));
private UniqueId GetUniqueId(string mailCopyId) => new(MailkitClientExtensions.ResolveUid(mailCopyId));
#region Mail Integrations
@@ -319,8 +175,6 @@ namespace Wino.Core.Synchronizers.Mail
var singleRequest = request.Request;
singleRequest.Mime.Prepare(EncodingConstraint.None);
using var smtpClient = new MailKit.Net.Smtp.SmtpClient();
if (smtpClient.IsConnected && client.IsAuthenticated) return;
@@ -400,22 +254,19 @@ namespace Wino.Core.Synchronizers.Mail
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
var imapFolder = message.MailFolder;
var summary = message.MessageSummary;
var mimeMessage = await imapFolder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false);
var mailCopy = summary.GetMailDetails(assignedFolder, mimeMessage);
var mailCopy = message.MessageSummary.GetMailDetails(assignedFolder, message.MimeMessage);
// Draft folder message updates must be updated as IsDraft.
// I couldn't find it in MimeMessage...
// I couldn't find it in MimeMesssage...
mailCopy.IsDraft = assignedFolder.SpecialFolderType == SpecialFolderType.Draft;
// Check draft mapping.
// This is the same implementation as in the OutlookSynchronizer.
if (mimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader)
&& Guid.TryParse(mimeMessage.Headers[Domain.Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId))
if (message.MimeMessage != null &&
message.MimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader) &&
Guid.TryParse(message.MimeMessage.Headers[Domain.Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId))
{
// 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.
@@ -427,7 +278,7 @@ namespace Wino.Core.Synchronizers.Mail
// Local copy doesn't exists. Continue execution to insert mail copy.
}
var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId);
var package = new NewMailItemPackage(mailCopy, message.MimeMessage, assignedFolder.RemoteFolderId);
return
[
@@ -463,7 +314,13 @@ namespace Wino.Core.Synchronizers.Mail
PublishSynchronizationProgress(progress);
var folderDownloadedMessageIds = await SynchronizeFolderInternalAsync(folder, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
if (cancellationToken.IsCancellationRequested) return MailSynchronizationResult.Canceled;
if (folderDownloadedMessageIds != null)
{
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
}
}
}
@@ -497,7 +354,7 @@ namespace Wino.Core.Synchronizers.Mail
// At this point this client is ready to execute async commands.
// Each task bundle will await and execution will continue in case of error.
ImapClient executorClient = null;
IImapClient executorClient = null;
bool isCrashed = false;
@@ -550,7 +407,7 @@ namespace Wino.Core.Synchronizers.Mail
/// <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(ImapClient executorClient, IMailFolder remoteFolder, MailItemFolder localFolder)
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)
@@ -592,7 +449,7 @@ namespace Wino.Core.Synchronizers.Mail
var localFolders = await _imapChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
ImapClient executorClient = null;
IImapClient executorClient = null;
try
{
@@ -674,6 +531,11 @@ namespace Wino.Core.Synchronizers.Mail
if (remoteFolder.IsNamespace && !remoteFolder.Attributes.HasFlag(FolderAttributes.Inbox) || !remoteFolder.Exists)
continue;
// Check for NoSelect folders. These are not selectable folders.
// TODO: With new MailKit version 'CanOpen' will be implemented for ease of use. Use that one.
if (remoteFolder.Attributes.HasFlag(FolderAttributes.NoSelect))
continue;
var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.FullName);
if (existingLocalFolder == null)
@@ -768,264 +630,42 @@ namespace Wino.Core.Synchronizers.Mail
}
}
private async Task<IEnumerable<string>> SynchronizeFolderInternalAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
{
if (!folder.IsSynchronizationEnabled) return default;
var downloadedMessageIds = new List<string>();
// STEP1: Ask for flag changes for older mails.
// STEP2: Get new mail changes.
// https://www.rfc-editor.org/rfc/rfc4549 - Section 4.3
var _synchronizationClient = await _clientPool.GetClientAsync();
IMailFolder imapFolder = null;
var knownMailIds = new UniqueIdSet();
var locallyKnownMailUids = await _imapChangeProcessor.GetKnownUidsForFolderAsync(folder.Id);
knownMailIds.AddRange(locallyKnownMailUids.Select(a => new UniqueId(a)));
var highestUniqueId = Math.Max(0, locallyKnownMailUids.Count == 0 ? 0 : locallyKnownMailUids.Max());
var missingMailIds = new UniqueIdSet();
var uidValidity = folder.UidValidity;
var highestModeSeq = folder.HighestModeSeq;
var logger = Log.ForContext("FolderName", folder.FolderName);
logger.Verbose("HighestModeSeq: {HighestModeSeq}, HighestUniqueId: {HighestUniqueId}, UIDValidity: {UIDValidity}", highestModeSeq, highestUniqueId, uidValidity);
// Event handlers are placed here to handle existing MailItemFolder and IIMailFolder from MailKit.
// MailKit doesn't expose folder data when these events are emitted.
// Use local folder's UidValidty because cache might've been expired for remote IMAP folder.
// That will make our mail copy id invalid.
EventHandler<MessagesVanishedEventArgs> MessageVanishedHandler = async (s, e) =>
{
if (imapFolder == null) return;
foreach (var uniqueId in e.UniqueIds)
{
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, uniqueId.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, localMailCopyId);
}
};
EventHandler<MessageFlagsChangedEventArgs> MessageFlagsChangedHandler = async (s, e) =>
{
if (imapFolder == null) return;
if (e.UniqueId == null) return;
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, e.UniqueId.Value.Id);
var isFlagged = MailkitClientExtensions.GetIsFlagged(e.Flags);
var isRead = MailkitClientExtensions.GetIsRead(e.Flags);
await _imapChangeProcessor.ChangeMailReadStatusAsync(localMailCopyId, isRead);
await _imapChangeProcessor.ChangeFlagStatusAsync(localMailCopyId, isFlagged);
};
EventHandler<MessageEventArgs> MessageExpungedHandler = async (s, e) =>
{
if (imapFolder == null) return;
if (e.UniqueId == null) return;
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, e.UniqueId.Value.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, localMailCopyId);
};
IImapClient availableClient = null;
retry:
try
{
imapFolder = await _synchronizationClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken);
imapFolder.MessageFlagsChanged += MessageFlagsChangedHandler;
availableClient = await _clientPool.GetClientAsync().ConfigureAwait(false);
// TODO: Bug: Enabling quick re-sync actually doesn't enable it.
var qsyncEnabled = false; // _synchronizationClient.Capabilities.HasFlag(ImapCapabilities.QuickResync);
var condStoreEnabled = _synchronizationClient.Capabilities.HasFlag(ImapCapabilities.CondStore);
if (qsyncEnabled)
{
imapFolder.MessagesVanished += MessageVanishedHandler;
await imapFolder.OpenAsync(FolderAccess.ReadWrite, uidValidity, (ulong)highestModeSeq, knownMailIds, cancellationToken);
// Check the folder validity.
// We'll delete our existing cache if it's not.
// Get all messages after the last successful synchronization date.
// This is fine for Wino synchronization because we're not really looking to
// synchronize all folder.
var allMessageIds = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken);
if (uidValidity != imapFolder.UidValidity)
{
// TODO: Cache is invalid. Delete all local cache.
//await ChangeProcessor.FolderService.ClearImapFolderCacheAsync(folder.Id);
folder.UidValidity = imapFolder.UidValidity;
missingMailIds.AddRange(allMessageIds);
}
else
{
// Cache is valid.
// Add missing mails only.
missingMailIds.AddRange(allMessageIds.Except(knownMailIds).Where(a => a.Id > highestUniqueId));
}
}
else
{
// QSYNC extension is not enabled for the server.
// We rely on ConditionalStore.
imapFolder.MessageExpunged += MessageExpungedHandler;
await imapFolder.OpenAsync(FolderAccess.ReadWrite, cancellationToken);
// Get all messages after the last succesful synchronization date.
// This is fine for Wino synchronization because we're not really looking to
// synchronize all folder.
var allMessageIds = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken);
if (uidValidity != imapFolder.UidValidity)
{
// TODO: Cache is invalid. Delete all local cache.
// await ChangeProcessor.FolderService.ClearImapFolderCacheAsync(folder.Id);
folder.UidValidity = imapFolder.UidValidity;
missingMailIds.AddRange(allMessageIds);
}
else
{
// Cache is valid.
var purgedMessages = knownMailIds.Except(allMessageIds);
foreach (var purgedMessage in purgedMessages)
{
var mailId = MailkitClientExtensions.CreateUid(folder.Id, purgedMessage.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, mailId);
}
IList<IMessageSummary> changed;
if (knownMailIds.Count > 0)
{
// CONDSTORE enabled. Fetch items with highest mode seq for known items
// to track flag changes. Otherwise just get changes without the mode seq.
if (condStoreEnabled)
changed = await imapFolder.FetchAsync(knownMailIds, (ulong)highestModeSeq, MessageSummaryItems.Flags | MessageSummaryItems.ModSeq | MessageSummaryItems.UniqueId);
else
changed = await imapFolder.FetchAsync(knownMailIds, MessageSummaryItems.Flags | MessageSummaryItems.UniqueId);
foreach (var changedItem in changed)
{
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, changedItem.UniqueId.Id);
var isFlagged = changedItem.Flags.GetIsFlagged();
var isRead = changedItem.Flags.GetIsRead();
await _imapChangeProcessor.ChangeMailReadStatusAsync(localMailCopyId, isRead);
await _imapChangeProcessor.ChangeFlagStatusAsync(localMailCopyId, isFlagged);
}
}
// We're only interested in items that has highier known uid than we fetched before.
// Others are just older messages.
missingMailIds.AddRange(allMessageIds.Except(knownMailIds).Where(a => a.Id > highestUniqueId));
}
}
// Fetch completely missing new items in the end.
// Limit check.
if (missingMailIds.Count > InitialMessageDownloadCountPerFolder)
{
missingMailIds = new UniqueIdSet(missingMailIds.TakeLast((int)InitialMessageDownloadCountPerFolder));
}
// In case of the high input, we'll batch them by 50 to reflect changes quickly.
var batchedMissingMailIds = missingMailIds.Batch(50).Select(a => new UniqueIdSet(a, SortOrder.Ascending));
foreach (var batchMissingMailIds in batchedMissingMailIds)
{
var summaries = await imapFolder.FetchAsync(batchMissingMailIds, mailSynchronizationFlags, cancellationToken).ConfigureAwait(false);
foreach (var summary in summaries)
{
// We pass the opened folder and summary to retrieve raw MimeMessage.
var creationPackage = new ImapMessageCreationPackage(summary, imapFolder);
var createdMailPackages = await CreateNewMailPackagesAsync(creationPackage, folder, cancellationToken).ConfigureAwait(false);
// Local draft is mapped. We don't need to create a new mail copy.
if (createdMailPackages == null)
continue;
foreach (var mailPackage in createdMailPackages)
{
bool isCreated = await _imapChangeProcessor.CreateMailAsync(Account.Id, mailPackage).ConfigureAwait(false);
if (isCreated)
{
downloadedMessageIds.Add(mailPackage.Copy.Id);
}
}
}
}
if (folder.HighestModeSeq != (long)imapFolder.HighestModSeq)
{
folder.HighestModeSeq = (long)imapFolder.HighestModSeq;
await _imapChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
}
// Update last synchronization date for the folder..
await _imapChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
return downloadedMessageIds;
var strategy = _imapSynchronizationStrategyProvider.GetSynchronizationStrategy(availableClient);
return await strategy.HandleSynchronizationAsync(availableClient, folder, this, cancellationToken).ConfigureAwait(false);
}
catch (FolderNotFoundException)
catch (IOException)
{
await _imapChangeProcessor.DeleteFolderAsync(Account.Id, folder.RemoteFolderId).ConfigureAwait(false);
_clientPool.Release(availableClient, false);
return default;
goto retry;
}
catch (OperationCanceledException)
{
// Ignore cancellations.
}
catch (Exception)
{
throw;
}
finally
{
if (imapFolder != null)
{
imapFolder.MessageFlagsChanged -= MessageFlagsChangedHandler;
imapFolder.MessageExpunged -= MessageExpungedHandler;
imapFolder.MessagesVanished -= MessageVanishedHandler;
if (imapFolder.IsOpen)
await imapFolder.CloseAsync();
}
_clientPool.Release(_synchronizationClient);
_clientPool.Release(availableClient, false);
}
}
return new List<string>();
}
/// <summary>
/// Whether the local folder should be updated with the remote folder.
@@ -1037,8 +677,141 @@ namespace Wino.Core.Synchronizers.Mail
=> !localFolder.FolderName.Equals(remoteFolder.Name, StringComparison.OrdinalIgnoreCase);
protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public async Task StartIdleClientAsync()
{
throw new NotImplementedException();
IImapClient idleClient = null;
IMailFolder inboxFolder = null;
bool? reconnect = null;
try
{
var client = await _clientPool.GetClientAsync().ConfigureAwait(false);
if (!client.Capabilities.HasFlag(ImapCapabilities.Idle))
{
Log.Debug($"{Account.Name} does not support Idle command. Ignored.");
return;
}
if (client.Inbox == null)
{
Log.Warning($"{Account.Name} does not have an Inbox folder for idle client to track. Ignored.");
return;
}
// Setup idle client.
idleClient = client;
idleDoneTokenSource ??= new CancellationTokenSource();
idleCancellationTokenSource ??= new CancellationTokenSource();
inboxFolder = client.Inbox;
await inboxFolder.OpenAsync(FolderAccess.ReadOnly, idleCancellationTokenSource.Token);
inboxFolder.CountChanged += IdleNotificationTriggered;
inboxFolder.MessageFlagsChanged += IdleNotificationTriggered;
inboxFolder.MessageExpunged += IdleNotificationTriggered;
inboxFolder.MessagesVanished += IdleNotificationTriggered;
Log.Debug("Starting an idle client for {Name}", Account.Name);
await client.IdleAsync(idleDoneTokenSource.Token, idleCancellationTokenSource.Token);
}
catch (ImapProtocolException protocolException)
{
Log.Warning(protocolException, "Idle client received protocol exception.");
reconnect = true;
}
catch (IOException ioException)
{
Log.Warning(ioException, "Idle client received IO exception.");
reconnect = true;
}
catch (OperationCanceledException)
{
reconnect = !IsDisposing;
}
catch (Exception ex)
{
Log.Warning(ex, "Idle client failed to start.");
reconnect = false;
}
finally
{
if (inboxFolder != null)
{
inboxFolder.CountChanged -= IdleNotificationTriggered;
inboxFolder.MessageFlagsChanged -= IdleNotificationTriggered;
inboxFolder.MessageExpunged -= IdleNotificationTriggered;
inboxFolder.MessagesVanished -= IdleNotificationTriggered;
}
if (idleDoneTokenSource != null)
{
idleDoneTokenSource.Dispose();
idleDoneTokenSource = null;
}
if (idleClient != null)
{
// Killing the client is not necessary. We can re-use it later.
_clientPool.Release(idleClient, destroyClient: false);
idleClient = null;
}
if (reconnect == true)
{
Log.Information("Idle client is reconnecting.");
_ = StartIdleClientAsync();
}
else if (reconnect == false)
{
Log.Information("Finalized idle client.");
}
}
}
private void RequestIdleChangeSynchronization()
{
Debug.WriteLine("Detected idle change.");
// We don't really need to act on the count change in detail.
// Our synchronization should be enough to handle the changes with on-demand sync.
// We can just trigger a sync here IMAPIdle type.
var options = new MailSynchronizationOptions()
{
AccountId = Account.Id,
Type = MailSynchronizationType.IMAPIdle
};
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options, SynchronizationSource.Client));
}
private void IdleNotificationTriggered(object sender, EventArgs e)
=> RequestIdleChangeSynchronization();
public Task StopIdleClientAsync()
{
idleDoneTokenSource?.Cancel();
idleCancellationTokenSource?.Cancel();
return Task.CompletedTask;
}
public override async Task KillSynchronizerAsync()
{
await base.KillSynchronizerAsync();
await StopIdleClientAsync();
// Make sure the client pool safely disconnects all ImapClients.
_clientPool.Dispose();
}
}
}

View File

@@ -1165,5 +1165,12 @@ namespace Wino.Core.Synchronizers.Mail
return !localCalendarName.Equals(remoteCalendarName, StringComparison.OrdinalIgnoreCase);
}
public override async Task KillSynchronizerAsync()
{
await base.KillSynchronizerAsync();
_graphClient.Dispose();
}
}
}

View File

@@ -25,11 +25,13 @@ namespace Wino.Core.Synchronizers
{
public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEventType> : BaseSynchronizer<TBaseRequest>, IWinoSynchronizerBase
{
protected bool IsDisposing { get; private set; }
protected Dictionary<MailSynchronizationOptions, CancellationTokenSource> PendingSynchronizationRequest = new();
protected ILogger Logger = Log.ForContext<WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEventType>>();
protected WinoSynchronizer(MailAccount account) : base(account)
{
}
protected WinoSynchronizer(MailAccount account) : base(account) { }
/// <summary>
/// How many items per single HTTP call can be modified.
@@ -83,94 +85,129 @@ namespace Wino.Core.Synchronizers
{
try
{
activeSynchronizationCancellationToken = cancellationToken;
State = AccountSynchronizerState.ExecutingRequests;
List<IRequestBundle<TBaseRequest>> nativeRequests = new();
List<IRequestBase> requestCopies = new(changeRequestQueue);
var keys = changeRequestQueue.GroupBy(a => a.GroupingKey());
foreach (var group in keys)
if (!ShouldQueueMailSynchronization(options))
{
var key = group.Key;
if (key is MailSynchronizerOperation mailSynchronizerOperation)
{
switch (mailSynchronizerOperation)
{
case MailSynchronizerOperation.MarkRead:
nativeRequests.AddRange(MarkRead(new BatchMarkReadRequest(group.Cast<MarkReadRequest>())));
break;
case MailSynchronizerOperation.Move:
nativeRequests.AddRange(Move(new BatchMoveRequest(group.Cast<MoveRequest>())));
break;
case MailSynchronizerOperation.Delete:
nativeRequests.AddRange(Delete(new BatchDeleteRequest(group.Cast<DeleteRequest>())));
break;
case MailSynchronizerOperation.CreateDraft:
nativeRequests.AddRange(CreateDraft(group.ElementAt(0) as CreateDraftRequest));
break;
case MailSynchronizerOperation.Send:
nativeRequests.AddRange(SendDraft(group.ElementAt(0) as SendDraftRequest));
break;
case MailSynchronizerOperation.ChangeFlag:
nativeRequests.AddRange(ChangeFlag(new BatchChangeFlagRequest(group.Cast<ChangeFlagRequest>())));
break;
case MailSynchronizerOperation.AlwaysMoveTo:
nativeRequests.AddRange(AlwaysMoveTo(new BatchAlwaysMoveToRequest(group.Cast<AlwaysMoveToRequest>())));
break;
case MailSynchronizerOperation.MoveToFocused:
nativeRequests.AddRange(MoveToFocused(new BatchMoveToFocusedRequest(group.Cast<MoveToFocusedRequest>())));
break;
case MailSynchronizerOperation.Archive:
nativeRequests.AddRange(Archive(new BatchArchiveRequest(group.Cast<ArchiveRequest>())));
break;
default:
break;
}
}
else if (key is FolderSynchronizerOperation folderSynchronizerOperation)
{
switch (folderSynchronizerOperation)
{
case FolderSynchronizerOperation.RenameFolder:
nativeRequests.AddRange(RenameFolder(group.ElementAt(0) as RenameFolderRequest));
break;
case FolderSynchronizerOperation.EmptyFolder:
nativeRequests.AddRange(EmptyFolder(group.ElementAt(0) as EmptyFolderRequest));
break;
case FolderSynchronizerOperation.MarkFolderRead:
nativeRequests.AddRange(MarkFolderAsRead(group.ElementAt(0) as MarkFolderAsReadRequest));
break;
default:
break;
}
}
Log.Debug($"{options.Type} synchronization is ignored.");
return MailSynchronizationResult.Canceled;
}
changeRequestQueue.Clear();
var newCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Console.WriteLine($"Prepared {nativeRequests.Count()} native requests");
PendingSynchronizationRequest.Add(options, newCancellationTokenSource);
activeSynchronizationCancellationToken = newCancellationTokenSource.Token;
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
PublishSynchronizationProgress(1);
await ExecuteNativeRequestsAsync(nativeRequests, activeSynchronizationCancellationToken);
// ImapSynchronizer will send this type when an Idle client receives a notification of changes.
// We should not execute requests in this case.
bool shouldExecuteRequests = options.Type != MailSynchronizationType.IMAPIdle;
PublishUnreadItemChanges();
bool shouldDelayExecution = false;
int maxExecutionDelay = 0;
// Execute request sync options should be re-calculated after execution.
// This is the part we decide which individual folders must be synchronized
// after the batch request execution.
if (options.Type == MailSynchronizationType.ExecuteRequests)
options = GetSynchronizationOptionsAfterRequestExecution(requestCopies);
if (shouldExecuteRequests && changeRequestQueue.Any())
{
State = AccountSynchronizerState.ExecutingRequests;
List<IRequestBundle<TBaseRequest>> nativeRequests = new();
List<IRequestBase> requestCopies = new(changeRequestQueue);
var keys = changeRequestQueue.GroupBy(a => a.GroupingKey());
foreach (var group in keys)
{
var key = group.Key;
if (key is MailSynchronizerOperation mailSynchronizerOperation)
{
switch (mailSynchronizerOperation)
{
case MailSynchronizerOperation.MarkRead:
nativeRequests.AddRange(MarkRead(new BatchMarkReadRequest(group.Cast<MarkReadRequest>())));
break;
case MailSynchronizerOperation.Move:
nativeRequests.AddRange(Move(new BatchMoveRequest(group.Cast<MoveRequest>())));
break;
case MailSynchronizerOperation.Delete:
nativeRequests.AddRange(Delete(new BatchDeleteRequest(group.Cast<DeleteRequest>())));
break;
case MailSynchronizerOperation.CreateDraft:
nativeRequests.AddRange(CreateDraft(group.ElementAt(0) as CreateDraftRequest));
break;
case MailSynchronizerOperation.Send:
nativeRequests.AddRange(SendDraft(group.ElementAt(0) as SendDraftRequest));
break;
case MailSynchronizerOperation.ChangeFlag:
nativeRequests.AddRange(ChangeFlag(new BatchChangeFlagRequest(group.Cast<ChangeFlagRequest>())));
break;
case MailSynchronizerOperation.AlwaysMoveTo:
nativeRequests.AddRange(AlwaysMoveTo(new BatchAlwaysMoveToRequest(group.Cast<AlwaysMoveToRequest>())));
break;
case MailSynchronizerOperation.MoveToFocused:
nativeRequests.AddRange(MoveToFocused(new BatchMoveToFocusedRequest(group.Cast<MoveToFocusedRequest>())));
break;
case MailSynchronizerOperation.Archive:
nativeRequests.AddRange(Archive(new BatchArchiveRequest(group.Cast<ArchiveRequest>())));
break;
default:
break;
}
}
else if (key is FolderSynchronizerOperation folderSynchronizerOperation)
{
switch (folderSynchronizerOperation)
{
case FolderSynchronizerOperation.RenameFolder:
nativeRequests.AddRange(RenameFolder(group.ElementAt(0) as RenameFolderRequest));
break;
case FolderSynchronizerOperation.EmptyFolder:
nativeRequests.AddRange(EmptyFolder(group.ElementAt(0) as EmptyFolderRequest));
break;
case FolderSynchronizerOperation.MarkFolderRead:
nativeRequests.AddRange(MarkFolderAsRead(group.ElementAt(0) as MarkFolderAsReadRequest));
break;
default:
break;
}
}
}
changeRequestQueue.Clear();
Console.WriteLine($"Prepared {nativeRequests.Count()} native requests");
await ExecuteNativeRequestsAsync(nativeRequests, activeSynchronizationCancellationToken).ConfigureAwait(false);
PublishUnreadItemChanges();
// Execute request sync options should be re-calculated after execution.
// This is the part we decide which individual folders must be synchronized
// after the batch request execution.
if (options.Type == MailSynchronizationType.ExecuteRequests)
options = GetSynchronizationOptionsAfterRequestExecution(requestCopies, options.Id);
// Let servers to finish their job. Sometimes the servers doesn't respond immediately.
// Bug: if Outlook can't create the message in Sent Items folder before this delay,
// message will not appear in user's inbox since it's not in the Sent Items folder.
shouldDelayExecution =
(Account.ProviderType == MailProviderType.Outlook)
&& requestCopies.Any(a => a.ResynchronizationDelay > 0);
if (shouldDelayExecution)
{
maxExecutionDelay = requestCopies.Aggregate(0, (max, next) => Math.Max(max, next.ResynchronizationDelay));
}
// In terms of flag/read changes, there is no point of synchronizing must have folders.
options.ExcludeMustHaveFolders = requestCopies.All(a => a is ICustomFolderSynchronizationRequest request && request.ExcludeMustHaveFolders);
}
State = AccountSynchronizerState.Synchronizing;
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
// Handle special synchronization types.
// Profile information sync.
@@ -213,19 +250,9 @@ namespace Wino.Core.Synchronizers
}
}
// Let servers to finish their job. Sometimes the servers doesn't respond immediately.
// Bug: if Outlook can't create the message in Sent Items folder before this delay,
// message will not appear in user's inbox since it's not in the Sent Items folder.
bool shouldDelayExecution =
(Account.ProviderType == MailProviderType.Outlook || Account.ProviderType == MailProviderType.Office365)
&& requestCopies.Any(a => a.ResynchronizationDelay > 0);
if (shouldDelayExecution)
{
var maxDelay = requestCopies.Aggregate(0, (max, next) => Math.Max(max, next.ResynchronizationDelay));
await Task.Delay(maxDelay);
await Task.Delay(maxExecutionDelay);
}
// Start the internal synchronization.
@@ -249,6 +276,15 @@ namespace Wino.Core.Synchronizers
}
finally
{
// Find the request and remove it from the pending list.
var pendingRequest = PendingSynchronizationRequest.FirstOrDefault(a => a.Key.Id == options.Id);
if (pendingRequest.Key != null)
{
PendingSynchronizationRequest.Remove(pendingRequest.Key);
}
// Reset account progress to hide the progress.
PublishSynchronizationProgress(0);
@@ -288,7 +324,7 @@ namespace Wino.Core.Synchronizers
/// </summary>
/// <param name="batches">Batch requests to run in synchronization.</param>
/// <returns>New synchronization options with minimal HTTP effort.</returns>
private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List<IRequestBase> requests)
private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List<IRequestBase> requests, Guid existingSynchronizationId)
{
List<Guid> synchronizationFolderIds = requests
.Where(a => a is ICustomFolderSynchronizationRequest)
@@ -301,6 +337,8 @@ namespace Wino.Core.Synchronizers
AccountId = Account.Id,
};
options.Id = existingSynchronizationId;
if (synchronizationFolderIds.Count > 0)
{
// Gather FolderIds to synchronize.
@@ -317,6 +355,35 @@ namespace Wino.Core.Synchronizers
return options;
}
/// <summary>
/// Checks if the mail synchronization should be queued or not.
/// </summary>
/// <param name="options">New mail sync request.</param>
/// <returns>Whether sync should be queued or not.</returns>
private bool ShouldQueueMailSynchronization(MailSynchronizationOptions options)
{
// Multiple IMAPIdle requests are ignored.
if (options.Type == MailSynchronizationType.IMAPIdle &&
PendingSynchronizationRequest.Any(a => a.Key.Type == MailSynchronizationType.IMAPIdle))
{
return false;
}
// Executing requests may trigger idle sync.
// If there are pending execute requests cancel idle change.
// TODO: Ideally this check should only work for Inbox execute requests.
// Check if request folders contains Inbox.
if (options.Type == MailSynchronizationType.IMAPIdle &&
PendingSynchronizationRequest.Any(a => a.Key.Type == MailSynchronizationType.ExecuteRequests))
{
return false;
}
return true;
}
#region Mail/Folder Operations
public virtual bool DelaySendOperationSynchronization() => false;
@@ -349,12 +416,12 @@ namespace Wino.Core.Synchronizers
/// <param name="cancellationToken">Cancellation token.</param>
public virtual Task DownloadMissingMimeMessageAsync(IMailItem mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public List<IRequestBundle<ImapRequest>> CreateSingleTaskBundle(Func<ImapClient, IRequestBase, Task> action, IRequestBase request, IUIChangeRequest uIChangeRequest)
public List<IRequestBundle<ImapRequest>> CreateSingleTaskBundle(Func<IImapClient, IRequestBase, Task> action, IRequestBase request, IUIChangeRequest uIChangeRequest)
{
return [new ImapRequestBundle(new ImapRequest(action, request), request, uIChangeRequest)];
}
public List<IRequestBundle<ImapRequest>> CreateTaskBundle<TSingeRequestType>(Func<ImapClient, TSingeRequestType, Task> value,
public List<IRequestBundle<ImapRequest>> CreateTaskBundle<TSingeRequestType>(Func<IImapClient, TSingeRequestType, Task> value,
List<TSingeRequestType> requests)
where TSingeRequestType : IRequestBase, IUIChangeRequest
{
@@ -368,6 +435,21 @@ namespace Wino.Core.Synchronizers
return ret;
}
public virtual Task KillSynchronizerAsync()
{
IsDisposing = true;
CancelAllSynchronizations();
return Task.CompletedTask;
}
protected void CancelAllSynchronizations()
{
foreach (var request in PendingSynchronizationRequest)
{
request.Value.Cancel();
request.Value.Dispose();
}
}
}
}

View File

@@ -7,32 +7,34 @@
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Diagnostics" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Google.Apis.Calendar.v3" />
<PackageReference Include="Google.Apis.Gmail.v1" />
<PackageReference Include="CommunityToolkit.Diagnostics" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Google.Apis.Calendar.v3" />
<PackageReference Include="Google.Apis.Gmail.v1" />
<PackageReference Include="Google.Apis.PeopleService.v1" />
<PackageReference Include="HtmlAgilityPack" />
<PackageReference Include="HtmlKit" />
<PackageReference Include="MailKit" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="HtmlAgilityPack" />
<PackageReference Include="HtmlKit" />
<PackageReference Include="MailKit" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Graph" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="Microsoft.Identity.Client.Broker" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
<PackageReference Include="MimeKit" />
<PackageReference Include="morelinq" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
<PackageReference Include="MimeKit" />
<PackageReference Include="morelinq" />
<PackageReference Include="Nito.AsyncEx.Tasks" />
<PackageReference Include="NodaTime" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Exceptions" />
<PackageReference Include="Serilog.Sinks.Debug" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="NodaTime" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Exceptions" />
<PackageReference Include="Serilog.Sinks.Debug" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="SkiaSharp" />
<PackageReference Include="SqlKata" />
<PackageReference Include="System.Text.Encoding.CodePages" />
<PackageReference Include="SqlKata" />
<PackageReference Include="System.Text.Encoding.CodePages" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wino.Authentication\Wino.Authentication.csproj" />
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />