From ed6a7d71b4a3aed46905a62cb69dcf68847a4bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Tue, 13 Aug 2024 14:12:54 +0200 Subject: [PATCH] Listening imap inbox changes with idle client. --- Wino.Core/Services/LogInitializer.cs | 2 +- Wino.Core/Synchronizers/ImapSynchronizer.cs | 159 ++++++++++++------ .../Server/NewSynchronizationRequested.cs | 2 +- Wino.Server/ServerContext.cs | 64 ++++++- Wino.Server/ServerViewModel.cs | 7 +- 5 files changed, 182 insertions(+), 52 deletions(-) diff --git a/Wino.Core/Services/LogInitializer.cs b/Wino.Core/Services/LogInitializer.cs index 10fc7e1e..a390be1d 100644 --- a/Wino.Core/Services/LogInitializer.cs +++ b/Wino.Core/Services/LogInitializer.cs @@ -26,7 +26,7 @@ namespace Wino.Core.Services { Log.Logger = new LoggerConfiguration() .MinimumLevel.ControlledBy(_levelSwitch) - .WriteTo.File(fullLogFilePath, retainedFileCountLimit: 3, rollOnFileSizeLimit: true, rollingInterval: RollingInterval.Day) + .WriteTo.File(fullLogFilePath, retainedFileCountLimit: 2, rollOnFileSizeLimit: true, rollingInterval: RollingInterval.Day) .WriteTo.Debug() .Enrich.FromLogContext() .Enrich.WithExceptionDetails() diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index 71ba0b90..e45965d5 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; using MailKit; using MailKit.Net.Imap; using MailKit.Search; @@ -21,13 +23,14 @@ using Wino.Core.Integration.Processors; using Wino.Core.Mime; using Wino.Core.Requests; using Wino.Core.Requests.Bundles; +using Wino.Messaging.Server; namespace Wino.Core.Synchronizers { public class ImapSynchronizer : BaseSynchronizer { private CancellationTokenSource idleDoneToken; - private CancellationTokenSource cancelInboxListeningToken = new CancellationTokenSource(); + private CancellationTokenSource cancelInboxListeningToken; private IMailFolder inboxFolder; @@ -35,6 +38,8 @@ namespace Wino.Core.Synchronizers private readonly ImapClientPool _clientPool; private readonly IImapChangeProcessor _imapChangeProcessor; + public bool IsChangeListeningActive { get; set; } + // Minimum summary items to Fetch for mail synchronization from IMAP. private readonly MessageSummaryItems mailSynchronizationFlags = MessageSummaryItems.Flags | @@ -47,12 +52,6 @@ namespace Wino.Core.Synchronizers MessageSummaryItems.References | MessageSummaryItems.ModSeq; - /// - /// Timer that keeps the alive for the lifetime of the pool. - /// Sends NOOP command to the server periodically. - /// - private Timer _noOpTimer; - /// /// ImapClient that keeps the Inbox folder opened all the time for listening notifications. /// @@ -65,44 +64,69 @@ namespace Wino.Core.Synchronizers { _clientPool = new ImapClientPool(Account.ServerInformation); _imapChangeProcessor = imapChangeProcessor; - - idleDoneToken = new CancellationTokenSource(); } - // 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."); + _logger.Warning("InboxClient is null. Cannot send IDLE command."); return; } + if (inboxFolder == null) + { + _logger.Warning("Inbox folder is null. Cannot listen for changes."); + return; + } + + idleDoneToken ??= new CancellationTokenSource(); + cancelInboxListeningToken ??= new CancellationTokenSource(); + await _clientPool.EnsureConnectedAsync(_inboxIdleClient); await _clientPool.EnsureAuthenticatedAsync(_inboxIdleClient); try { - if (inboxFolder == null) + if (!inboxFolder.IsOpen) { - inboxFolder = _inboxIdleClient.Inbox; await inboxFolder.OpenAsync(FolderAccess.ReadOnly, cancelInboxListeningToken.Token); } - idleDoneToken = new CancellationTokenSource(); + if (!_inboxIdleClient.IsIdle) + { + idleDoneToken = new CancellationTokenSource(); - await _inboxIdleClient.IdleAsync(idleDoneToken.Token, cancelInboxListeningToken.Token); + IsChangeListeningActive = true; + + await _inboxIdleClient.IdleAsync(idleDoneToken.Token, cancelInboxListeningToken.Token); + } + } + catch (ImapProtocolException) + { + await ReconnectAsync(); + } + catch (IOException) + { + await ReconnectAsync(); + } + catch (Exception exception) + { + Logger.Error(exception, "Error occured while listening for Inbox changes."); } finally { - idleDoneToken.Dispose(); - idleDoneToken = null; + IsChangeListeningActive = false; } } - private async Task StopInboxListeningAsync() + private async Task ReconnectAsync() + { + await _clientPool.EnsureConnectedAsync(_inboxIdleClient); + await _clientPool.EnsureAuthenticatedAsync(_inboxIdleClient); + } + + public async Task StopInboxListeningAsync() { if (inboxFolder != null) { @@ -111,12 +135,6 @@ namespace Wino.Core.Synchronizers inboxFolder.MessageFlagsChanged -= InboxFolderMessageFlagsChanged; } - if (_noOpTimer != null) - { - _noOpTimer.Dispose(); - _noOpTimer = null; - } - if (idleDoneToken != null) { idleDoneToken.Cancel(); @@ -124,9 +142,19 @@ namespace Wino.Core.Synchronizers idleDoneToken = null; } + if (cancelInboxListeningToken != null) + { + cancelInboxListeningToken.Cancel(); + cancelInboxListeningToken.Dispose(); + cancelInboxListeningToken = null; + } + + IsChangeListeningActive = false; + if (_inboxIdleClient != null) { await _inboxIdleClient.DisconnectAsync(true); + _inboxIdleClient.Dispose(); _inboxIdleClient = null; } @@ -136,20 +164,14 @@ namespace Wino.Core.Synchronizers /// Tries to connect & authenticate with the given credentials. /// Prepares synchronizer for active listening of Inbox folder. /// - public async Task StartInboxListeningAsync() + 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; + return false; } inboxFolder = _inboxIdleClient.Inbox; @@ -157,35 +179,62 @@ namespace Wino.Core.Synchronizers if (inboxFolder == null) { _logger.Information("Inbox folder is null. Cannot listen for changes."); - return; + + return false; } inboxFolder.CountChanged += InboxFolderCountChanged; inboxFolder.MessageExpunged += InboxFolderMessageExpunged; inboxFolder.MessageFlagsChanged += InboxFolderMessageFlagsChanged; - while (!cancelInboxListeningToken.IsCancellationRequested) + try { - await AwaitInboxIdleAsync(); + while (true) + { + await AwaitInboxIdleAsync(); + } + } + catch (OperationCanceledException) + { + Log.Information("Listening Inbox changes for IMAP is canceled."); + } + catch (Exception ex) + { + Log.Error(ex, "Error occured while listening for Inbox changes."); + return false; } - await StopInboxListeningAsync(); + return true; + } + + /// + /// When a change occurs in the Inbox folder, this method is called to force synchronization. + /// Quueing new sync is ignored if the synchronizer is already in a synchronization process. + /// + private void ForceInboxIdleSynchronization() + { + // Do not try to synchronize if we're already in a synchronization process. + + if (State == AccountSynchronizerState.Idle) + { + var options = new SynchronizationOptions() + { + Type = SynchronizationType.Inbox, + AccountId = Account.Id + }; + + WeakReferenceMessenger.Default.Send(new NewSynchronizationRequested(options, SynchronizationSource.Server)); + } } private void InboxFolderMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs e) - { - Console.WriteLine("Flags have changed for message #{0} ({1}).", e.Index, e.Flags); - } + => ForceInboxIdleSynchronization(); private void InboxFolderMessageExpunged(object sender, MessageEventArgs e) - { - _logger.Information("Inbox folder message expunged"); - } + => ForceInboxIdleSynchronization(); private void InboxFolderCountChanged(object sender, EventArgs e) - { - _logger.Information("Inbox folder count changed."); - } + => ForceInboxIdleSynchronization(); /// /// Parses List of string of mail copy ids and return valid uIds. @@ -998,5 +1047,21 @@ namespace Wino.Core.Synchronizers /// Remote folder /// Local folder. public bool ShouldUpdateFolder(IMailFolder remoteFolder, MailItemFolder localFolder) => remoteFolder.Name != localFolder.FolderName; + + /// + /// 1. Stops active inbox listening. + /// 2. Disconnects client pool with all clients. + /// 3. Disposes everything gracefully. + /// + /// Usefull for killing server scenario where nothing is needed to be connected to IMAP server anymore. + /// + public async Task KillAsync() + { + Log.Debug("Killing ImapSynchronizer for {Name}", Account.Name); + + await StopInboxListeningAsync(); + + _clientPool.Dispose(); + } } } diff --git a/Wino.Messages/Server/NewSynchronizationRequested.cs b/Wino.Messages/Server/NewSynchronizationRequested.cs index af7e343f..94fa2764 100644 --- a/Wino.Messages/Server/NewSynchronizationRequested.cs +++ b/Wino.Messages/Server/NewSynchronizationRequested.cs @@ -8,5 +8,5 @@ namespace Wino.Messaging.Server /// Triggers a new synchronization if possible. /// /// Options for synchronization. - public record NewSynchronizationRequested(SynchronizationOptions Options, SynchronizationSource Source) : IClientMessage; + public record NewSynchronizationRequested(SynchronizationOptions Options, SynchronizationSource Source) : IClientMessage, IUIMessage; } diff --git a/Wino.Server/ServerContext.cs b/Wino.Server/ServerContext.cs index cf2ce174..34ae5d48 100644 --- a/Wino.Server/ServerContext.cs +++ b/Wino.Server/ServerContext.cs @@ -13,6 +13,7 @@ using Wino.Core.Domain.Models.Requests; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Integration.Json; using Wino.Core.Services; +using Wino.Core.Synchronizers; using Wino.Messaging; using Wino.Messaging.Client.Authorization; using Wino.Messaging.Enums; @@ -40,7 +41,8 @@ namespace Wino.Server IRecipient, IRecipient, IRecipient, - IRecipient + IRecipient, + IRecipient { private readonly System.Timers.Timer _timer; private static object connectionLock = new object(); @@ -57,6 +59,8 @@ namespace Wino.Server TypeInfoResolver = new ServerRequestTypeInfoResolver() }; + private Task imapIdleTask = null; + public ServerContext(IDatabaseService databaseService, IApplicationConfiguration applicationFolderConfiguration, ISynchronizerFactory synchronizerFactory, @@ -171,7 +175,11 @@ namespace Wino.Server AppServiceConnectionStatus status = await connection.OpenAsync(); - if (status != AppServiceConnectionStatus.Success) + if (status == AppServiceConnectionStatus.Success) + { + imapIdleTask = RegisterImapSynchronizerChangesAsync(); + } + else { Log.Error("Opening server connection failed. Status: {status}", status); @@ -179,6 +187,53 @@ namespace Wino.Server } } + private async Task PerformForAllImapSynchronizers(Action action) + { + var allAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + + var imapAccounts = allAccounts.FindAll(a => a.ProviderType == MailProviderType.IMAP4); + + foreach (var account in imapAccounts) + { + var synchronizer = await _synchronizerFactory.GetAccountSynchronizerAsync(account.Id).ConfigureAwait(false); + + if (synchronizer == null) continue; + if (synchronizer is not ImapSynchronizer accountImapSynchronizer) + { + Log.Warning("Account '{Name}' has IMAP4 type but synchronizer is not ImapSynchronizer.", account.Name); + continue; + } + + action(accountImapSynchronizer); + } + } + + /// + /// Hooks all ImapSynchronizer instances to listen for changes like new mail, folder rename etc. + /// + private Task RegisterImapSynchronizerChangesAsync() + => PerformForAllImapSynchronizers(async accountImapSynchronizer => + { + // First make sure that listening is stopped. + + await accountImapSynchronizer.StopInboxListeningAsync(); + + var startListeningTask = accountImapSynchronizer.StartInboxListeningAsync(); + + await Task.Delay(10000); // Wait for 10 seconds. + + if (startListeningTask.Exception == null) + { + Log.Information("IMAP change listening started for account '{Name}'.", accountImapSynchronizer.Account.Name); + } + }); + + public Task DisposeActiveImapConnectionsAsync() + => PerformForAllImapSynchronizers(async accountImapSynchronizer => + { + await accountImapSynchronizer.KillAsync(); + }); + /// /// Disposes current connection to UWP app service. /// @@ -337,5 +392,10 @@ namespace Wino.Server App.Current.ChangeNotifyIconVisiblity(isServerTrayIconVisible); } + + public async void Receive(NewSynchronizationRequested message) + { + await ExecuteServerMessageSafeAsync(null, message); + } } } diff --git a/Wino.Server/ServerViewModel.cs b/Wino.Server/ServerViewModel.cs index bb5899f2..6a6eb8b7 100644 --- a/Wino.Server/ServerViewModel.cs +++ b/Wino.Server/ServerViewModel.cs @@ -26,6 +26,8 @@ namespace Wino.Server [RelayCommand] public Task LaunchWinoAsync() { + // Stop listening active imap synchronizer changes and disconnect gracefully. + return Context.DisposeActiveImapConnectionsAsync(); //var opt = new SynchronizationOptions() //{ // Type = Wino.Core.Domain.Enums.SynchronizationType.Full, @@ -37,7 +39,7 @@ namespace Wino.Server // return Task.CompletedTask; - return Launcher.LaunchUriAsync(new Uri($"{App.WinoMailLaunchProtocol}:")).AsTask(); + // return Launcher.LaunchUriAsync(new Uri($"{App.WinoMailLaunchProtocol}:")).AsTask(); //await _notificationBuilder.CreateNotificationsAsync(Guid.Empty, new List() //{ // new MailCopy(){ UniqueId = Guid.Parse("8f25d2a0-4448-4fee-96a9-c9b25a19e866")} @@ -71,6 +73,9 @@ namespace Wino.Server } } + // Stop listening active imap synchronizer changes and disconnect gracefully. + await Context.DisposeActiveImapConnectionsAsync(); + Application.Current.Shutdown(); }