diff --git a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs
index 86ec5cce..bfacad73 100644
--- a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs
+++ b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs
@@ -81,6 +81,11 @@ public interface ISynchronizationManager
///
IWinoSynchronizerBase CreateSynchronizerForAccount(MailAccount account);
+ ///
+ /// Cancels ongoing synchronizations for the given account.
+ ///
+ Task CancelSynchronizationsAsync(Guid accountId);
+
///
/// Destroys the synchronizer for the given account.
///
diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs
index cadaadda..108a08ea 100644
--- a/Wino.Core/Services/SynchronizationManager.cs
+++ b/Wino.Core/Services/SynchronizationManager.cs
@@ -26,6 +26,7 @@ public class SynchronizationManager : ISynchronizationManager
public static SynchronizationManager Instance => _instance.Value;
private readonly ConcurrentDictionary _synchronizerCache = new();
+ private readonly ConcurrentDictionary _accountSynchronizationCancellationSources = new();
private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
private readonly ILogger _logger = Log.ForContext();
@@ -141,9 +142,14 @@ public class SynchronizationManager : ISynchronizationManager
_logger.Information("Starting mail synchronization for account {AccountId} with type {SyncType}",
options.AccountId, options.Type);
+ var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource());
+ using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ accountCancellationSource.Token);
+
try
{
- var result = await synchronizer.SynchronizeMailsAsync(options, cancellationToken);
+ var result = await synchronizer.SynchronizeMailsAsync(options, linkedCancellationTokenSource.Token);
_logger.Information("Mail synchronization completed for account {AccountId} with state {State}",
options.AccountId, result.CompletedState);
@@ -156,6 +162,11 @@ public class SynchronizationManager : ISynchronizationManager
return result;
}
+ catch (OperationCanceledException)
+ {
+ _logger.Information("Mail synchronization canceled for account {AccountId}", options.AccountId);
+ return MailSynchronizationResult.Canceled;
+ }
catch (AuthenticationAttentionException authEx)
{
_logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId);
@@ -350,9 +361,14 @@ public class SynchronizationManager : ISynchronizationManager
_logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}",
options.AccountId, options.Type);
+ var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource());
+ using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ accountCancellationSource.Token);
+
try
{
- var result = await synchronizer.SynchronizeCalendarEventsAsync(options, cancellationToken);
+ var result = await synchronizer.SynchronizeCalendarEventsAsync(options, linkedCancellationTokenSource.Token);
_logger.Information("Calendar synchronization completed for account {AccountId} with state {State}",
options.AccountId, result.CompletedState);
@@ -363,6 +379,11 @@ public class SynchronizationManager : ISynchronizationManager
return result;
}
+ catch (OperationCanceledException)
+ {
+ _logger.Information("Calendar synchronization canceled for account {AccountId}", options.AccountId);
+ return CalendarSynchronizationResult.Canceled;
+ }
catch (AuthenticationAttentionException authEx)
{
_logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId);
@@ -491,6 +512,38 @@ public class SynchronizationManager : ISynchronizationManager
}
}
+ ///
+ /// Cancels all in-flight synchronizations for the given account.
+ ///
+ /// Account ID to cancel synchronizations for
+ public Task CancelSynchronizationsAsync(Guid accountId)
+ {
+ EnsureInitialized();
+
+ if (_accountSynchronizationCancellationSources.TryRemove(accountId, out var cancellationSource))
+ {
+ try
+ {
+ if (!cancellationSource.IsCancellationRequested)
+ {
+ cancellationSource.Cancel();
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ // no-op
+ }
+ finally
+ {
+ cancellationSource.Dispose();
+ }
+
+ _logger.Information("Canceled ongoing synchronizations for account {AccountId}", accountId);
+ }
+
+ return Task.CompletedTask;
+ }
+
///
/// Destroys the synchronizer for the given account.
///
@@ -498,6 +551,7 @@ public class SynchronizationManager : ISynchronizationManager
public async Task DestroySynchronizerAsync(Guid accountId)
{
EnsureInitialized();
+ await CancelSynchronizationsAsync(accountId);
if (_synchronizerCache.TryRemove(accountId, out var synchronizer))
{
@@ -506,6 +560,10 @@ public class SynchronizationManager : ISynchronizationManager
await synchronizer.KillSynchronizerAsync();
_logger.Information("Destroyed synchronizer for account {AccountId}", accountId);
}
+ catch (OperationCanceledException)
+ {
+ _logger.Information("Synchronizer destruction canceled for account {AccountId}", accountId);
+ }
catch (Exception ex)
{
_logger.Error(ex, "Failed to destroy synchronizer for account {AccountId}", accountId);
diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs
index 35965c35..a711a7e4 100644
--- a/Wino.Mail.WinUI/App.xaml.cs
+++ b/Wino.Mail.WinUI/App.xaml.cs
@@ -1,6 +1,8 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
@@ -32,7 +34,13 @@ public partial class App : WinoApplication,
IRecipient,
IRecipient
{
+ private const int InboxSyncsPerFullSync = 20;
private ISynchronizationManager? _synchronizationManager;
+ private IPreferencesService? _preferencesService;
+ private IAccountService? _accountService;
+ private CancellationTokenSource? _autoSynchronizationLoopCts;
+ private readonly SemaphoreSlim _autoSynchronizationSemaphore = new(1, 1);
+ private readonly Dictionary _inboxSyncCounters = [];
public App()
{
@@ -136,6 +144,12 @@ public partial class App : WinoApplication,
await InitializeServicesAsync();
_synchronizationManager = Services.GetRequiredService();
+ _preferencesService = Services.GetRequiredService();
+ _accountService = Services.GetRequiredService();
+
+ _preferencesService.PreferenceChanged -= PreferencesServiceChanged;
+ _preferencesService.PreferenceChanged += PreferencesServiceChanged;
+ RestartAutoSynchronizationLoop();
// Check if launched from toast notification.
if (IsNotificationActivation(out AppNotificationActivatedEventArgs toastArgs))
@@ -463,6 +477,132 @@ public partial class App : WinoApplication,
};
}
+ private void PreferencesServiceChanged(object? sender, string propertyName)
+ {
+ if (propertyName != nameof(IPreferencesService.EmailSyncIntervalMinutes))
+ return;
+
+ RestartAutoSynchronizationLoop();
+ }
+
+ private void RestartAutoSynchronizationLoop()
+ {
+ if (_preferencesService == null)
+ return;
+
+ StopAutoSynchronizationLoop();
+
+ int intervalMinutes = Math.Max(1, _preferencesService.EmailSyncIntervalMinutes);
+ _autoSynchronizationLoopCts = new CancellationTokenSource();
+
+ _ = RunAutoSynchronizationLoopAsync(TimeSpan.FromMinutes(intervalMinutes), _autoSynchronizationLoopCts.Token);
+ LogActivation($"Automatic sync loop started. Interval: {intervalMinutes} minute(s).");
+ }
+
+ private void StopAutoSynchronizationLoop()
+ {
+ if (_autoSynchronizationLoopCts == null)
+ return;
+
+ _autoSynchronizationLoopCts.Cancel();
+ _autoSynchronizationLoopCts.Dispose();
+ _autoSynchronizationLoopCts = null;
+ }
+
+ private async Task RunAutoSynchronizationLoopAsync(TimeSpan interval, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await ExecuteAutoSynchronizationAsync(cancellationToken).ConfigureAwait(false);
+
+ using var timer = new PeriodicTimer(interval);
+
+ while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
+ {
+ await ExecuteAutoSynchronizationAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // no-op
+ }
+ catch (Exception ex)
+ {
+ LogActivation($"Automatic sync loop failed: {ex.Message}");
+ }
+ }
+
+ private async Task ExecuteAutoSynchronizationAsync(CancellationToken cancellationToken)
+ {
+ if (_synchronizationManager == null || _accountService == null)
+ return;
+
+ bool lockTaken = false;
+
+ try
+ {
+ lockTaken = await _autoSynchronizationSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false);
+ if (!lockTaken)
+ return;
+
+ var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
+ var currentAccountIds = accounts.Select(a => a.Id).ToHashSet();
+ _inboxSyncCounters.Keys.Where(a => !currentAccountIds.Contains(a)).ToList().ForEach(a => _inboxSyncCounters.Remove(a));
+
+ foreach (var account in accounts)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (_synchronizationManager.IsAccountSynchronizing(account.Id))
+ continue;
+
+ var inboxSyncOptions = new MailSynchronizationOptions()
+ {
+ AccountId = account.Id,
+ Type = MailSynchronizationType.InboxOnly
+ };
+
+ var inboxSyncResult = await _synchronizationManager.SynchronizeMailAsync(inboxSyncOptions, cancellationToken).ConfigureAwait(false);
+
+ if (inboxSyncResult.CompletedState is SynchronizationCompletedState.Success or SynchronizationCompletedState.PartiallyCompleted)
+ {
+ _inboxSyncCounters.TryAdd(account.Id, 0);
+ _inboxSyncCounters[account.Id]++;
+
+ if (_inboxSyncCounters[account.Id] >= InboxSyncsPerFullSync)
+ {
+ var fullSyncOptions = new MailSynchronizationOptions()
+ {
+ AccountId = account.Id,
+ Type = MailSynchronizationType.FullFolders
+ };
+
+ await _synchronizationManager.SynchronizeMailAsync(fullSyncOptions, cancellationToken).ConfigureAwait(false);
+ _inboxSyncCounters[account.Id] = 0;
+ }
+ }
+
+ if (!account.IsCalendarAccessGranted)
+ continue;
+
+ var calendarOptions = new CalendarSynchronizationOptions()
+ {
+ AccountId = account.Id,
+ Type = CalendarSynchronizationType.CalendarMetadata
+ };
+
+ await _synchronizationManager.SynchronizeCalendarAsync(calendarOptions, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ if (lockTaken)
+ {
+ _autoSynchronizationSemaphore.Release();
+ }
+ }
+ }
+
///
/// Handles activation redirected from another instance (single-instancing).
/// This is called when a second instance tries to launch and redirects to this existing instance.
diff --git a/Wino.Mail.WinUI/Services/PreferencesService.cs b/Wino.Mail.WinUI/Services/PreferencesService.cs
index b6171b64..8b930750 100644
--- a/Wino.Mail.WinUI/Services/PreferencesService.cs
+++ b/Wino.Mail.WinUI/Services/PreferencesService.cs
@@ -50,12 +50,6 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
set => SetPropertyAndSave(nameof(MailItemDisplayMode), value);
}
- public bool IsSemanticZoomEnabled
- {
- get => _configurationService.Get(nameof(IsSemanticZoomEnabled), true);
- set => SetPropertyAndSave(nameof(IsSemanticZoomEnabled), value);
- }
-
public bool IsHardDeleteProtectionEnabled
{
get => _configurationService.Get(nameof(IsHardDeleteProtectionEnabled), true);