diff --git a/Directory.Packages.props b/Directory.Packages.props index e824166e..81cc018f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -52,7 +52,7 @@ - + @@ -64,7 +64,7 @@ - + diff --git a/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs b/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs new file mode 100644 index 00000000..0123e97f --- /dev/null +++ b/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs @@ -0,0 +1,19 @@ +using System; +using SQLite; + +namespace Wino.Core.Domain.Entities.Mail; + +public class MailItemQueue +{ + [PrimaryKey] + public Guid Id { get; set; } + public Guid AccountId { get; set; } + public string RemoteServerId { get; set; } + public bool IsProcessed { get; set; } + public int FailedCount { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? ProcessedAt { get; set; } + + public bool IsRecent() => (DateTime.UtcNow - CreatedAt).TotalDays <= 7; + public bool ShouldDelete() => IsProcessed || FailedCount >= 30; +} diff --git a/Wino.Core.Domain/Entities/Shared/MailAccount.cs b/Wino.Core.Domain/Entities/Shared/MailAccount.cs index 8f40f70d..889d1729 100644 --- a/Wino.Core.Domain/Entities/Shared/MailAccount.cs +++ b/Wino.Core.Domain/Entities/Shared/MailAccount.cs @@ -33,6 +33,11 @@ public class MailAccount /// public MailProviderType ProviderType { get; set; } + /// + /// Gets or sets the initial mail sync status for the account. + /// + public InitialSynchronizationStatus SynchronizationStatus { get; set; } + /// /// For tracking mail change delta. /// Gmail : historyId diff --git a/Wino.Core.Domain/Enums/InitialSynchronizationStatus.cs b/Wino.Core.Domain/Enums/InitialSynchronizationStatus.cs new file mode 100644 index 00000000..0fb7fb97 --- /dev/null +++ b/Wino.Core.Domain/Enums/InitialSynchronizationStatus.cs @@ -0,0 +1,8 @@ +namespace Wino.Core.Domain.Enums; + +public enum InitialSynchronizationStatus +{ + None, + IdsFetched, + Completed +} diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs index e6dfd7aa..d6824e80 100644 --- a/Wino.Core.Domain/Interfaces/IMailService.cs +++ b/Wino.Core.Domain/Interfaces/IMailService.cs @@ -162,4 +162,9 @@ public interface IMailService /// Retrieved MailCopy ids from search result. /// Result model that contains added and removed mail copy ids. Task GetGmailArchiveComparisonResultAsync(Guid archiveFolderId, List onlineArchiveMailIds); + Task ClearMailItemQueueAsync(Guid accountId); + Task AddMailItemQueueItemsAsync(IEnumerable queueItems); + Task GetMailItemQueueCountAsync(Guid accountId); + Task> GetMailItemQueueAsync(Guid accountId, int take); + Task UpdateMailItemQueueAsync(IEnumerable queueItems); } diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index 8e19322a..bbfe9a90 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -1,17 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Web; using Google.Apis.Calendar.v3.Data; using Google.Apis.Gmail.v1.Data; -using MimeKit; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; using Wino.Core.Misc; using Wino.Services; -using Wino.Services.Extensions; namespace Wino.Core.Extensions; @@ -121,41 +118,6 @@ public static class GoogleIntegratorExtensions return GetNormalizedLabelName(lastPart); } - /// - /// Returns MailCopy out of native Gmail message and converted MimeMessage of that native messaage. - /// - /// Gmail Message - /// MimeMessage representation of that native message. - /// MailCopy object that is ready to be inserted to database. - public static MailCopy AsMailCopy(this Message gmailMessage, MimeMessage mimeMessage) - { - bool isUnread = gmailMessage.GetIsUnread(); - bool isFocused = gmailMessage.GetIsFocused(); - bool isFlagged = gmailMessage.GetIsFlagged(); - bool isDraft = gmailMessage.GetIsDraft(); - - return new MailCopy() - { - CreationDate = mimeMessage.Date.UtcDateTime, - Subject = HttpUtility.HtmlDecode(mimeMessage.Subject), - FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage), - FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage), - PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet), - ThreadId = gmailMessage.ThreadId, - Importance = (MailImportance)mimeMessage.Importance, - Id = gmailMessage.Id, - IsDraft = isDraft, - HasAttachments = mimeMessage.Attachments.Any(), - IsRead = !isUnread, - IsFlagged = isFlagged, - IsFocused = isFocused, - InReplyTo = mimeMessage.InReplyTo, - MessageId = mimeMessage.MessageId, - References = mimeMessage.References.GetReferences(), - FileId = Guid.NewGuid() - }; - } - public static List GetRemoteAliases(this ListSendAsResponse response) { return response?.SendAs?.Select(a => new RemoteAccountAlias() diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index 14247be8..a74504f0 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -65,6 +65,11 @@ public interface IDefaultChangeProcessor Task IsMailExistsInFolderAsync(string messageId, Guid folderId); Task> AreMailsExistsAsync(IEnumerable mailCopyIds); Task UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string synchronizationDeltaIdentifier); + Task ClearMailItemQueueAsync(Guid accountId); + Task AddMailItemQueueItemsAsync(IEnumerable queueItems); + Task GetMailItemQueueCountAsync(Guid accountId); + Task> GetMailItemQueueAsync(Guid accountId, int take); + Task UpdateMailItemQueueAsync(IEnumerable queueItems); } public interface IGmailChangeProcessor : IDefaultChangeProcessor @@ -208,6 +213,21 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, public Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken) => CalendarService.UpdateCalendarDeltaSynchronizationToken(calendarId, deltaToken); + public Task ClearMailItemQueueAsync(Guid accountId) + => MailService.ClearMailItemQueueAsync(accountId); + + public Task AddMailItemQueueItemsAsync(IEnumerable queueItems) + => MailService.AddMailItemQueueItemsAsync(queueItems); + + public Task GetMailItemQueueCountAsync(Guid accountId) + => MailService.GetMailItemQueueCountAsync(accountId); + + public Task> GetMailItemQueueAsync(Guid accountId, int take) + => MailService.GetMailItemQueueAsync(accountId, take); + + public Task UpdateMailItemQueueAsync(IEnumerable queueItems) + => MailService.UpdateMailItemQueueAsync(queueItems); + public async Task DeleteUserMailCacheAsync(Guid accountId) { await _mimeFileService.DeleteUserMimeCacheAsync(accountId).ConfigureAwait(false); diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 63bb9a80..f2a921e6 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -42,13 +42,31 @@ using CalendarService = Google.Apis.Calendar.v3.CalendarService; namespace Wino.Core.Synchronizers.Mail; +/// +/// Gmail synchronizer implementation. +/// +/// IMPORTANT: This synchronizer implements METADATA-ONLY synchronization strategy: +/// - During sync (initial/delta), only message metadata is downloaded (headers, labels, snippet) +/// - NO raw MIME content is downloaded during synchronization +/// - Messages are created with Format=Metadata which populates Payload.Headers but NOT Raw content +/// - MIME files are downloaded on-demand only when user explicitly reads a message +/// - This dramatically reduces bandwidth usage and sync time, especially for accounts with many messages +/// +/// Key implementation details: +/// - CreateNewMailPackagesAsync: Creates MailCopy from metadata, passes null for MimeMessage +/// - CreateMinimalMailCopyAsync: Extracts all MailCopy fields from Gmail Metadata format (Payload.Headers) +/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested +/// - CreateSingleMessageGet: Always uses Metadata format (not Raw format) for synchronization +/// public class GmailSynchronizer : WinoSynchronizer, IHttpClientFactory { public override uint BatchModificationSize => 1000; + /// /// Maximum messages to fetch per folder during initial sync (1500). - /// For each folder: first 50 messages are downloaded with MIME, next 500 with metadata only. - /// Messages beyond 550 per folder are skipped during initial sync. + /// All messages are downloaded with METADATA ONLY - no raw MIME content. + /// Uses Gmail API's Metadata format which includes headers, labels, and snippet but NOT full message body. + /// public override uint InitialMessageDownloadCountPerFolder => 1500; // It's actually 100. But Gmail SDK has internal bug for Out of Memory exception. @@ -67,9 +85,6 @@ public class GmailSynchronizer : WinoSynchronizer _folderDownloadCounts = new(); - public GmailSynchronizer(MailAccount account, IGmailAuthenticator authenticator, IGmailChangeProcessor gmailChangeProcessor, @@ -169,256 +184,229 @@ public class GmailSynchronizer : WinoSynchronizer(); - - var deltaChanges = new List(); // For tracking delta changes. - var listChanges = new List(); // For tracking initial sync changes. - - /* Processing flow order is important to preserve the validity of history. - * 1 - Process added mails. Because we need to create the mail first before assigning it to labels. - * 2 - Process label assignments. - * 3 - Process removed mails. - * This affects reporting progres if done individually for each history change. - * Therefore we need to process all changes in one go after the fetch. - */ - - if (isInitialSync) + if (Account.SynchronizationStatus == InitialSynchronizationStatus.None) { - // Get all folders that need synchronization - var folders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false); - var syncFolders = folders.Where(f => - f.IsSynchronizationEnabled && - f.SpecialFolderType != SpecialFolderType.Category && - f.SpecialFolderType != SpecialFolderType.Archive).ToList(); - - // Reset folder download counts for this sync - _folderDownloadCounts.Clear(); - - // Download messages for each folder separately - foreach (var folder in syncFolders) - { - var messageRequest = _gmailService.Users.Messages.List("me"); - messageRequest.MaxResults = InitialMessageDownloadCountPerFolder; - messageRequest.LabelIds = new[] { folder.RemoteFolderId }; - // messageRequest.OrderBy = "internalDate desc"; // Get latest messages first - messageRequest.IncludeSpamTrash = true; - - string nextPageToken = null; - uint downloadedCount = 0; - int folderMimeDownloadCount = 0; - int folderMetadataDownloadCount = 0; - const int maxMetadataOnlyMessagesPerLabel = 500; - - do - { - if (!string.IsNullOrEmpty(nextPageToken)) - { - messageRequest.PageToken = nextPageToken; - } - - var result = await messageRequest.ExecuteAsync(cancellationToken); - nextPageToken = result.NextPageToken; - - if (result.Messages != null) - { - downloadedCount += (uint)result.Messages.Count; - listChanges.Add(result); - - // Process messages in this folder: first 50 with MIME, next 500 with metadata only - foreach (var message in result.Messages) - { - if (folderMimeDownloadCount < InitialSyncMimeDownloadCount) - { - // Download with MIME for first 50 messages - await DownloadSingleMessageAsync(message.Id, true, cancellationToken).ConfigureAwait(false); - folderMimeDownloadCount++; - _logger.Debug("Downloaded MIME message {MessageId} ({Count}/{MaxCount}) for folder {FolderName}", - message.Id, folderMimeDownloadCount, InitialSyncMimeDownloadCount, folder.FolderName); - } - else if (folderMetadataDownloadCount < maxMetadataOnlyMessagesPerLabel) - { - // Download metadata only for next 500 messages - await DownloadSingleMessageAsync(message.Id, false, cancellationToken).ConfigureAwait(false); - folderMetadataDownloadCount++; - _logger.Debug("Downloaded metadata message {MessageId} ({Count}/{MaxCount}) for folder {FolderName}", - message.Id, folderMetadataDownloadCount, maxMetadataOnlyMessagesPerLabel, folder.FolderName); - } - // Messages beyond 50 MIME + 500 metadata are skipped for initial sync - } - } - - // Stop if we've downloaded enough messages for this folder or reached the metadata limit - if (downloadedCount >= InitialMessageDownloadCountPerFolder || - (folderMimeDownloadCount >= InitialSyncMimeDownloadCount && folderMetadataDownloadCount >= maxMetadataOnlyMessagesPerLabel)) - { - break; - } - - } while (!string.IsNullOrEmpty(nextPageToken)); - - _logger.Information("Downloaded {Count} messages for folder {Folder} (first {MimeCount} with MIME, {MetadataCount} metadata-only)", - downloadedCount, folder.FolderName, Math.Min(folderMimeDownloadCount, InitialSyncMimeDownloadCount), folderMetadataDownloadCount); - - // Track how many messages we've downloaded with MIME for this folder - _folderDownloadCounts[folder.RemoteFolderId] = folderMimeDownloadCount; - } - } - else - { - var startHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier); - var nextPageToken = ulong.Parse(Account.SynchronizationDeltaIdentifier).ToString(); - - var historyRequest = _gmailService.Users.History.List("me"); - historyRequest.StartHistoryId = startHistoryId; - - try - { - while (!string.IsNullOrEmpty(nextPageToken)) - { - // If this is the first delta check, start from the last history id. - // Otherwise start from the next page token. We set them both to the same value for start. - // For each different page we set the page token to the next page token. - - bool isFirstDeltaCheck = nextPageToken == startHistoryId.ToString(); - - if (!isFirstDeltaCheck) - historyRequest.PageToken = nextPageToken; - - var historyResponse = await historyRequest.ExecuteAsync(cancellationToken); - - nextPageToken = historyResponse.NextPageToken; - - if (historyResponse.History == null) - continue; - - deltaChanges.Add(historyResponse); - } - } - catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound) - { - // History ID is too old or expired, need to do a full sync. - // Theoratically we need to delete the local cache and start from scratch. - - _logger.Warning("History ID {StartHistoryId} is expired for {Name}. Will remove user's mail cache and do full sync.", startHistoryId, Account.Name); - - await _gmailChangeProcessor.DeleteUserMailCacheAsync(Account.Id).ConfigureAwait(false); - - Account.SynchronizationDeltaIdentifier = string.Empty; - - await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); - - goto retry; - } + await FetchAllEmailIdsAsync().ConfigureAwait(false); + await CompleteAccountSyncStatusAsync(); } - // For delta sync or messages beyond the MIME download limit, add to missing messages list - if (!isInitialSync) + if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier)) { - // For delta sync, add all message IDs - they will be downloaded with MIME - missingMessageIds.AddRange(listChanges.Where(a => a.Messages != null).SelectMany(a => a.Messages).Select(a => a.Id)); - } - else - { - // For initial sync, messages are now downloaded immediately during folder processing - // (first 50 with MIME, next 500 metadata-only per folder) - // No remaining messages to process here - _logger.Information("Initial sync completed: messages downloaded per folder (50 MIME + up to 500 metadata-only)"); + await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false); + await CompleteAccountSyncStatusAsync(); } - // Add missing message ids from delta changes. - foreach (var historyResponse in deltaChanges) + if (Account.SynchronizationStatus == InitialSynchronizationStatus.IdsFetched) { - var addedMessageIds = historyResponse.History - .Where(a => a.MessagesAdded != null) - .SelectMany(a => a.MessagesAdded) - .Where(a => a.Message != null) - .Select(a => a.Message.Id); - - missingMessageIds.AddRange(addedMessageIds); + await ProcessEmailMetadataFromQueueAsync(cancellationToken); } - // Start downloading remaining messages (metadata only for initial sync beyond first 50, full download for delta sync). - foreach (var messageId in missingMessageIds) + if (Account.SynchronizationStatus == InitialSynchronizationStatus.Completed) { - // For initial sync, download without MIME (metadata only) since MIME was already downloaded for first 50 - // For delta sync, download with MIME as usual - bool downloadMime = !isInitialSync; - await DownloadSingleMessageAsync(messageId, downloadMime, cancellationToken).ConfigureAwait(false); + } - // Map archive assignments if there are any changes reported. - if (listChanges.Any() || deltaChanges.Any()) - { - await MapArchivedMailsAsync(cancellationToken).ConfigureAwait(false); - } - - // Map remote drafts to local drafts. - await MapDraftIdsAsync(cancellationToken).ConfigureAwait(false); - - // Start processing delta changes. - foreach (var historyResponse in deltaChanges) - { - await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false); - } - - // Take the max history id from delta changes and update the account sync modifier. - - if (deltaChanges.Any()) - { - var maxHistoryId = deltaChanges.Where(a => a.HistoryId != null).Max(a => a.HistoryId); - - await UpdateAccountSyncIdentifierAsync(maxHistoryId); - - if (maxHistoryId != null) - { - // TODO: This is not good. Centralize the identifier fetch and prevent direct access here. - // Account.SynchronizationDeltaIdentifier = await _gmailChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, maxHistoryId.ToString()).ConfigureAwait(false); - - _logger.Debug("Final sync identifier {SynchronizationDeltaIdentifier}", Account.SynchronizationDeltaIdentifier); - } - } - - // Get all unred new downloaded items and return in the result. - // This is primarily used in notifications. - - var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, missingMessageIds).ConfigureAwait(false); - - return MailSynchronizationResult.Completed(unreadNewItems); + return MailSynchronizationResult.Completed(new List()); } - private async Task DownloadSingleMessageAsync(string messageId, CancellationToken cancellationToken = default) - { - await DownloadSingleMessageAsync(messageId, true, cancellationToken).ConfigureAwait(false); - } + #region Queue System - private async Task DownloadSingleMessageAsync(string messageId, bool downloadMime, CancellationToken cancellationToken = default) + private async Task FetchAllEmailIdsAsync() { - // Google .NET SDK has memory issues with batch downloading messages which will not be fixed since the library is in maintenance mode. - // https://github.com/googleapis/google-api-dotnet-client/issues/2603 - // This method will be used to download messages one by one to prevent memory spikes. - try { - var singleRequest = CreateSingleMessageGet(messageId, downloadMime); - var downloadedMessage = await singleRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false); + // If this method is hit, we don't need previous state for this table, + // we just clean it first to make sure nothing was left before. + await _gmailChangeProcessor.ClearMailItemQueueAsync(Account.Id).ConfigureAwait(false); - if (downloadMime) - { - await HandleSingleItemDownloadedCallbackAsync(downloadedMessage, null, messageId, cancellationToken).ConfigureAwait(false); - } - else - { - await HandleSingleItemDownloadedCallbackMinimalAsync(downloadedMessage, null, messageId, cancellationToken).ConfigureAwait(false); - } + var totalFetched = 0; + string? pageToken = null; - await UpdateAccountSyncIdentifierAsync(downloadedMessage.HistoryId).ConfigureAwait(false); + do + { + try + { + // Use maximum page size of 500 for efficiency + var request = _gmailService.Users.Messages.List("me"); + request.MaxResults = 500; + request.IncludeSpamTrash = true; + request.PageToken = pageToken; + + var response = await request.ExecuteAsync(); + + if (response.Messages != null) + { + var queueEntries1 = response.Messages.Select(x => new MailItemQueue + { + Id = Guid.CreateVersion7(), + AccountId = Account.Id, + RemoteServerId = x.Id, + IsProcessed = false, + CreatedAt = DateTime.UtcNow + }); + + await _gmailChangeProcessor.AddMailItemQueueItemsAsync(queueEntries1).ConfigureAwait(false); + + totalFetched += queueEntries1.Count(); + } + + pageToken = response.NextPageToken; + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + // Handle 429 rate limit errors by waiting and retrying + _logger.Warning("Rate limit exceeded while fetching email IDs for {Name}. Retrying after delay.", Account.Name); + + await Task.Delay(TimeSpan.FromSeconds(10)); + continue; // Retry the same page + } + } while (!string.IsNullOrEmpty(pageToken)); } catch (Exception ex) { - _logger.Error(ex, "Error while downloading message {MessageId} for {Name}", messageId, Account.Name); + throw; } } + private async Task CompleteAccountSyncStatusAsync() + { + // Set history ID immediately after fetching email IDs for future incremental syncs + var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(); + Account.SynchronizationDeltaIdentifier = profile.HistoryId.ToString(); + + // Update account sync status + Account.SynchronizationStatus = InitialSynchronizationStatus.IdsFetched; + + await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); + } + + private async Task SynchronizeDeltaAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default) + { + try + { + var historyRequest = _gmailService.Users.History.List("me"); + historyRequest.StartHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier!); + + var historyResponse = await historyRequest.ExecuteAsync(); + + if (historyResponse.History != null) + { + var addedMessageIds = new List(); + + // Collect all added messages first + foreach (var historyRecord in historyResponse.History) + { + if (historyRecord.MessagesAdded != null) + { + addedMessageIds.AddRange(historyRecord.MessagesAdded.Select(ma => ma.Message.Id)); + } + } + + // Process added messages in batches if any + // During delta sync, download with Raw format to get MIME content + if (addedMessageIds.Count != 0) + { + await DownloadMessagesInBatchAsync(addedMessageIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false); + } + + // Process other history changes + foreach (var historyRecord in historyResponse.History) + { + await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false); + } + } + } + catch (Exception) + { + + throw; + } + } + + private async Task ProcessEmailMetadataFromQueueAsync(CancellationToken cancellationToken = default) + { + // Get total count for progress tracking + var totalInQueue = await _gmailChangeProcessor.GetMailItemQueueCountAsync(Account.Id).ConfigureAwait(false); + + try + { + var totalFailed = 0; + + // Continue until all emails in queue are processed + while (true) + { + // Get next batch of unprocessed emails from queue + var mailItemQueue = await _gmailChangeProcessor.GetMailItemQueueAsync(Account.Id, 100).ConfigureAwait(false); + + if (mailItemQueue.Count == 0) + break; // No more emails to process + + var messageIds = mailItemQueue.Select(q => q.RemoteServerId).ToList(); + + try + { + // Remove the deleted items from queue first + mailItemQueue.RemoveAll(a => a.ShouldDelete()); + + var mailChunks = mailItemQueue.Chunk(100); + + foreach (var chunk in mailChunks) + { + // Collect message IDs from the chunk + var messageIdsToDownload = chunk.Select(q => q.RemoteServerId).ToList(); + + try + { + // Download all messages in this chunk using batch API + await DownloadMessagesInBatchAsync(messageIdsToDownload, cancellationToken).ConfigureAwait(false); + + // Mark all items in chunk as processed + foreach (var queueItem in chunk) + { + queueItem.IsProcessed = true; + queueItem.ProcessedAt = DateTime.UtcNow; + } + } + catch (Exception) + { + // Mark all items in chunk as failed + foreach (var queueItem in chunk) + { + queueItem.IsProcessed = false; + queueItem.ProcessedAt = null; + queueItem.FailedCount++; + totalFailed++; + } + } + + await _gmailChangeProcessor.UpdateMailItemQueueAsync(mailItemQueue).ConfigureAwait(false); + + // If too many failures, pause to avoid hitting rate limits + if (totalFailed > 85) await Task.Delay(TimeSpan.FromSeconds(10)); + } + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + // Handle 429 rate limit errors by waiting and retrying + await Task.Delay(TimeSpan.FromSeconds(10)); + continue; // Retry the same batch + } + } + + // Update account sync status to completed + Account.SynchronizationStatus = InitialSynchronizationStatus.Completed; + + await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); + } + catch (Exception) + { + throw; + } + } + + + #endregion + protected override async Task SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) { _logger.Information("Internal calendar synchronization started for {Name}", Account.Name); @@ -737,21 +725,35 @@ public class GmailSynchronizer : WinoSynchronizer - /// Returns a single get request to retrieve the message with the given id + /// Returns a single get request to retrieve the message with the given id. + /// Always uses Metadata format to download only headers and labels - NOT raw MIME content. + /// MIME content is only downloaded when explicitly needed via DownloadMissingMimeMessageAsync. /// /// Message to download. - /// True to download raw MIME content, false to download metadata only (headers, labels, etc.) - /// Get request for message with appropriate format. - private UsersResource.MessagesResource.GetRequest CreateSingleMessageGet(string messageId, bool downloadMime = true) + /// Get request for message with Metadata format. + private UsersResource.MessagesResource.GetRequest CreateSingleMessageGet(string messageId) { var singleRequest = _gmailService.Users.Messages.Get("me", messageId); - - // Use Raw format when downloading MIME content, Metadata format when downloading headers only - // This is critical: Raw format doesn't populate Payload.Headers, Metadata format does! - // Previously always used Raw format, causing FromAddress/FromName to be empty when downloadMime=false - singleRequest.Format = downloadMime - ? UsersResource.MessagesResource.GetRequest.FormatEnum.Raw - : UsersResource.MessagesResource.GetRequest.FormatEnum.Metadata; + + // Always use Metadata format for synchronization - this populates Payload.Headers + // but does NOT download the raw MIME content, saving significant bandwidth and time + singleRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Metadata; + + return singleRequest; + } + + /// + /// Returns a single get request to retrieve the message with Raw format (includes MIME). + /// Used during delta sync to download full message content. + /// + /// Message to download. + /// Get request for message with Raw format. + private UsersResource.MessagesResource.GetRequest CreateSingleMessageGetRaw(string messageId) + { + var singleRequest = _gmailService.Users.Messages.Get("me", messageId); + + // Use Raw format to get full MIME content + singleRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw; return singleRequest; } @@ -763,7 +765,7 @@ public class GmailSynchronizer : WinoSynchronizerList of history changes. private async Task ProcessHistoryChangesAsync(ListHistoryResponse listHistoryResponse) { - _logger.Debug("Processing delta change {HistoryId} for {Name}", Account.Name, listHistoryResponse.HistoryId.GetValueOrDefault()); + _logger.Debug("Processing delta change {HistoryId} for {Name}", listHistoryResponse.HistoryId.GetValueOrDefault(), Account.Name); foreach (var history in listHistoryResponse.History) { @@ -1094,17 +1096,167 @@ public class GmailSynchronizer : WinoSynchronizer + /// Downloads multiple messages in batches with metadata only (no MIME) and creates mail packages. + /// Uses Gmail batch API to download up to MaximumAllowedBatchRequestSize messages per request. + /// Used for initial sync where MIME is not needed. + /// + /// List of Gmail message IDs to download + /// Cancellation token + private async Task DownloadMessagesInBatchAsync(IEnumerable messageIds, CancellationToken cancellationToken = default) + { + await DownloadMessagesInBatchAsync(messageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false); + } + + /// + /// Downloads multiple messages in batches with optional MIME content and creates mail packages. + /// Uses Gmail batch API to download up to MaximumAllowedBatchRequestSize messages per request. + /// + /// List of Gmail message IDs to download + /// True to download Raw format with MIME, false for Metadata only + /// Cancellation token + private async Task DownloadMessagesInBatchAsync(IEnumerable messageIds, bool downloadRawMime, CancellationToken cancellationToken = default) + { + var messageIdList = messageIds.ToList(); + if (messageIdList.Count == 0) return; + + // Split into batches based on MaximumAllowedBatchRequestSize + var batches = messageIdList.Batch((int)MaximumAllowedBatchRequestSize); + + foreach (var batch in batches) + { + var batchRequest = new BatchRequest(_gmailService); + var downloadedMessages = new List(); + var batchTasks = new List(); + + foreach (var messageId in batch) + { + var request = downloadRawMime ? CreateSingleMessageGetRaw(messageId) : CreateSingleMessageGet(messageId); + + batchRequest.Queue(request, (message, error, index, httpMessage) => + { + var task = Task.Run(async () => + { + if (error != null) + { + _logger.Warning("Failed to download message {MessageId}: {Error}", messageId, error.Message); + return; + } + + if (message != null) + { + lock (downloadedMessages) + { + downloadedMessages.Add(message); + } + } + }); + + batchTasks.Add(task); + }); + } + + // Execute the batch request + await batchRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false); + await Task.WhenAll(batchTasks).ConfigureAwait(false); + + // Process all downloaded messages + foreach (var gmailMessage in downloadedMessages) + { + try + { + MimeMessage mimeMessage = null; + + // Extract MIME if we downloaded raw format + if (downloadRawMime) + { + mimeMessage = gmailMessage.GetGmailMimeMessage(); + + if (mimeMessage == null) + { + _logger.Warning("Failed to parse MIME for message {MessageId}", gmailMessage.Id); + } + } + + // Create mail packages from metadata (or raw if downloaded) + var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false); + + if (packages != null) + { + // For Gmail, multiple packages can share the same message (different labels/folders) + // They should all share the same FileId so MIME is stored only once + Guid sharedFileId = Guid.NewGuid(); + + foreach (var package in packages) + { + // Set the same FileId for all copies + package.Copy.FileId = sharedFileId; + + // Create the mail copy with the MIME (if downloaded) + var packageWithMime = downloadRawMime && mimeMessage != null + ? new NewMailItemPackage(package.Copy, mimeMessage, package.AssignedRemoteFolderId) + : package; + + await _gmailChangeProcessor.CreateMailAsync(Account.Id, packageWithMime).ConfigureAwait(false); + } + } + + // Update sync identifier if available + if (gmailMessage.HistoryId.HasValue) + { + await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId.Value).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to process downloaded message {MessageId}", gmailMessage.Id); + } + } + } + } + + /// + /// Downloads a single message by ID with metadata only (no MIME) and creates mail packages. + /// + /// Gmail message ID to download + /// Cancellation token + private async Task DownloadSingleMessageMetadataAsync(string messageId, CancellationToken cancellationToken = default) + { + var request = CreateSingleMessageGet(messageId); + var gmailMessage = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false); + + if (gmailMessage == null) + { + _logger.Warning("Failed to download message metadata for {MessageId}", messageId); + return; + } + + // Create mail packages from metadata + var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false); + + if (packages != null) + { + foreach (var package in packages) + { + await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); + } + } + + // Update sync identifier if available + if (gmailMessage.HistoryId.HasValue) + { + await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId.Value).ConfigureAwait(false); + } + } + public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) @@ -1230,111 +1382,6 @@ public class GmailSynchronizer : WinoSynchronizer - /// Handles after each single message download with minimal metadata only (no MIME). - /// This involves adding the Gmail message into Wino database with minimal properties. - /// - /// Gmail message - /// Request error - /// Message ID being downloaded - /// Cancellation token - private async Task HandleSingleItemDownloadedCallbackMinimalAsync(Message message, - RequestError error, - string downloadingMessageId, - CancellationToken cancellationToken = default) - { - try - { - await ProcessGmailRequestErrorAsync(error, null); - } - catch (OutOfMemoryException) - { - _logger.Warning("Gmail SDK got OutOfMemoryException due to bug in the SDK"); - } - catch (SynchronizerEntityNotFoundException) - { - _logger.Warning("Resource not found for {DownloadingMessageId}", downloadingMessageId); - } - catch (SynchronizerException synchronizerException) - { - _logger.Error("Gmail SDK returned error for {DownloadingMessageId}\n{SynchronizerException}", downloadingMessageId, synchronizerException); - } - - if (message == null) - { - _logger.Warning("Skipped GMail message download for {DownloadingMessageId}", downloadingMessageId); - - return null; - } - - // Use minimal package creation for metadata-only download - var mailPackage = await CreateNewMailPackagesMinimalAsync(message, cancellationToken); - - // If CreateNewMailPackagesMinimalAsync returns null it means local draft mapping is done. - // We don't need to insert anything else. - if (mailPackage == null) return message; - - foreach (var package in mailPackage) - { - await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); - } - - return message; - } - - /// - /// Handles after each single message download. - /// This involves adding the Gmail message into Wino database. - /// - /// - /// - /// - /// - private async Task HandleSingleItemDownloadedCallbackAsync(Message message, - RequestError error, - string downloadingMessageId, - CancellationToken cancellationToken = default) - { - try - { - await ProcessGmailRequestErrorAsync(error, null); - } - catch (OutOfMemoryException) - { - _logger.Warning("Gmail SDK got OutOfMemoryException due to bug in the SDK"); - } - catch (SynchronizerEntityNotFoundException) - { - _logger.Warning("Resource not found for {DownloadingMessageId}", downloadingMessageId); - } - catch (SynchronizerException synchronizerException) - { - _logger.Error("Gmail SDK returned error for {DownloadingMessageId}\n{SynchronizerException}", downloadingMessageId, synchronizerException); - } - - if (message == null) - { - _logger.Warning("Skipped GMail message download for {DownloadingMessageId}", downloadingMessageId); - - return null; - } - - // Gmail has LabelId property for each message. - // Therefore we can pass null as the assigned folder safely. - var mailPackage = await CreateNewMailPackagesAsync(message, null, cancellationToken); - - // If CreateNewMailPackagesAsync returns null it means local draft mapping is done. - // We don't need to insert anything else. - if (mailPackage == null) return message; - - foreach (var package in mailPackage) - { - await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); - } - - return message; - } - private bool ShouldUpdateSyncIdentifier(ulong? historyId) { if (historyId == null) return false; @@ -1366,15 +1413,21 @@ public class GmailSynchronizer : WinoSynchronizer folderBundle) { - var gmailLabel = await folderBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken).ConfigureAwait(false); - - if (gmailLabel == null) return; - // TODO: Handle new Gmail Label added or updated. } else if (bundle is HttpRequestBundle draftBundle && draftBundle.Request is CreateDraftRequest createDraftRequest) @@ -1480,13 +1533,7 @@ public class GmailSynchronizer : WinoSynchronizer - /// Creates a minimal MailCopy from Gmail message without downloading MIME content. - /// This includes only the information available from the Gmail message metadata. - /// - /// Gmail message - /// MailCopy with minimal properties - private static MailCopy CreateMinimalMailCopy(Message gmailMessage) + protected override Task CreateMinimalMailCopyAsync(Message gmailMessage, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) { bool isUnread = gmailMessage.GetIsUnread(); bool isFocused = gmailMessage.GetIsFocused(); @@ -1495,7 +1542,7 @@ public class GmailSynchronizer : WinoSynchronizer h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? ""; + var (fromName, fromAddress) = ExtractNameAndEmailFromHeader(fromHeaderValue); + + var copy = new MailCopy() { CreationDate = creationDate, Subject = HttpUtility.HtmlDecode(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Subject", StringComparison.OrdinalIgnoreCase))?.Value ?? ""), - FromName = HttpUtility.HtmlDecode(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? ""), - FromAddress = ExtractEmailFromHeader(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? ""), - PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet), + FromName = HttpUtility.HtmlDecode(fromName), + FromAddress = fromAddress, + PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet ?? "").Trim(), ThreadId = gmailMessage.ThreadId, - Importance = MailImportance.Normal, // Default importance without MIME + Importance = MailImportance.Normal, // Default importance without MIME parsing Id = gmailMessage.Id, IsDraft = isDraft, HasAttachments = gmailMessage.Payload?.Parts?.Any(p => !string.IsNullOrEmpty(p.Filename)) ?? false, @@ -1531,56 +1582,43 @@ public class GmailSynchronizer : WinoSynchronizer h.Name.Equals("References", StringComparison.OrdinalIgnoreCase))?.Value, FileId = Guid.NewGuid() }; + + // Set DraftId if this is a draft + if (copy.IsDraft) + copy.DraftId = copy.ThreadId; + + return Task.FromResult(copy); } /// - /// Extracts email address from a header value like "Name " or "email@domain.com" + /// Extracts name and email address from a header value like "Name " or "email@domain.com" /// - private static string ExtractEmailFromHeader(string headerValue) + private static (string name, string email) ExtractNameAndEmailFromHeader(string headerValue) { - if (string.IsNullOrEmpty(headerValue)) return ""; + if (string.IsNullOrEmpty(headerValue)) + return ("", ""); - var match = System.Text.RegularExpressions.Regex.Match(headerValue, @"<(.+?)>"); + // Try to match "Name " format + var match = System.Text.RegularExpressions.Regex.Match(headerValue, @"^(.+?)\s*<(.+?)>$"); if (match.Success) - return match.Groups[1].Value; - - // If no angle brackets, assume the whole value is the email - return headerValue.Trim(); - } - - /// - /// Creates new mail packages for the given message with minimal metadata only (no MIME download). - /// This is used for messages beyond the initial MIME download limit during synchronization. - /// - /// Gmail message to create package for. - /// Cancellation token - /// New mail package with minimal metadata only. - private async Task> CreateNewMailPackagesMinimalAsync(Message message, CancellationToken cancellationToken = default) - { - var packageList = new List(); - - // Create mail copy with minimal properties - no MIME download - var mailCopy = CreateMinimalMailCopy(message); - - // Since this is metadata-only download, we can't check for draft mapping via MIME headers - // Draft mapping will be handled during delta synchronization or when MIME is downloaded on-demand - - if (message.LabelIds is not null) { - foreach (var labelId in message.LabelIds) - { - packageList.Add(new NewMailItemPackage(mailCopy, null, labelId)); - } + var name = match.Groups[1].Value.Trim().Trim('"'); + var email = match.Groups[2].Value.Trim(); + return (name, email); } - return packageList; + // If no angle brackets, assume the whole value is the email with no name + var emailOnly = headerValue.Trim(); + return ("", emailOnly); } /// /// Creates new mail packages for the given message. /// AssignedFolder is null since the LabelId is parsed out of the Message. + /// NOTE: This method does NOT download MIME content during synchronization. + /// MIME is only downloaded when user explicitly reads the message. /// - /// Gmail message to create package for. + /// Gmail message to create package for (must have Metadata format). /// Null, not used. /// Cancellation token /// New mail package that change processor can use to insert new mail into database. @@ -1590,33 +1628,33 @@ public class GmailSynchronizer : WinoSynchronizer(); - MimeMessage mimeMessage = message.GetGmailMimeMessage(); - var mailCopy = message.AsMailCopy(mimeMessage); + // Create MailCopy from metadata only - NO MIME download + var mailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken); - // Check whether this message is mapped to any local draft. - // Previously we were using Draft resource response as mapping drafts. - // This seem to be a worse approach. Now both Outlook and Gmail use X-Wino-Draft-Id header to map drafts. - // This is a better approach since we don't need to fetch the draft resource to get the draft id. - - if (mailCopy.IsDraft - && mimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader) - && Guid.TryParse(mimeMessage.Headers[Domain.Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId)) + // Check for local draft mapping using X-Wino-Draft-Id header from metadata + if (mailCopy.IsDraft) { - // 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. + var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value; - bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId); + if (!string.IsNullOrEmpty(draftIdHeader) && Guid.TryParse(draftIdHeader, 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. - if (isMappingSuccesfull) return null; + bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId); - // Local copy doesn't exists. Continue execution to insert mail copy. + if (isMappingSuccesfull) return null; + + // Local copy doesn't exists. Continue execution to insert mail copy. + } } if (message.LabelIds is not null) { foreach (var labelId in message.LabelIds) { - packageList.Add(new NewMailItemPackage(mailCopy, mimeMessage, labelId)); + // Pass null for MimeMessage - it will be downloaded later when user reads the mail + packageList.Add(new NewMailItemPackage(mailCopy, null, labelId)); } } diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs index 1d32c489..26eac5d9 100644 --- a/Wino.Core/Synchronizers/WinoSynchronizer.cs +++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs @@ -41,22 +41,27 @@ public abstract class WinoSynchronizer /// How many items must be downloaded per folder when the folder is first synchronized. + /// Only metadata is downloaded during sync - MIME content is fetched on-demand when user reads mail. /// public abstract uint InitialMessageDownloadCountPerFolder { get; } /// - /// Number of MIME messages to download during initial synchronization per folder. - /// For the first messages in each folder during initial sync, both metadata and MIME content will be downloaded. - /// Subsequent messages will only have metadata downloaded, with MIME content fetched on-demand. + /// DEPRECATED: MIME messages are no longer downloaded during synchronization. + /// MIME content is only downloaded when explicitly needed (e.g., when user reads a message). + /// This property is kept for backward compatibility but is no longer used. /// - public virtual int InitialSyncMimeDownloadCount => 50; + [Obsolete("MIME messages are no longer downloaded during sync. Use DownloadMissingMimeMessageAsync instead.")] + public virtual int InitialSyncMimeDownloadCount => 0; /// - /// Creates a new Wino Mail Item package out of native message type with full Mime. + /// Creates a new Wino Mail Item package out of native message type with metadata only. + /// NO MIME content is downloaded during synchronization - only headers and essential metadata. + /// MIME will be downloaded on-demand when user explicitly reads the message. /// /// Native message type for the synchronizer. + /// Folder to assign the mail to. /// Cancellation token - /// Package that encapsulates downloaded Mime and additional information for adding new mail. + /// Package with MailCopy metadata. MimeMessage will be null during sync. public abstract Task> CreateNewMailPackagesAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default); /// @@ -86,13 +91,15 @@ public abstract class WinoSynchronizer /// Creates a MailCopy object with minimal properties from the native message type. - /// This is used for queue-based sync to avoid downloading full MIME messages. - /// Only overridden by synchronizers that support the new queue-based sync. + /// This is used during synchronization to create mail entries WITHOUT downloading MIME content. + /// Only metadata (headers, labels, flags) is extracted from the native message format. + /// MIME content will be downloaded later on-demand when user reads the message. + /// Only overridden by synchronizers that support metadata-only synchronization. /// /// Native message type /// Folder this message belongs to /// Cancellation token - /// MailCopy with minimal properties + /// MailCopy with minimal properties populated from metadata protected virtual Task CreateMinimalMailCopyAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) => Task.FromResult(null); /// diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index ab39f8e2..65a2ff1c 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -543,6 +543,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, { if (IsInitializingFolder || IsOnlineSearchEnabled) return; + Debug.WriteLine("Loading more..."); await ExecuteUIThread(() => { IsInitializingFolder = true; }); var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders, diff --git a/Wino.Mail.WinUI/Views/MailListPage.xaml b/Wino.Mail.WinUI/Views/MailListPage.xaml index 66d4a01d..180a876b 100644 --- a/Wino.Mail.WinUI/Views/MailListPage.xaml +++ b/Wino.Mail.WinUI/Views/MailListPage.xaml @@ -92,8 +92,8 @@ ContextRequested="MailItemContextRequested" CreationDate="{x:Bind CreationDate}" DisplayMode="{Binding ElementName=root, Path=ViewModel.PreferencesService.MailItemDisplayMode, Mode=OneWay}" - FromAddress="{x:Bind FromAddress}" - FromName="{x:Bind FromName}" + FromAddress="{x:Bind FromAddress, Mode=OneWay}" + FromName="{x:Bind FromName, Mode=OneWay}" HasAttachments="{x:Bind HasAttachments, Mode=OneWay}" HoverActionExecutedCommand="{Binding ElementName=root, Path=ViewModel.ExecuteHoverActionCommand}" IsAvatarVisible="{Binding ElementName=root, Path=ViewModel.PreferencesService.IsShowSenderPicturesEnabled, Mode=OneWay}" @@ -302,7 +302,6 @@ Grid.Row="1" Grid.Column="2" Orientation="Horizontal"> -