From e936c431a2cc76facffcf0b61f51bb9a72f0039d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 12 Feb 2026 18:57:55 +0100 Subject: [PATCH] Search improvements. --- Wino.Core/Synchronizers/GmailSynchronizer.cs | 160 +++++++++++---- .../Synchronizers/OutlookSynchronizer.cs | 54 +++-- Wino.Mail.ViewModels/MailListPageViewModel.cs | 188 +++++++++++++----- Wino.Services/MailService.cs | 78 +++++++- 4 files changed, 372 insertions(+), 108 deletions(-) diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 46d0b206..6970cbbb 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -248,6 +248,20 @@ public class GmailSynchronizer : WinoSynchronizer> OnlineSearchAsync(string queryText, List folders, CancellationToken cancellationToken = default) { - var request = _gmailService.Users.Messages.List("me"); - request.Q = queryText; - request.MaxResults = 500; // Max 500 is returned. + if (string.IsNullOrWhiteSpace(queryText)) + return []; - string pageToken = null; + static bool IsArchiveFolder(IMailItemFolder folder) + => folder?.SpecialFolderType == SpecialFolderType.Archive || folder?.RemoteFolderId == ServiceConstants.ARCHIVE_LABEL_ID; - List messagesToDownload = []; + var messageIds = new HashSet(StringComparer.Ordinal); - do + async Task CollectMessageIdsAsync(UsersResource.MessagesResource.ListRequest request) { - if (queryText.StartsWith("label:") || queryText.StartsWith("in:")) + string pageToken = null; + + do { - // Ignore the folders if the query starts with these keywords. - // User is trying to list everything. - } - else if (folders?.Count > 0) + if (!string.IsNullOrEmpty(pageToken)) + { + request.PageToken = pageToken; + } + + var response = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false); + if (response.Messages == null || response.Messages.Count == 0) break; + + foreach (var message in response.Messages) + { + if (!string.IsNullOrEmpty(message.Id)) + { + messageIds.Add(message.Id); + } + } + + pageToken = response.NextPageToken; + } while (!string.IsNullOrEmpty(pageToken)); + } + + bool hasScopedQuery = queryText.StartsWith("label:", StringComparison.OrdinalIgnoreCase) || + queryText.StartsWith("in:", StringComparison.OrdinalIgnoreCase); + + if (hasScopedQuery || folders?.Count == 0) + { + var request = _gmailService.Users.Messages.List("me"); + request.Q = queryText; + request.MaxResults = 500; + + await CollectMessageIdsAsync(request).ConfigureAwait(false); + } + else + { + foreach (var folder in folders) { - request.LabelIds = folders.Select(a => a.RemoteFolderId).ToList(); + cancellationToken.ThrowIfCancellationRequested(); + + var request = _gmailService.Users.Messages.List("me"); + request.MaxResults = 500; + + if (IsArchiveFolder(folder)) + { + // Gmail archive is virtual. Query via search operator instead of label id. + request.Q = $"in:archive {queryText}".Trim(); + } + else + { + request.Q = queryText; + request.LabelIds = new List { folder.RemoteFolderId }; + } + + await CollectMessageIdsAsync(request).ConfigureAwait(false); } + } - if (!string.IsNullOrEmpty(pageToken)) - { - request.PageToken = pageToken; - } + if (messageIds.Count == 0) + return []; - var response = await request.ExecuteAsync(cancellationToken); - if (response.Messages == null) break; + var messageIdList = messageIds.ToList(); - // Handle skipping manually - messagesToDownload.AddRange(response.Messages); + // Do not download messages that already exist locally. + var existingMessageIds = await _gmailChangeProcessor.AreMailsExistsAsync(messageIdList).ConfigureAwait(false); + var messagesToDownload = messageIdList.Except(existingMessageIds, StringComparer.Ordinal); - pageToken = response.NextPageToken; - } while (!string.IsNullOrEmpty(pageToken)); - - // Do not download messages that exists, but return them for listing. - - var messageIds = messagesToDownload.Select(a => a.Id); - - var downloadRequireMessageIds = messageIds.Except(await _gmailChangeProcessor.AreMailsExistsAsync(messageIds)); - - // Download missing messages in batch. - await DownloadMessagesInBatchAsync(downloadRequireMessageIds, cancellationToken).ConfigureAwait(false); + // Download missing messages in batch with metadata only. + await DownloadMessagesInBatchAsync(messagesToDownload, cancellationToken).ConfigureAwait(false); // Get results from database and return. - - return await _gmailChangeProcessor.GetMailCopiesAsync(messageIds); + return await _gmailChangeProcessor.GetMailCopiesAsync(messageIdList).ConfigureAwait(false); } /// @@ -1644,41 +1695,62 @@ public class GmailSynchronizer : WinoSynchronizerCancellation token. private async Task MapArchivedMailsAsync(CancellationToken cancellationToken) { + if (!archiveFolderId.HasValue) return; + var request = _gmailService.Users.Messages.List("me"); request.Q = "in:archive"; - request.MaxResults = InitialMessageDownloadCountPerFolder; + request.MaxResults = 500; string pageToken = null; - var archivedMessageIds = new List(); + var archivedMessageIds = new HashSet(StringComparer.Ordinal); do { if (!string.IsNullOrEmpty(pageToken)) request.PageToken = pageToken; - var response = await request.ExecuteAsync(cancellationToken); + var response = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false); if (response.Messages == null) break; foreach (var message in response.Messages) { - if (archivedMessageIds.Contains(message.Id)) continue; - - archivedMessageIds.Add(message.Id); + if (!string.IsNullOrEmpty(message.Id)) + { + archivedMessageIds.Add(message.Id); + } } pageToken = response.NextPageToken; } while (!string.IsNullOrEmpty(pageToken)); - var result = await _gmailChangeProcessor.GetGmailArchiveComparisonResultAsync(archiveFolderId.Value, archivedMessageIds).ConfigureAwait(false); + var result = await _gmailChangeProcessor.GetGmailArchiveComparisonResultAsync(archiveFolderId.Value, archivedMessageIds.ToList()).ConfigureAwait(false); - foreach (var archiveAddedItem in result.Added) + var addedArchiveIds = result.Added.Distinct(StringComparer.Ordinal).ToList(); + var removedArchiveIds = result.Removed.Distinct(StringComparer.Ordinal).ToList(); + + if (addedArchiveIds.Count > 0) { - await HandleArchiveAssignmentAsync(archiveAddedItem); + // Archive sync can surface messages that were never downloaded before. + // Download metadata first so assignment creation can succeed. + var existingBeforeDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false); + var missingArchiveIds = addedArchiveIds.Except(existingBeforeDownload, StringComparer.Ordinal).ToList(); + + if (missingArchiveIds.Count > 0) + { + await DownloadMessagesInBatchAsync(missingArchiveIds, cancellationToken).ConfigureAwait(false); + } + + var existingAfterDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false); + + foreach (var archiveAddedItem in existingAfterDownload) + { + await HandleArchiveAssignmentAsync(archiveAddedItem).ConfigureAwait(false); + } } - foreach (var unAarchivedRemovedItem in result.Removed) + foreach (var unAarchivedRemovedItem in removedArchiveIds) { - await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem); + await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem).ConfigureAwait(false); } } diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 2d28bca3..102c57ef 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -260,6 +260,16 @@ public class OutlookSynchronizer : WinoSynchronizer> OnlineSearchAsync(string queryText, List folders, CancellationToken cancellationToken = default) { - List messagesReturnedByApi = []; + var messagesById = new Dictionary(StringComparer.Ordinal); // Perform search for each folder separately. if (folders?.Count > 0) { - var folderIds = folders.Select(a => a.RemoteFolderId); + var folderIds = folders + .Where(a => a != null && !string.IsNullOrWhiteSpace(a.RemoteFolderId)) + .Select(a => a.RemoteFolderId) + .Distinct(StringComparer.Ordinal) + .ToList(); var tasks = folderIds.Select(async folderId => { @@ -1785,15 +1800,19 @@ public class OutlookSynchronizer : WinoSynchronizer x.RemoteFolderId); - var messagesDictionary = messagesReturnedByApi.ToDictionary(a => a.Id); - // Contains a list of message ids that potentially can be downloaded. - List messageIdsWithKnownFolder = []; + var messageIdsWithKnownFolder = new HashSet(StringComparer.Ordinal); // Validate that all messages are in a known folder. - foreach (var message in messagesReturnedByApi) + foreach (var message in messagesById.Values) { if (!localFolders.ContainsKey(message.ParentFolderId)) { @@ -1841,13 +1862,18 @@ public class OutlookSynchronizer : WinoSynchronizer messagesToDownload = []; - foreach (var id in messagesDictionary.Keys.Except(locallyExistingMails)) + foreach (var id in messageIdsWithKnownFolder.Except(locallyExistingMails, StringComparer.Ordinal)) { - messagesToDownload.Add(messagesDictionary[id]); + if (messagesById.TryGetValue(id, out var message)) + { + messagesToDownload.Add(message); + } } foreach (var message in messagesToDownload) diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 929a3c54..bd6c48d7 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -550,6 +550,8 @@ public partial class MailListPageViewModel : MailBaseViewModel, { IsOnlineSearchEnabled = false; AreSearchResultsOnline = false; + HasNoOnlineSearchResult = false; + OnPropertyChanged(nameof(HasNoOnlineSearchResult)); IsInSearchMode = !string.IsNullOrEmpty(SearchQuery); if (IsInSearchMode) @@ -619,6 +621,57 @@ public partial class MailListPageViewModel : MailBaseViewModel, return condition; } + private static bool IsDraftOrSentFolder(MailCopy mailItem) + => mailItem?.AssignedFolder?.SpecialFolderType is SpecialFolderType.Draft or SpecialFolderType.Sent; + + private bool IsMailMatchingLocalSearch(MailCopy mailItem) + { + if (!IsInSearchMode) return true; + if (string.IsNullOrWhiteSpace(SearchQuery)) return true; + + var query = SearchQuery.Trim(); + + return (!string.IsNullOrEmpty(mailItem.Subject) && mailItem.Subject.Contains(query, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(mailItem.PreviewText) && mailItem.PreviewText.Contains(query, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(mailItem.FromName) && mailItem.FromName.Contains(query, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(mailItem.FromAddress) && mailItem.FromAddress.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + private bool ShouldRemoveUpdatedMailFromCurrentList(MailCopy updatedMail) + { + if (ActiveFolder == null || updatedMail?.AssignedFolder == null) return true; + + bool isFromDraftOrSentFolder = IsDraftOrSentFolder(updatedMail); + + if (!isFromDraftOrSentFolder && !ActiveFolder.HandlingFolders.Any(a => a.Id == updatedMail.AssignedFolder.Id)) + { + return true; + } + + if (isFromDraftOrSentFolder && !ThreadIdExistsInCollection(updatedMail)) + { + return true; + } + + if (ShouldPreventItemAdd(updatedMail)) + { + return true; + } + + if (SelectedFolderPivot?.IsFocused is bool isFocused && updatedMail.IsFocused != isFocused) + { + return true; + } + + // Online search results are a server-provided snapshot. Keep current items stable. + if (IsInSearchMode && (IsOnlineSearchEnabled || AreSearchResultsOnline)) + { + return false; + } + + return !IsMailMatchingLocalSearch(updatedMail); + } + [RelayCommand] public void RemoveFirst() { @@ -652,8 +705,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, if (!ActiveFolder.HandlingFolders.Any(a => a.MailAccountId == addedMail.AssignedAccount.Id)) return; // Messages coming to sent or draft folder should only be inserted if their ThreadId exists in the collection. - bool isFromDraftOrSentFolder = addedMail.AssignedFolder.SpecialFolderType == SpecialFolderType.Draft || - addedMail.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent; + bool isFromDraftOrSentFolder = IsDraftOrSentFolder(addedMail); if (isFromDraftOrSentFolder) { @@ -719,6 +771,20 @@ public partial class MailListPageViewModel : MailBaseViewModel, if (ShouldPreventItemAdd(addedMail)) return; } + if (SelectedFolderPivot?.IsFocused is bool isFocused && addedMail.IsFocused != isFocused) + { + return; + } + + if (IsInSearchMode) + { + // Online search results are loaded from a dedicated query snapshot. + // Ignore live additions while that snapshot is active. + if (IsOnlineSearchEnabled || AreSearchResultsOnline) return; + + if (!IsMailMatchingLocalSearch(addedMail)) return; + } + await listManipulationSemepahore.WaitAsync(); // AddAsync already handles UI threading internally, no need to wrap it @@ -743,6 +809,17 @@ public partial class MailListPageViewModel : MailBaseViewModel, try { await listManipulationSemepahore.WaitAsync(); + + bool isItemListed = MailCollection.ContainsMailUniqueId(updatedMail.UniqueId); + if (!isItemListed) return; + + if (ShouldRemoveUpdatedMailFromCurrentList(updatedMail)) + { + await MailCollection.RemoveAsync(updatedMail); + await ExecuteUIThread(() => { NotifyItemFoundState(); }); + return; + } + await MailCollection.UpdateMailCopy(updatedMail, source); } finally @@ -890,6 +967,36 @@ public partial class MailListPageViewModel : MailBaseViewModel, await InitializeFolderAsync(); } + private async Task> PerformSynchronizerOnlineSearchAsync(string queryText, + IEnumerable handlingFolders, + CancellationToken cancellationToken) + { + if (handlingFolders == null) return []; + + var foldersByAccount = handlingFolders + .GroupBy(a => a.MailAccountId) + .ToList(); + + if (foldersByAccount.Count == 0) return []; + + var searchTasks = foldersByAccount.Select(async groupedFolders => + { + var synchronizer = await SynchronizationManager.Instance.GetSynchronizerAsync(groupedFolders.Key).ConfigureAwait(false); + if (synchronizer == null) return new List(); + + var accountResults = await synchronizer.OnlineSearchAsync(queryText, groupedFolders.ToList(), cancellationToken).ConfigureAwait(false); + return accountResults ?? new List(); + }); + + var allResults = await Task.WhenAll(searchTasks).ConfigureAwait(false); + + return allResults + .SelectMany(a => a) + .GroupBy(a => a.UniqueId) + .Select(a => a.First()) + .ToList(); + } + private async Task InitializeFolderAsync() { if (SelectedFilterOption == null || SelectedFolderPivot == null || SelectedSortingOption == null) @@ -921,6 +1028,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, // Here items are sorted and filtered. List items = null; + List onlineSearchItems = null; bool isDoingSearch = !string.IsNullOrEmpty(SearchQuery); bool isDoingOnlineSearch = false; @@ -932,52 +1040,31 @@ public partial class MailListPageViewModel : MailBaseViewModel, // Perform online search. if (isDoingOnlineSearch) { - // TODO: Burak: Handle online search. - //WinoServerResponse onlineSearchResult = null; - //string onlineSearchFailedMessage = null; + try + { + onlineSearchItems = await PerformSynchronizerOnlineSearchAsync(SearchQuery, ActiveFolder.HandlingFolders, cancellationToken).ConfigureAwait(false); + await ExecuteUIThread(() => { AreSearchResultsOnline = true; }); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to perform online search."); - //try - //{ - // var accountIds = ActiveFolder.HandlingFolders.Select(a => a.MailAccountId).ToList(); - // var folders = ActiveFolder.HandlingFolders.ToList(); - // var searchRequest = new OnlineSearchRequested(accountIds, SearchQuery, folders); + isDoingOnlineSearch = false; + onlineSearchItems = null; - // onlineSearchResult = await _winoServerConnectionManager.GetResponseAsync(searchRequest, cancellationToken); + await ExecuteUIThread(() => + { + IsOnlineSearchEnabled = false; + AreSearchResultsOnline = false; - // if (onlineSearchResult.IsSuccess) - // { - // await ExecuteUIThread(() => { AreSearchResultsOnline = true; }); - - // onlineSearchItems = onlineSearchResult.Data.SearchResult; - // } - // else - // { - // onlineSearchFailedMessage = onlineSearchResult.Message; - // } - //} - //catch (OperationCanceledException) - //{ - // throw; - //} - //catch (Exception ex) - //{ - // Log.Warning(ex, "Failed to perform online search."); - // onlineSearchFailedMessage = ex.Message; - //} - - //if (onlineSearchResult != null && !onlineSearchResult.IsSuccess) - //{ - // // Query or server error. - // var serverErrorMessage = string.Format(Translator.OnlineSearchFailed_Message, onlineSearchResult.Message); - // _mailDialogService.InfoBarMessage(Translator.GeneralTitle_Error, serverErrorMessage, InfoBarMessageType.Warning); - - //} - //else if (!string.IsNullOrEmpty(onlineSearchFailedMessage)) - //{ - // // Fatal error. - // var serverErrorMessage = string.Format(Translator.OnlineSearchFailed_Message, onlineSearchFailedMessage); - // _mailDialogService.InfoBarMessage(Translator.GeneralTitle_Error, serverErrorMessage, InfoBarMessageType.Warning); - //} + var serverErrorMessage = string.Format(Translator.OnlineSearchFailed_Message, ex.Message); + _mailDialogService.InfoBarMessage(Translator.GeneralTitle_Error, serverErrorMessage, InfoBarMessageType.Warning); + }); + } } } @@ -986,8 +1073,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, SelectedSortingOption.Type, PreferencesService.IsThreadingEnabled, SelectedFolderPivot.IsFocused, - SearchQuery, - MailCollection.MailCopyIdHashSet); + isDoingOnlineSearch ? string.Empty : SearchQuery, + MailCollection.MailCopyIdHashSet, + onlineSearchItems); items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false); @@ -1003,6 +1091,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, await ExecuteUIThread(() => { + HasNoOnlineSearchResult = isDoingOnlineSearch && items.Count == 0; + OnPropertyChanged(nameof(HasNoOnlineSearchResult)); + if (isDoingSearch && !isDoingOnlineSearch) { IsOnlineSearchButtonVisible = true; @@ -1060,6 +1151,8 @@ public partial class MailListPageViewModel : MailBaseViewModel, IsInSearchMode = false; IsOnlineSearchButtonVisible = false; AreSearchResultsOnline = false; + HasNoOnlineSearchResult = false; + OnPropertyChanged(nameof(HasNoOnlineSearchResult)); // Prepare Focused - Other or folder name tabs. await UpdateFolderPivotsAsync(); @@ -1093,6 +1186,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, SearchQuery = string.Empty; IsInSearchMode = false; IsOnlineSearchEnabled = false; + HasNoOnlineSearchResult = false; } } diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index 7e322a62..a8632edb 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -230,6 +230,67 @@ public class MailService : BaseDatabaseService, IMailService return (sql.ToString(), parameters.ToArray()); } + private static List ApplyOptionsToPreFetchedMails(MailListInitializationOptions options) + { + var allowedFolderIds = options.Folders.Select(f => f.Id).ToHashSet(); + + IEnumerable query = options.PreFetchMailCopies + .Where(m => m != null && allowedFolderIds.Contains(m.FolderId)) + .GroupBy(m => m.UniqueId) + .Select(g => g.First()); + + switch (options.FilterType) + { + case FilterOptionType.Unread: + query = query.Where(m => !m.IsRead); + break; + case FilterOptionType.Flagged: + query = query.Where(m => m.IsFlagged); + break; + case FilterOptionType.Files: + query = query.Where(m => m.HasAttachments); + break; + } + + if (options.IsFocusedOnly is bool isFocused) + { + query = query.Where(m => m.IsFocused == isFocused); + } + + if (!string.IsNullOrWhiteSpace(options.SearchQuery)) + { + var search = options.SearchQuery.Trim(); + query = query.Where(m => + (!string.IsNullOrEmpty(m.PreviewText) && m.PreviewText.Contains(search, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(m.Subject) && m.Subject.Contains(search, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(m.FromName) && m.FromName.Contains(search, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(m.FromAddress) && m.FromAddress.Contains(search, StringComparison.OrdinalIgnoreCase))); + } + + if (options.ExistingUniqueIds?.Any() ?? false) + { + query = query.Where(m => !options.ExistingUniqueIds.ContainsKey(m.UniqueId)); + } + + query = options.SortingOptionType switch + { + SortingOptionType.Sender => query.OrderBy(m => m.FromName).ThenByDescending(m => m.CreationDate), + _ => query.OrderByDescending(m => m.CreationDate) + }; + + if (options.Skip > 0) + { + query = query.Skip(options.Skip); + } + + if (options.Take > 0) + { + query = query.Take(options.Take); + } + + return query.ToList(); + } + public async Task> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default) { List mails = null; @@ -237,7 +298,7 @@ public class MailService : BaseDatabaseService, IMailService // If user performs an online search, mail copies are passed to options. if (options.PreFetchMailCopies != null) { - mails = options.PreFetchMailCopies; + mails = ApplyOptionsToPreFetchedMails(options); } else { @@ -1163,12 +1224,23 @@ public class MailService : BaseDatabaseService, IMailService public async Task GetGmailArchiveComparisonResultAsync(Guid archiveFolderId, List onlineArchiveMailIds) { + onlineArchiveMailIds ??= []; + var localArchiveMails = await Connection.Table() .Where(a => a.FolderId == archiveFolderId) .ToListAsync().ConfigureAwait(false); - var removedMails = localArchiveMails.Where(a => !onlineArchiveMailIds.Contains(a.Id)).Select(a => a.Id).Distinct().ToArray(); - var addedMails = onlineArchiveMailIds.Where(a => !localArchiveMails.Select(b => b.Id).Contains(a)).Distinct().ToArray(); + var onlineArchiveIdSet = onlineArchiveMailIds + .Where(a => !string.IsNullOrWhiteSpace(a)) + .ToHashSet(StringComparer.Ordinal); + + var localArchiveIdSet = localArchiveMails + .Select(a => a.Id) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .ToHashSet(StringComparer.Ordinal); + + var removedMails = localArchiveIdSet.Except(onlineArchiveIdSet).ToArray(); + var addedMails = onlineArchiveIdSet.Except(localArchiveIdSet).ToArray(); return new GmailArchiveComparisonResult(addedMails, removedMails); }