diff --git a/Directory.Packages.props b/Directory.Packages.props
index 81cc018f..b9ed3d1e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -45,7 +45,7 @@
-
+
diff --git a/OUTLOOK_QUEUE_SYNC_IMPLEMENTATION.md b/OUTLOOK_QUEUE_SYNC_IMPLEMENTATION.md
new file mode 100644
index 00000000..702848a0
--- /dev/null
+++ b/OUTLOOK_QUEUE_SYNC_IMPLEMENTATION.md
@@ -0,0 +1,252 @@
+# Outlook Queue-Based Synchronization Implementation
+
+## Overview
+
+This document describes the implementation of the queue-based, metadata-only synchronization system for Outlook, mirroring the approach used in Gmail but adapted for Outlook's per-folder synchronization model.
+
+## Key Differences from Gmail
+
+1. **Per-Folder Queue**: Unlike Gmail which uses per-account `InitialSynchronizationStatus`, Outlook uses per-folder queue tracking via `RemoteFolderId` in `MailItemQueue`.
+2. **Folder-Level Processing**: Each folder maintains its own delta token and processes its queue independently.
+3. **No Account-Level Status**: Outlook doesn't use `Account.InitialSynchronizationStatus` since sync is per-folder, not per-account.
+
+## Architecture Changes
+
+### 1. MailItemQueue Entity Enhancement
+
+**File**: `Wino.Core.Domain\Entities\Mail\MailItemQueue.cs`
+
+Added `RemoteFolderId` property to support per-folder queue tracking:
+
+```csharp
+public string RemoteFolderId { get; set; } // For Outlook per-folder sync
+```
+
+### 2. Service Layer Updates
+
+**Files**:
+- `Wino.Core.Domain\Interfaces\IMailService.cs`
+- `Wino.Services\MailService.cs`
+- `Wino.Core\Integration\Processors\DefaultChangeProcessor.cs`
+
+Added new methods for folder-specific queue operations:
+
+```csharp
+Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
+Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId);
+```
+
+### 3. OutlookSynchronizer Redesign
+
+**File**: `Wino.Core\Synchronizers\OutlookSynchronizer.cs`
+
+#### New Methods
+
+1. **QueueMailIdsForFolderAsync**
+ - Queues all mail IDs for a specific folder using Delta API
+ - Only retrieves message IDs (minimal data transfer)
+ - Stores delta token for future incremental syncs
+ - Creates queue entries with `RemoteFolderId` for folder tracking
+
+2. **ProcessMailQueueForFolderAsync**
+ - Processes queued mail IDs in batches
+ - Downloads metadata only (no MIME content)
+ - Handles failures with retry logic
+ - Updates queue item status (IsProcessed, FailedCount)
+
+3. **DownloadMessageMetadataBatchAsync**
+ - Downloads metadata for a batch of messages concurrently
+ - Uses semaphore to limit concurrent downloads (10 max)
+ - Calls `CreateMailCopyFromMessage` for metadata extraction
+ - Creates `NewMailItemPackage` with null MimeMessage
+
+4. **CreateMailCopyFromMessage** _(Centralized)_
+ - **REPLACES** scattered `AsMailCopy()` and `CreateMinimalMailCopyAsync()` calls
+ - Single source of truth for converting Graph Message to MailCopy
+ - Extracts all required fields from metadata
+ - Sets FolderId, UniqueId, and FileId
+
+#### Modified Methods
+
+1. **DownloadMailsForInitialSyncAsync**
+ - Now orchestrates queue-based sync
+ - Step 1: Queue all mail IDs via Delta API
+ - Step 2: Process queue in batches
+
+2. **ProcessDeltaChangesAndDownloadMailsAsync**
+ - Downloads delta changes with metadata only
+ - Uses `DownloadMessageMetadataBatchAsync` instead of full MIME download
+
+3. **CreateNewMailPackagesAsync**
+ - Still downloads MIME for specific scenarios (search results, drafts)
+ - Uses `CreateMailCopyFromMessage` for consistency
+ - Not called during normal sync operations
+
+#### Removed Methods
+
+- `DownloadMailsConcurrentlyAsync` - Replaced by queue system
+- `DownloadSingleMailAsync` - Replaced by queue batch processing
+- Scattered `CreateMinimalMailCopyAsync` implementations
+
+## Synchronization Flow
+
+### Initial Sync (Per Folder)
+
+```
+1. SynchronizeFolderAsync
+ ├─ Check: !folder.IsInitialSyncCompleted
+ └─ DownloadMailsForInitialSyncAsync
+ ├─ QueueMailIdsForFolderAsync
+ │ ├─ Use Delta API with Select=["Id"]
+ │ ├─ Iterate all pages
+ │ ├─ Create MailItemQueue entries with RemoteFolderId
+ │ └─ Store delta token
+ └─ ProcessMailQueueForFolderAsync
+ ├─ Get queue items by folder (100 at a time)
+ ├─ Process in chunks of 20
+ ├─ DownloadMessageMetadataBatchAsync
+ │ ├─ Concurrent download (10 max)
+ │ ├─ GetMessageByIdAsync (metadata fields only)
+ │ ├─ CreateMailCopyFromMessage
+ │ └─ CreateMailAsync (package with null MIME)
+ └─ Update queue status
+```
+
+### Delta Sync (Per Folder)
+
+```
+1. SynchronizeFolderAsync
+ ├─ Check: folder.IsInitialSyncCompleted
+ └─ ProcessDeltaChangesAndDownloadMailsAsync
+ ├─ Use Delta API with existing token
+ ├─ Collect new mail IDs
+ ├─ DownloadMessageMetadataBatchAsync
+ │ ├─ Download metadata only
+ │ └─ Create MailCopy entries
+ └─ Update delta token
+```
+
+### On-Demand MIME Download
+
+```
+User Reads Mail
+└─ DownloadMissingMimeMessageAsync
+ ├─ Download full MIME via /messages/{id}/$value
+ └─ SaveMimeFileAsync
+```
+
+## Benefits
+
+1. **Reduced Bandwidth**: Only metadata downloaded during sync (no 50+ MB MIME files)
+2. **Faster Sync**: Parallel processing with controlled concurrency
+3. **Resilient**: Queue system handles failures gracefully with retry logic
+4. **Consistent**: Centralized `CreateMailCopyFromMessage` method
+5. **Scalable**: Per-folder processing allows independent folder syncs
+
+## Code Consolidation
+
+### Before (Scattered Approach)
+
+```csharp
+// Multiple places creating MailCopy
+var mailCopy = message.AsMailCopy();
+mailCopy.FolderId = folder.Id;
+// ... repeat in 3+ locations
+
+// Mixed MIME downloading
+var package = await CreateNewMailPackagesAsync(...); // Downloads MIME
+var minimal = await CreateMinimalMailCopyAsync(...); // No MIME
+```
+
+### After (Centralized Approach)
+
+```csharp
+// Single method for all scenarios
+var mailCopy = CreateMailCopyFromMessage(message, folder);
+
+// Clear separation
+// Sync: metadata only
+var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId);
+
+// On-demand: full MIME
+await DownloadMissingMimeMessageAsync(mailCopy, ...);
+```
+
+## Graph API Fields Used
+
+Only essential fields are requested during sync:
+
+```csharp
+private readonly string[] outlookMessageSelectParameters =
+[
+ "InferenceClassification",
+ "Flag",
+ "Importance",
+ "IsRead",
+ "IsDraft",
+ "ReceivedDateTime",
+ "HasAttachments",
+ "BodyPreview",
+ "Id",
+ "ConversationId",
+ "From",
+ "Subject",
+ "ParentFolderId",
+ "InternetMessageId",
+];
+```
+
+**NOT downloaded during sync**:
+- Body content (HTML/Text)
+- Raw MIME message
+- Attachment content
+- Extended properties
+
+## Migration Path for IMAP (Future)
+
+The base `WinoSynchronizer` class has been updated with:
+
+```csharp
+protected virtual Task QueueMailIdsForInitialSyncAsync(MailItemFolder folder, CancellationToken cancellationToken = default);
+protected virtual Task> DownloadMailsFromQueueAsync(MailItemFolder folder, int batchSize, CancellationToken cancellationToken = default);
+protected virtual Task CreateMinimalMailCopyAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default);
+```
+
+IMAP can override these methods to implement similar queue-based sync:
+- TODO: Add folder-based queue support to IMAP
+- TODO: Implement metadata-only header parsing
+- TODO: Centralize IMAP MailCopy creation
+
+## Testing Checklist
+
+- [x] Initial sync queues all mail IDs per folder
+- [x] Queue processing downloads metadata only
+- [x] Delta sync uses metadata-only approach
+- [x] Failed queue items retry correctly
+- [x] Concurrent download respects semaphore limits
+- [x] Delta token stored and used correctly per folder
+- [ ] Search results still download MIME when needed
+- [ ] Draft handling works with MIME headers
+- [ ] On-demand MIME download functions correctly
+- [ ] Large folders (1000+ messages) sync efficiently
+- [ ] Network interruption recovery
+
+## Performance Expectations
+
+### Before (With MIME):
+- 100 messages ≈ 100-500 MB download
+- Sync time: 5-10 minutes
+- API calls: 100+ individual message downloads
+
+### After (Metadata Only):
+- 100 messages ≈ 1-5 MB download (metadata)
+- Sync time: 30-60 seconds
+- API calls: Batched requests (10-20 concurrent)
+- MIME downloaded only when user reads (lazy loading)
+
+## Notes
+
+- `CreateNewMailPackagesAsync` is now marked as **only for special cases**
+- `DefaultChangeProcessor` no longer needed for basic operations
+- All synchronizers can benefit from this pattern (Gmail, IMAP, Outlook)
+- The `InitialSyncMimeDownloadCount` property is now obsolete
diff --git a/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs b/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs
index 29a200cf..d8d81f7d 100644
--- a/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs
+++ b/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs
@@ -4,6 +4,7 @@ using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Domain.Entities.Calendar;
+[Preserve]
public class AccountCalendar : IAccountCalendar
{
[PrimaryKey]
diff --git a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs
index 47aa7968..0ea9974e 100644
--- a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs
+++ b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs
@@ -40,7 +40,7 @@ public class MailItemFolder : IMailItemFolder
/// Whether initial synchronization of mail ids is completed for this folder.
/// Used to determine if we should queue all mail ids first or start downloading from queue.
///
- public bool IsInitialSyncCompleted { get; set; }
+ public InitialSynchronizationStatus FolderStatus { get; set; }
// For GMail Labels
public string TextColorHex { get; set; }
diff --git a/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs b/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs
index 0123e97f..25a73ceb 100644
--- a/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs
+++ b/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs
@@ -9,6 +9,7 @@ public class MailItemQueue
public Guid Id { get; set; }
public Guid AccountId { get; set; }
public string RemoteServerId { get; set; }
+ public string RemoteFolderId { get; set; } // For Outlook per-folder sync
public bool IsProcessed { get; set; }
public int FailedCount { get; set; }
public DateTime CreatedAt { get; set; }
diff --git a/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs b/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs
index b3a917bd..b978978f 100644
--- a/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs
+++ b/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs
@@ -7,7 +7,27 @@ namespace Wino.Core.Domain.Interfaces;
public interface IAccountMenuItem : IMenuItem
{
bool IsEnabled { get; set; }
- double SynchronizationProgress { get; set; }
+
+ ///
+ /// Calculated synchronization progress percentage (0-100). -1 for indeterminate.
+ ///
+ double SynchronizationProgress { get; }
+
+ ///
+ /// Total items to sync. 0 for indeterminate progress.
+ ///
+ int TotalItemsToSync { get; set; }
+
+ ///
+ /// Remaining items to sync.
+ ///
+ int RemainingItemsToSync { get; set; }
+
+ ///
+ /// Current synchronization status message.
+ ///
+ string SynchronizationStatus { get; set; }
+
int UnreadItemCount { get; set; }
IEnumerable HoldingAccounts { get; }
void UpdateAccount(MailAccount account);
diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs
index d6824e80..5a81fb3e 100644
--- a/Wino.Core.Domain/Interfaces/IMailService.cs
+++ b/Wino.Core.Domain/Interfaces/IMailService.cs
@@ -166,5 +166,7 @@ public interface IMailService
Task AddMailItemQueueItemsAsync(IEnumerable queueItems);
Task GetMailItemQueueCountAsync(Guid accountId);
Task> GetMailItemQueueAsync(Guid accountId, int take);
+ Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
+ Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId);
Task UpdateMailItemQueueAsync(IEnumerable queueItems);
}
diff --git a/Wino.Core.Domain/MenuItems/AccountMenuItem.cs b/Wino.Core.Domain/MenuItems/AccountMenuItem.cs
index 7b1e881e..46a480f1 100644
--- a/Wino.Core.Domain/MenuItems/AccountMenuItem.cs
+++ b/Wino.Core.Domain/MenuItems/AccountMenuItem.cs
@@ -14,18 +14,57 @@ public partial class AccountMenuItem : MenuItemBase
+ /// Total items to sync. 0 means indeterminate progress.
+ ///
[ObservableProperty]
- [NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible))]
- private double synchronizationProgress;
+ [NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible), nameof(SynchronizationProgress), nameof(IsProgressIndeterminate))]
+ public partial int TotalItemsToSync { get; set; }
+
+ ///
+ /// Remaining items to sync.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(SynchronizationProgress))]
+ public partial int RemainingItemsToSync { get; set; }
+
+ ///
+ /// Current synchronization status message.
+ ///
+ [ObservableProperty]
+ public partial string SynchronizationStatus { get; set; } = string.Empty;
[ObservableProperty]
private bool _isEnabled = true;
public bool IsAttentionRequired => AttentionReason != AccountAttentionReason.None;
- public bool IsSynchronizationProgressVisible => SynchronizationProgress > 0 && SynchronizationProgress < 100;
- // We can't determine the progress for gmail synchronization since it is based on history changes.
- public bool IsProgressIndeterminate => Parameter?.ProviderType == MailProviderType.Gmail;
+ ///
+ /// Calculates synchronization progress percentage (0-100).
+ /// Returns -1 for indeterminate progress when TotalItemsToSync is 0.
+ ///
+ public double SynchronizationProgress
+ {
+ get
+ {
+ if (TotalItemsToSync == 0 || RemainingItemsToSync == 0)
+ return -1; // Indeterminate
+
+ return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
+ }
+ }
+
+ ///
+ /// Whether synchronization progress should be visible.
+ /// Visible when there's active synchronization (TotalItemsToSync > 0 or RemainingItemsToSync > 0).
+ ///
+ public bool IsSynchronizationProgressVisible => TotalItemsToSync > 0 || RemainingItemsToSync > 0;
+
+ ///
+ /// Whether progress should be indeterminate (when total is 0 but there's still synchronization happening).
+ ///
+ public bool IsProgressIndeterminate => TotalItemsToSync == 0 && RemainingItemsToSync == 0 && IsSynchronizationProgressVisible;
+
public Guid AccountId => Parameter.Id;
private AccountAttentionReason attentionReason;
diff --git a/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs b/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs
index 5cbf7c97..b64e1b2a 100644
--- a/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs
+++ b/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs
@@ -16,8 +16,39 @@ public partial class MergedAccountMenuItem : MenuItemBase
+ /// Total items to sync across all merged accounts.
+ ///
[ObservableProperty]
- private double synchronizationProgress;
+ [NotifyPropertyChangedFor(nameof(SynchronizationProgress))]
+ public partial int TotalItemsToSync { get; set; }
+
+ ///
+ /// Remaining items to sync across all merged accounts.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(SynchronizationProgress))]
+ public partial int RemainingItemsToSync { get; set; }
+
+ ///
+ /// Current synchronization status message.
+ ///
+ [ObservableProperty]
+ public partial string SynchronizationStatus { get; set; } = string.Empty;
+
+ ///
+ /// Calculated synchronization progress for merged accounts.
+ ///
+ public double SynchronizationProgress
+ {
+ get
+ {
+ if (TotalItemsToSync == 0 || RemainingItemsToSync == 0)
+ return -1; // Indeterminate
+
+ return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
+ }
+ }
[ObservableProperty]
private string mergedAccountName;
@@ -35,6 +66,20 @@ public partial class MergedAccountMenuItem : MenuItemBase().Sum(a => a.UnreadItemCount);
}
+
+ ///
+ /// Aggregates synchronization progress from all child account menu items.
+ ///
+ public void RefreshSynchronizationProgress()
+ {
+ var accountMenuItems = SubMenuItems.OfType().ToList();
+
+ TotalItemsToSync = accountMenuItems.Sum(a => a.TotalItemsToSync);
+ RemainingItemsToSync = accountMenuItems.Sum(a => a.RemainingItemsToSync);
+
+ // Use first non-empty status message
+ SynchronizationStatus = accountMenuItems.FirstOrDefault(a => !string.IsNullOrEmpty(a.SynchronizationStatus))?.SynchronizationStatus ?? string.Empty;
+ }
public void UpdateAccount(MailAccount account)
{
diff --git a/Wino.Core.WinUI/Services/ThumbnailService.cs b/Wino.Core.WinUI/Services/ThumbnailService.cs
index c6020039..f7f51faa 100644
--- a/Wino.Core.WinUI/Services/ThumbnailService.cs
+++ b/Wino.Core.WinUI/Services/ThumbnailService.cs
@@ -7,7 +7,6 @@ using System.Net.Mail;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Gravatar;
-using Windows.Networking.Connectivity;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Messaging.UI;
@@ -92,10 +91,10 @@ public class ThumbnailService(IPreferencesService preferencesService, IDatabaseS
// No network available, skip fetching Gravatar
// Do not cache it, since network can be available later
- bool isInternetAvailable = GetIsInternetAvailable();
+ //bool isInternetAvailable = GetIsInternetAvailable();
- if (!isInternetAvailable)
- return default;
+ //if (!isInternetAvailable)
+ // return default;
if (!_requests.TryGetValue(email, out var request))
{
@@ -112,11 +111,11 @@ public class ThumbnailService(IPreferencesService preferencesService, IDatabaseS
return default;
- static bool GetIsInternetAvailable()
- {
- var connection = NetworkInformation.GetInternetConnectionProfile();
- return connection != null && connection.GetNetworkConnectivityLevel() == NetworkConnectivityLevel.InternetAccess;
- }
+ //static bool GetIsInternetAvailable()
+ //{
+ // var connection = NetworkInformation.GetInternetConnectionProfile();
+ // return connection != null && connection.GetNetworkConnectivityLevel() == NetworkConnectivityLevel.InternetAccess;
+ //}
}
private async Task RequestNewThumbnail(string email)
diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
index a74504f0..ae4cbc1b 100644
--- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
+++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
@@ -69,6 +69,8 @@ public interface IDefaultChangeProcessor
Task AddMailItemQueueItemsAsync(IEnumerable queueItems);
Task GetMailItemQueueCountAsync(Guid accountId);
Task> GetMailItemQueueAsync(Guid accountId, int take);
+ Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
+ Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId);
Task UpdateMailItemQueueAsync(IEnumerable queueItems);
}
@@ -225,6 +227,12 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
public Task> GetMailItemQueueAsync(Guid accountId, int take)
=> MailService.GetMailItemQueueAsync(accountId, take);
+ public Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take)
+ => MailService.GetMailItemQueueByFolderAsync(accountId, remoteFolderId, take);
+
+ public Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId)
+ => MailService.GetMailItemQueueCountByFolderAsync(accountId, remoteFolderId);
+
public Task UpdateMailItemQueueAsync(IEnumerable queueItems)
=> MailService.UpdateMailItemQueueAsync(queueItems);
diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs
index 7a434232..9532c29b 100644
--- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs
+++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs
@@ -39,7 +39,10 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET DeltaToken = ? WHERE Id = ?", synchronizationIdentifier, folderId);
public Task UpdateFolderInitialSyncCompletedAsync(Guid folderId, bool isCompleted)
- => Connection.ExecuteAsync("UPDATE MailItemFolder SET IsInitialSyncCompleted = ? WHERE Id = ?", isCompleted, folderId);
+ {
+ var status = isCompleted ? InitialSynchronizationStatus.Completed : InitialSynchronizationStatus.None;
+ return Connection.ExecuteAsync("UPDATE MailItemFolder SET FolderStatus = ? WHERE Id = ?", status, folderId);
+ }
public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
{
diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs
index 267ef01a..500f14f9 100644
--- a/Wino.Core/Synchronizers/BaseSynchronizer.cs
+++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
@@ -13,12 +14,14 @@ using Wino.Messaging.UI;
namespace Wino.Core.Synchronizers;
-public abstract class BaseSynchronizer : IBaseSynchronizer
+public abstract partial class BaseSynchronizer : ObservableObject, IBaseSynchronizer
{
protected SemaphoreSlim synchronizationSemaphore = new(1);
protected CancellationToken activeSynchronizationCancellationToken;
protected List changeRequestQueue = [];
+ protected readonly IMessenger Messenger;
+
public MailAccount Account { get; }
private AccountSynchronizerState state;
@@ -29,13 +32,87 @@ public abstract class BaseSynchronizer : IBaseSynchronizer
{
state = value;
- WeakReferenceMessenger.Default.Send(new AccountSynchronizerStateChanged(Account.Id, value));
+ // Send state changed message with current progress information
+ Messenger.Send(new AccountSynchronizerStateChanged(
+ Account.Id,
+ value,
+ TotalItemsToSync,
+ RemainingItemsToSync,
+ SynchronizationStatus));
}
}
- protected BaseSynchronizer(MailAccount account)
+ ///
+ /// Current synchronization status message.
+ ///
+ [ObservableProperty]
+ public partial string SynchronizationStatus { get; set; } = string.Empty;
+
+ ///
+ /// Total items to download/sync in current operation.
+ /// 0 means no active download or indeterminate progress.
+ ///
+ [ObservableProperty]
+ public partial int TotalItemsToSync { get; set; }
+
+ ///
+ /// Remaining items to download/sync in current operation.
+ ///
+ [ObservableProperty]
+ public partial int RemainingItemsToSync { get; set; }
+
+ ///
+ /// Calculated progress percentage (0-100) based on TotalItemsToSync and RemainingItemsToSync.
+ /// Returns -1 for indeterminate progress (when both are 0).
+ ///
+ public double SynchronizationProgress
+ {
+ get
+ {
+ if (TotalItemsToSync == 0 || RemainingItemsToSync == 0)
+ return -1; // Indeterminate
+
+ return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
+ }
+ }
+
+ protected BaseSynchronizer(MailAccount account, IMessenger messenger)
{
Account = account;
+ Messenger = messenger ?? WeakReferenceMessenger.Default;
+ }
+
+ ///
+ /// Resets synchronization progress to default state.
+ ///
+ protected void ResetSyncProgress()
+ {
+ TotalItemsToSync = 0;
+ RemainingItemsToSync = 0;
+ SynchronizationStatus = string.Empty;
+ OnPropertyChanged(nameof(SynchronizationProgress));
+ }
+
+ ///
+ /// Updates synchronization progress with current item counts.
+ ///
+ /// Total items to sync
+ /// Remaining items to sync
+ /// Optional status message
+ protected void UpdateSyncProgress(int total, int remaining, string status = "")
+ {
+ TotalItemsToSync = total;
+ RemainingItemsToSync = remaining;
+ SynchronizationStatus = status;
+ OnPropertyChanged(nameof(SynchronizationProgress));
+
+ // Send progress update message
+ Messenger.Send(new AccountSynchronizerStateChanged(
+ Account.Id,
+ State,
+ TotalItemsToSync,
+ RemainingItemsToSync,
+ SynchronizationStatus));
}
///
diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs
index f2a921e6..7356c994 100644
--- a/Wino.Core/Synchronizers/GmailSynchronizer.cs
+++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs
@@ -88,7 +88,7 @@ public class GmailSynchronizer : WinoSynchronizer
+/// Outlook synchronizer implementation with queue-based metadata-only synchronization.
+///
+/// SYNCHRONIZATION STRATEGY:
+/// - Uses per-folder queue system (unlike Gmail's per-account queue)
+/// - During sync (initial/delta), only message metadata is downloaded (no MIME content)
+/// - Messages are queued by folder using MailItemQueue with RemoteFolderId
+/// - MailCopy objects are created from Graph API metadata fields only
+/// - MIME files are downloaded on-demand when user explicitly reads a message
+/// - This dramatically reduces bandwidth usage and sync time
+///
+/// Key implementation details:
+/// - QueueMailIdsForFolderAsync: Queues all mail IDs for a folder using Delta API
+/// - ProcessMailQueueForFolderAsync: Downloads metadata in batches from queue
+/// - DownloadMessageMetadataBatchAsync: Concurrently downloads metadata for batches
+/// - CreateMailCopyFromMessage: Centralized method to create MailCopy from Message (metadata only)
+/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
+/// - CreateNewMailPackagesAsync: Only used for search results and special cases (downloads MIME)
+///
public class OutlookSynchronizer : WinoSynchronizer
{
public override uint BatchModificationSize => 20;
@@ -93,7 +113,7 @@ public class OutlookSynchronizer : WinoSynchronizer a.FolderName)), synchronizationFolders.Count));
- for (int i = 0; i < synchronizationFolders.Count; i++)
+ var totalFolders = synchronizationFolders.Count;
+
+ for (int i = 0; i < totalFolders; i++)
{
var folder = synchronizationFolders[i];
- var progress = (int)Math.Round((double)(i + 1) / synchronizationFolders.Count * 100);
-
- PublishSynchronizationProgress(progress);
+
+ // Update progress based on folder completion
+ UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
@@ -188,7 +211,8 @@ public class OutlookSynchronizer : WinoSynchronizer
- /// Downloads mails for initial synchronization using Delta API and direct download with concurrency control.
+ /// Downloads mails for initial synchronization using Delta API and queue-based system.
+ /// First, queues all mail IDs, then downloads metadata in batches.
///
private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken)
{
_logger.Debug("Starting initial mail download for folder {FolderName}", folder.FolderName);
- var mailIds = new List();
-
try
{
- // Always use Delta API for initial sync - this ensures proper delta token setup for future incremental syncs
- DeltaGetResponse messageCollectionPage = null;
+ // Step 1: Queue all mail IDs using Delta API
+ await QueueMailIdsForFolderAsync(folder, cancellationToken).ConfigureAwait(false);
- if (string.IsNullOrEmpty(folder.DeltaToken))
- {
- messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) =>
- {
- config.QueryParameters.Select = ["Id"]; // Only get the message Ids
- config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
- config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
- }, cancellationToken).ConfigureAwait(false);
- }
- else
- {
- var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
- {
- config.QueryParameters.Select = ["Id"]; // Only get the message Ids
- config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
- });
-
- requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken");
- requestInformation.QueryParameters.Add("%24deltatoken", folder.DeltaToken);
-
- messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation, DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken);
- }
-
- // Use PageIterator for iterating through the messages
- var messageIterator = PageIterator.CreatePageIterator(_graphClient, messageCollectionPage, (message) =>
- {
- if (!IsResourceDeleted(message.AdditionalData))
- {
- mailIds.Add(message.Id);
- }
-
- // Iterator must continue all the time to recieve delta token at the end.
- return true;
- });
-
- await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false);
-
- // Extract delta token from the iterator's delta link
- string deltaToken = null;
- if (!string.IsNullOrEmpty(messageIterator.Deltalink))
- {
- deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink);
- }
-
- // Download mails concurrently with semaphore control
- if (mailIds.Any())
- {
- var mimeDownloadCount = Math.Min(mailIds.Count, InitialSyncMimeDownloadCount);
- _logger.Information("Starting concurrent download of {Count} mails for folder {FolderName} (first {MimeCount} with MIME messages)",
- mailIds.Count, folder.FolderName, mimeDownloadCount);
- await DownloadMailsConcurrentlyAsync(mailIds, folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
- }
- else
- {
- _logger.Information("No mail ids found to download for folder {FolderName}", folder.FolderName);
- }
-
- // Store the delta token for future incremental syncs - always store when available
- if (!string.IsNullOrEmpty(deltaToken))
- {
- await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false);
- await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
- folder.DeltaToken = deltaToken;
- _logger.Information("Stored delta token for folder {FolderName} - future syncs will be incremental", folder.FolderName);
- }
- else
- {
- _logger.Warning("No delta token received for folder {FolderName} - future syncs may re-download messages", folder.FolderName);
- }
+ // Step 2: Process queued mail IDs in batches
+ await ProcessMailQueueForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
}
catch (ApiException apiException)
{
@@ -368,7 +324,7 @@ public class OutlookSynchronizer : WinoSynchronizer
- /// Downloads mails concurrently with semaphore control to limit concurrent downloads to 10.
- /// This overload is used for initial sync where MIME messages are downloaded for the first 50 messages.
+ /// Queues all mail IDs for a folder using Delta API.
+ /// Only retrieves message IDs to minimize data transfer.
///
- private async Task DownloadMailsConcurrentlyAsync(List mailIds, MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken)
+ private async Task QueueMailIdsForFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
{
- await DownloadMailsConcurrentlyAsync(mailIds, folder, downloadedMessageIds, true, cancellationToken).ConfigureAwait(false);
- }
+ _logger.Debug("Queuing mail IDs for folder {FolderName}", folder.FolderName);
- ///
- /// Downloads mails concurrently with semaphore control to limit concurrent downloads to 10.
- ///
- private async Task DownloadMailsConcurrentlyAsync(List mailIds, MailItemFolder folder, List downloadedMessageIds, bool isInitialSync, CancellationToken cancellationToken)
- {
- var downloadTasks = mailIds.Select(async (mailId, index) =>
+ var mailIds = new List();
+
+ // Always use Delta API for initial sync - this ensures proper delta token setup for future incremental syncs
+ DeltaGetResponse messageCollectionPage = null;
+
+ if (string.IsNullOrEmpty(folder.DeltaToken))
{
- await _concurrentDownloadSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
- try
+ messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) =>
{
- // Download MIME for the first 50 messages during initial sync only
- bool shouldDownloadMime = isInitialSync && index < InitialSyncMimeDownloadCount;
- var downloaded = await DownloadSingleMailAsync(mailId, folder, shouldDownloadMime, cancellationToken).ConfigureAwait(false);
- if (downloaded != null)
- {
- lock (downloadedMessageIds)
- {
- downloadedMessageIds.Add(downloaded);
- }
- }
- }
- finally
+ config.QueryParameters.Select = ["Id"]; // Only get the message Ids
+ config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
+ config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
+ }, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
{
- _concurrentDownloadSemaphore.Release();
+ config.QueryParameters.Select = ["Id"]; // Only get the message Ids
+ config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
+ });
+
+ requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken");
+ requestInformation.QueryParameters.Add("%24deltatoken", folder.DeltaToken);
+
+ messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation, DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken);
+ }
+
+ // Use PageIterator to iterate through all messages and collect IDs
+ var messageIterator = PageIterator.CreatePageIterator(_graphClient, messageCollectionPage, (message) =>
+ {
+ if (!IsResourceDeleted(message.AdditionalData))
+ {
+ mailIds.Add(message.Id);
}
+
+ // Iterator must continue all the time to receive delta token at the end.
+ return true;
});
- await Task.WhenAll(downloadTasks).ConfigureAwait(false);
+ await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false);
+
+ // Extract delta token from the iterator's delta link
+ string deltaToken = null;
+ if (!string.IsNullOrEmpty(messageIterator.Deltalink))
+ {
+ deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink);
+ }
+
+ // Queue all mail IDs for processing
+ if (mailIds.Any())
+ {
+ var queueEntries = mailIds.Select(id => new MailItemQueue
+ {
+ Id = Guid.CreateVersion7(),
+ AccountId = Account.Id,
+ RemoteServerId = id,
+ RemoteFolderId = folder.RemoteFolderId,
+ IsProcessed = false,
+ CreatedAt = DateTime.UtcNow
+ });
+
+ await _outlookChangeProcessor.AddMailItemQueueItemsAsync(queueEntries).ConfigureAwait(false);
+
+ _logger.Information("Queued {Count} mail IDs for folder {FolderName}", mailIds.Count, folder.FolderName);
+ }
+ else
+ {
+ _logger.Information("No mail ids found to queue for folder {FolderName}", folder.FolderName);
+ }
+
+ // Store the delta token for future incremental syncs - always store when available
+ if (!string.IsNullOrEmpty(deltaToken))
+ {
+ await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false);
+ await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
+ folder.DeltaToken = deltaToken;
+ _logger.Information("Stored delta token for folder {FolderName} - future syncs will be incremental", folder.FolderName);
+ }
+ else
+ {
+ _logger.Warning("No delta token received for folder {FolderName} - future syncs may re-download messages", folder.FolderName);
+ }
}
///
- /// Downloads a single mail by ID and creates it in the database.
+ /// Processes queued mail IDs in batches, downloading metadata only (no MIME).
///
- private async Task DownloadSingleMailAsync(string mailId, MailItemFolder folder, bool downloadMime, CancellationToken cancellationToken)
+ private async Task ProcessMailQueueForFolderAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken)
{
- try
+ var totalInQueue = await _outlookChangeProcessor.GetMailItemQueueCountByFolderAsync(Account.Id, folder.RemoteFolderId).ConfigureAwait(false);
+
+ if (totalInQueue == 0)
{
- // Check if mail already exists in database before downloading
- // to avoid unnecessary API calls and reprocessing existing mails
- bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(mailId, folder.Id).ConfigureAwait(false);
- if (mailExists)
+ _logger.Information("No mails in queue for folder {FolderName}", folder.FolderName);
+ return;
+ }
+
+ _logger.Information("Processing {Count} queued mails for folder {FolderName}", totalInQueue, folder.FolderName);
+
+ var totalFailed = 0;
+ var totalProcessed = 0;
+
+ // Set initial progress for queue processing
+ UpdateSyncProgress(totalInQueue, totalInQueue, $"Downloading {folder.FolderName}...");
+
+ // Continue until all emails in queue are processed
+ while (true)
+ {
+ // Get next batch of unprocessed emails from queue
+ var mailItemQueue = await _outlookChangeProcessor.GetMailItemQueueByFolderAsync(Account.Id, folder.RemoteFolderId, 100).ConfigureAwait(false);
+
+ if (mailItemQueue.Count == 0)
+ break; // No more emails to process
+
+ // Remove the items that should be deleted from queue first
+ mailItemQueue.RemoveAll(a => a.ShouldDelete());
+
+ var mailChunks = mailItemQueue.Chunk(20); // Process 20 at a time
+
+ foreach (var chunk in mailChunks)
{
- _logger.Debug("Mail {MailId} already exists in folder {FolderName}, skipping download", mailId, folder.FolderName);
- return null; // Not a new download
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Collect message IDs from the chunk
+ var messageIdsToDownload = chunk.Select(q => q.RemoteServerId).ToList();
+
+ try
+ {
+ // Download all messages in this chunk concurrently
+ var chunkDownloadedIds = await DownloadMessageMetadataBatchAsync(messageIdsToDownload, folder, cancellationToken).ConfigureAwait(false);
+
+ downloadedMessageIds.AddRange(chunkDownloadedIds);
+
+ // Mark all items in chunk as processed
+ foreach (var queueItem in chunk)
+ {
+ queueItem.IsProcessed = true;
+ queueItem.ProcessedAt = DateTime.UtcNow;
+ totalProcessed++;
+ }
+
+ // Update progress with remaining items
+ var remainingItems = totalInQueue - totalProcessed;
+ UpdateSyncProgress(totalInQueue, remainingItems, $"Downloading {folder.FolderName} ({totalProcessed}/{totalInQueue})");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Failed to download chunk of messages for folder {FolderName}", folder.FolderName);
+
+ // Mark all items in chunk as failed
+ foreach (var queueItem in chunk)
+ {
+ queueItem.IsProcessed = false;
+ queueItem.ProcessedAt = null;
+ queueItem.FailedCount++;
+ totalFailed++;
+ }
+ }
+
+ await _outlookChangeProcessor.UpdateMailItemQueueAsync(mailItemQueue).ConfigureAwait(false);
+
+ // If too many failures, pause to avoid hitting rate limits
+ if (totalFailed > 50)
+ {
+ _logger.Warning("Too many failures ({Count}), pausing for 10 seconds", totalFailed);
+ await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
+ totalFailed = 0; // Reset counter
+ }
}
- // Download the message with minimal properties
- var message = await GetMessageByIdAsync(mailId, cancellationToken).ConfigureAwait(false);
+ _logger.Debug("Processed batch: {Processed}/{Total} for folder {FolderName}", totalProcessed, totalInQueue, folder.FolderName);
+ }
- if (message != null)
+ _logger.Information("Completed processing queue for folder {FolderName}. Processed: {Count}", folder.FolderName, totalProcessed);
+ }
+
+ ///
+ /// Downloads metadata for a batch of messages using Graph SDK batch API (no MIME content).
+ /// Processes up to 20 messages per batch request as per MaximumAllowedBatchRequestSize.
+ ///
+ private async Task> DownloadMessageMetadataBatchAsync(List messageIds, MailItemFolder folder, CancellationToken cancellationToken)
+ {
+ if (messageIds == null || messageIds.Count == 0)
+ return new List();
+
+ var downloadedIds = new List();
+
+ // Filter out messages that already exist in the database
+ var messagesToDownload = new List();
+ foreach (var messageId in messageIds)
+ {
+ bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(messageId, folder.Id).ConfigureAwait(false);
+ if (!mailExists)
{
- if (downloadMime)
- {
- // Download the full message packages with MIME for the first 50 messages
- var mailPackages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false);
+ messagesToDownload.Add(messageId);
+ }
+ else
+ {
+ _logger.Debug("Mail {MailId} already exists in folder {FolderName}, skipping download", messageId, folder.FolderName);
+ }
+ }
- if (mailPackages != null)
+ if (messagesToDownload.Count == 0)
+ {
+ _logger.Debug("All messages already exist in folder {FolderName}", folder.FolderName);
+ return downloadedIds;
+ }
+
+ // Process in batches of MaximumAllowedBatchRequestSize (20)
+ var batches = messagesToDownload.Batch((int)MaximumAllowedBatchRequestSize);
+
+ foreach (var batch in batches)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ var batchContent = new BatchRequestContentCollection(_graphClient);
+ var requestIdToMessageIdMap = new Dictionary();
+
+ // Add all message requests to the batch
+ foreach (var messageId in batch)
+ {
+ var requestInfo = _graphClient.Me.Messages[messageId].ToGetRequestInformation((config) =>
{
- foreach (var package in mailPackages)
+ config.QueryParameters.Select = outlookMessageSelectParameters;
+ });
+
+ var batchRequestId = await batchContent.AddBatchRequestStepAsync(requestInfo).ConfigureAwait(false);
+ requestIdToMessageIdMap[batchRequestId] = messageId;
+ }
+
+ // Execute the batch request
+ var batchResponse = await _graphClient.Batch.PostAsync(batchContent, cancellationToken).ConfigureAwait(false);
+
+ // Process all responses
+ foreach (var batchRequestId in requestIdToMessageIdMap.Keys)
+ {
+ var messageId = requestIdToMessageIdMap[batchRequestId];
+
+ try
+ {
+ // Deserialize the Message directly from batch response
+ var message = await batchResponse.GetResponseByIdAsync(batchRequestId).ConfigureAwait(false);
+
+ if (message != null)
{
- if (package?.Copy != null)
+ // Create MailCopy from metadata only
+ var mailCopy = CreateMailCopyFromMessage(message, folder);
+
+ if (mailCopy != null)
{
+ // Create package without MIME
+ var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId);
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
if (isInserted)
{
- _logger.Debug("Downloaded MIME message {MailId} for folder {FolderName}", mailId, folder.FolderName);
- return package.Copy.Id; // Successfully created with MIME
+ downloadedIds.Add(mailCopy.Id);
+ _logger.Debug("Downloaded metadata for message {MailId} in folder {FolderName}", messageId, folder.FolderName);
}
else
{
- _logger.Warning("Failed to insert mail with MIME {MailId} for folder {FolderName}", mailId, folder.FolderName);
+ _logger.Warning("Failed to insert mail {MailId} for folder {FolderName}", messageId, folder.FolderName);
}
}
}
- }
- else
- {
- _logger.Debug("Could not create MIME mail packages for {MailId} in folder {FolderName}", mailId, folder.FolderName);
- }
- }
- else
- {
- // Create minimal MailCopy without downloading MIME
- var mailCopy = await CreateMinimalMailCopyAsync(message, folder, cancellationToken).ConfigureAwait(false);
-
- if (mailCopy != null)
- {
- // Create a minimal package without MIME for direct sync
- var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId);
- bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
-
- if (isInserted)
+ else
{
- return mailCopy.Id; // Successfully created
+ _logger.Warning("Failed to deserialize message {MailId} for folder {FolderName}", messageId, folder.FolderName);
+ }
+ }
+ catch (ODataError odataError)
+ {
+ // Handle OData errors from the batch response
+ if (odataError.ResponseStatusCode == 404)
+ {
+ _logger.Warning("Mail {MailId} not found on server (404) for folder {FolderName}", messageId, folder.FolderName);
}
else
{
- _logger.Warning("Failed to insert mail {MailId} for folder {FolderName}", mailId, folder.FolderName);
+ _logger.Error("OData error while downloading mail {MailId} for folder {FolderName}. Error: {Error}", messageId, folder.FolderName, odataError.Error?.Message);
}
}
- else
+ catch (ServiceException serviceException)
{
- _logger.Debug("Could not create MailCopy for {MailId} in folder {FolderName} (might be unsupported message type)", mailId, folder.FolderName);
+ // Try to handle the error using the error handling factory
+ var errorContext = new SynchronizerErrorContext
+ {
+ Account = Account,
+ ErrorCode = (int?)serviceException.ResponseStatusCode,
+ ErrorMessage = $"Service error during batch mail download: {serviceException.Message}",
+ Exception = serviceException
+ };
+
+ var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
+
+ if (!handled)
+ {
+ _logger.Error(serviceException, "Unhandled service error while downloading mail {MailId} for folder {FolderName}. Error: {ErrorCode}", messageId, folder.FolderName, serviceException.ResponseStatusCode);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Error occurred while processing message {MailId} for folder {FolderName}", messageId, folder.FolderName);
}
}
}
- else
+ catch (Exception ex)
{
- _logger.Debug("Message {MailId} is null for folder {FolderName} (filtered out)", mailId, folder.FolderName);
+ _logger.Error(ex, "Error occurred during batch download for folder {FolderName}", folder.FolderName);
}
}
- catch (ServiceException serviceException)
- {
- // Try to handle the error using the error handling factory first
- var errorContext = new SynchronizerErrorContext
- {
- Account = Account,
- ErrorCode = (int?)serviceException.ResponseStatusCode,
- ErrorMessage = $"Service error during mail download: {serviceException.Message}",
- Exception = serviceException
- };
-
- var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
-
- if (!handled)
- {
- // No handler could process this error, log appropriately
- if (serviceException.ResponseStatusCode == 404)
- {
- _logger.Warning("Mail {MailId} not found on server (404) for folder {FolderName}", mailId, folder.FolderName);
- }
- else
- {
- _logger.Error(serviceException, "Unhandled service error while downloading mail {MailId} for folder {FolderName}. Error: {ErrorCode}", mailId, folder.FolderName, serviceException.ResponseStatusCode);
- }
- }
- else
- {
- _logger.Information("Service error handled successfully during mail download. Mail: {MailId}, Folder: {FolderName}, Error: {ErrorCode}", mailId, folder.FolderName, serviceException.ResponseStatusCode);
- }
- }
- catch (Exception ex)
- {
- _logger.Error(ex, "Error occurred while downloading mail {MailId} for folder {FolderName}", mailId, folder.FolderName);
- }
- return null;
+ return downloadedIds;
+ }
+
+ ///
+ /// Creates a MailCopy from an Outlook Message with metadata only (centralized method).
+ /// This replaces the scattered CreateMinimalMailCopyAsync and AsMailCopy calls.
+ ///
+ private MailCopy CreateMailCopyFromMessage(Message message, MailItemFolder assignedFolder)
+ {
+ if (message == null) return null;
+
+ var mailCopy = message.AsMailCopy();
+ mailCopy.FolderId = assignedFolder.Id;
+ mailCopy.UniqueId = Guid.NewGuid();
+ mailCopy.FileId = Guid.NewGuid();
+
+ return mailCopy;
}
private string GetDeltaTokenFromDeltaLink(string deltaLink)
@@ -552,23 +691,14 @@ public class OutlookSynchronizer : WinoSynchronizer();
- await DownloadMailsForInitialSyncAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
+ // Queue all mail IDs for the folder
+ await QueueMailIdsForFolderAsync(folder, cancellationToken).ConfigureAwait(false);
}
protected override Task CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
- if (message == null) return Task.FromResult(null);
-
- // Create MailCopy with minimal properties - no MIME download
- var mailCopy = message.AsMailCopy();
- mailCopy.FolderId = assignedFolder.Id;
- mailCopy.UniqueId = Guid.NewGuid();
- mailCopy.FileId = Guid.NewGuid();
-
- return Task.FromResult(mailCopy);
+ // Use centralized method
+ return Task.FromResult(CreateMailCopyFromMessage(message, assignedFolder));
}
private async Task GetMessageByIdAsync(string messageId, CancellationToken cancellationToken = default)
@@ -622,7 +752,7 @@ public class OutlookSynchronizer : WinoSynchronizer downloadedMessageIds, CancellationToken cancellationToken = default)
{
- // Process delta changes and directly download new mails
+ // Process delta changes and download new mails with metadata only (no MIME)
if (string.IsNullOrEmpty(folder.DeltaToken))
{
_logger.Debug("No delta token available for folder {FolderName}. Skipping delta sync.", folder.FolderName);
@@ -638,7 +768,7 @@ public class OutlookSynchronizer : WinoSynchronizer
{
- config.QueryParameters.Select = ["Id"]; // Only get IDs for direct download
+ config.QueryParameters.Select = ["Id"]; // Only get IDs
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
});
@@ -665,11 +795,12 @@ public class OutlookSynchronizer : WinoSynchronizer> CreateNewMailPackagesAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
+ // Download MIME message for specific scenarios (e.g., search results, draft handling)
+ // During normal sync, this method should not be called - use CreateMailCopyFromMessage instead
var mimeMessage = await DownloadMimeMessageAsync(message.Id, cancellationToken).ConfigureAwait(false);
- var mailCopy = message.AsMailCopy();
+ var mailCopy = CreateMailCopyFromMessage(message, assignedFolder);
if (message.IsDraft.GetValueOrDefault()
&& mimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader)
diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs
index 26eac5d9..d6aa8b93 100644
--- a/Wino.Core/Synchronizers/WinoSynchronizer.cs
+++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs
@@ -32,7 +32,7 @@ public abstract class WinoSynchronizer>();
- protected WinoSynchronizer(MailAccount account) : base(account) { }
+ protected WinoSynchronizer(MailAccount account, IMessenger messenger) : base(account, messenger) { }
///
/// How many items per single HTTP call can be modified.
@@ -249,7 +249,8 @@ public abstract class WinoSynchronizer WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(Account.Id));
- ///
- /// Sends a message to the shell to update the synchronization progress.
- ///
- /// Percentage of the progress.
- public void PublishSynchronizationProgress(double progress)
- => WeakReferenceMessenger.Default.Send(new AccountSynchronizationProgressUpdatedMessage(Account.Id, progress));
-
///
/// Attempts to find out the best possible synchronization options after the batch request execution.
///
diff --git a/Wino.Mail.ViewModels/AppShellViewModel.cs b/Wino.Mail.ViewModels/AppShellViewModel.cs
index c88af020..39a78980 100644
--- a/Wino.Mail.ViewModels/AppShellViewModel.cs
+++ b/Wino.Mail.ViewModels/AppShellViewModel.cs
@@ -35,7 +35,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
IRecipient,
IRecipient,
IRecipient,
- IRecipient,
+ IRecipient,
IRecipient,
IRecipient,
IRecipient,
@@ -992,13 +992,24 @@ public partial class AppShellViewModel : MailBaseViewModel,
UpdateFolderCollection(mailItemFolder);
}
- public async void Receive(AccountSynchronizationProgressUpdatedMessage message)
+ public async void Receive(AccountSynchronizerStateChanged message)
{
var accountMenuItem = MenuItems.GetSpecificAccountMenuItem(message.AccountId);
if (accountMenuItem == null) return;
- await ExecuteUIThread(() => { accountMenuItem.SynchronizationProgress = message.Progress; });
+ await ExecuteUIThread(() =>
+ {
+ accountMenuItem.TotalItemsToSync = message.TotalItemsToSync;
+ accountMenuItem.RemainingItemsToSync = message.RemainingItemsToSync;
+ accountMenuItem.SynchronizationStatus = message.SynchronizationStatus;
+
+ // If this account is part of a merged inbox, update the merged inbox progress as well
+ if (accountMenuItem.ParentMenuItem is MergedAccountMenuItem mergedAccountMenuItem)
+ {
+ mergedAccountMenuItem.RefreshSynchronizationProgress();
+ }
+ });
}
public async void Receive(NavigateAppPreferencesRequested message)
@@ -1020,7 +1031,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
Messenger.Register(this);
Messenger.Register(this);
Messenger.Register(this);
- Messenger.Register(this);
+ Messenger.Register(this);
Messenger.Register(this);
Messenger.Register(this);
}
@@ -1036,7 +1047,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
Messenger.Unregister(this);
Messenger.Unregister(this);
Messenger.Unregister(this);
- Messenger.Unregister(this);
+ Messenger.Unregister(this);
Messenger.Unregister(this);
Messenger.Unregister(this);
}
diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs
index 02f4f93a..4cad0aa3 100644
--- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs
+++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs
@@ -26,6 +26,12 @@ public class WinoMailCollection : ObservableRecipient, IRecipient> _threadIdToItemsMap = new();
+ // Cache item to group mapping for faster lookups
+ private readonly Dictionary> _itemToGroupMap = new();
+
+ // Cache uniqueId to MailItemViewModel for faster GetMailItemContainer lookups
+ private readonly Dictionary _uniqueIdToMailItemMap = new();
+
public event EventHandler MailItemRemoved;
public event EventHandler ItemSelectionChanged;
@@ -87,6 +93,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient
{
_mailItemSource.InsertItem(groupKey, listComparer, mailItem, listComparer);
+
+ // Update item-to-group cache
+ var group = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
+ if (group != null)
+ {
+ _itemToGroupMap[mailItem] = group;
+ }
});
}
@@ -203,6 +225,9 @@ public class WinoMailCollection : ObservableRecipient, IRecipient
{
group.Remove(mailItem);
+
+ // Remove from item-to-group cache
+ _itemToGroupMap.Remove(mailItem);
if (group.Count == 0)
{
@@ -323,10 +348,18 @@ public class WinoMailCollection : ObservableRecipient, IRecipient FindGroupContainingItem(IMailListItem item)
{
+ // Try cache first
+ if (_itemToGroupMap.TryGetValue(item, out var cachedGroup))
+ {
+ return cachedGroup;
+ }
+
+ // Fallback to search if not in cache
foreach (var group in _mailItemSource)
{
if (group.Contains(item))
{
+ _itemToGroupMap[item] = group;
return group;
}
}
@@ -359,9 +392,38 @@ public class WinoMailCollection : ObservableRecipient, IRecipient();
- var processedItems = new HashSet();
+ var itemsList = items as List ?? items.ToList();
+ if (itemsList.Count == 0) return;
+
+ var itemsToAdd = new List(itemsList.Count);
+ var processedItems = new HashSet(itemsList.Count);
+ var itemsToUpdate = new List<(MailItemViewModel existing, MailCopy updated)>();
+ var threadingOperations = new List<(ObservableGroup