diff --git a/Wino.Core.Domain/Enums/MailListDisplayMode.cs b/Wino.Core.Domain/Enums/MailListDisplayMode.cs index c1d04584..b25b45eb 100644 --- a/Wino.Core.Domain/Enums/MailListDisplayMode.cs +++ b/Wino.Core.Domain/Enums/MailListDisplayMode.cs @@ -1,4 +1,5 @@ -namespace Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Enums; public enum MailListDisplayMode { diff --git a/Wino.Core.Domain/Enums/SearchMode.cs b/Wino.Core.Domain/Enums/SearchMode.cs new file mode 100644 index 00000000..f34f3f8a --- /dev/null +++ b/Wino.Core.Domain/Enums/SearchMode.cs @@ -0,0 +1,6 @@ +namespace Wino.Core.Domain.Enums; +public enum SearchMode +{ + Local, + Online +} diff --git a/Wino.Core.Domain/Interfaces/IImapSynchronizerStrategy.cs b/Wino.Core.Domain/Interfaces/IImapSynchronizerStrategy.cs index be6e1218..67ca3ef0 100644 --- a/Wino.Core.Domain/Interfaces/IImapSynchronizerStrategy.cs +++ b/Wino.Core.Domain/Interfaces/IImapSynchronizerStrategy.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using MailKit; using MailKit.Net.Imap; using Wino.Core.Domain.Entities.Mail; @@ -17,5 +18,15 @@ public interface IImapSynchronizerStrategy /// Cancellation token. /// List of new downloaded message ids that don't exist locally. Task> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default); + + /// + /// Downloads given set of messages from the folder. + /// Folder is expected to be opened and synchronizer is connected. + /// + /// Synchronizer that performs the action. + /// Remote folder to download messages from. + /// Set of message uniqueids. + /// Cancellation token. + Task DownloadMessagesAsync(IImapSynchronizer synchronizer, IMailFolder folder, UniqueIdSet uniqueIdSet, CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs index 854015e5..911640ef 100644 --- a/Wino.Core.Domain/Interfaces/IMailService.cs +++ b/Wino.Core.Domain/Interfaces/IMailService.cs @@ -13,6 +13,14 @@ public interface IMailService { Task GetSingleMailItemAsync(string mailCopyId, string remoteFolderId); Task GetSingleMailItemAsync(Guid uniqueMailId); + + /// + /// Returns the single mail item with the given mail copy id. + /// Caution: This method is not safe. Use other overrides. + /// + /// + /// + Task GetSingleMailItemAsync(string mailCopyId); Task> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default); /// @@ -117,4 +125,14 @@ public interface IMailService /// /// Task> GetExistingMailsAsync(Guid folderId, IEnumerable uniqueIds); + + /// + /// Creates a new mail from a package without doing any existence check. + /// Use it with caution. + /// + /// Account that mail belongs to. + /// Assigned folder. + /// Mail creation package. + /// + Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package); } diff --git a/Wino.Core.Domain/Interfaces/IPreferencesService.cs b/Wino.Core.Domain/Interfaces/IPreferencesService.cs index 739518c0..14eef740 100644 --- a/Wino.Core.Domain/Interfaces/IPreferencesService.cs +++ b/Wino.Core.Domain/Interfaces/IPreferencesService.cs @@ -39,6 +39,17 @@ public interface IPreferencesService /// bool Prefer24HourTimeFormat { get; set; } + /// + /// Diagnostic ID for the application. + /// Changes per-install. + /// + string DiagnosticId { get; set; } + + /// + /// Setting: Defines the user's preference of default search mode in mail list. + /// Local search will still offer online search at the end of local search results. + /// + SearchMode DefaultSearchMode { get; set; } #endregion #region Mail @@ -187,7 +198,7 @@ public interface IPreferencesService DayOfWeek WorkingDayStart { get; set; } DayOfWeek WorkingDayEnd { get; set; } double HourHeight { get; set; } - string DiagnosticId { get; set; } + CalendarSettings GetCurrentCalendarSettings(); diff --git a/Wino.Core.Domain/Interfaces/IWinoSynchronizerBase.cs b/Wino.Core.Domain/Interfaces/IWinoSynchronizerBase.cs index e5a3d806..ba2384b5 100644 --- a/Wino.Core.Domain/Interfaces/IWinoSynchronizerBase.cs +++ b/Wino.Core.Domain/Interfaces/IWinoSynchronizerBase.cs @@ -1,6 +1,9 @@ -using System.Threading; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using MailKit; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; @@ -35,4 +38,13 @@ public interface IWinoSynchronizerBase : IBaseSynchronizer /// 3. Dispose all resources. /// Task KillSynchronizerAsync(); + + /// + /// Perform online search on the server. + /// + /// Search query. + /// Folders to include in search. All folders if null. + /// Cancellation token. + /// Search results after downloading missing mail copies from server. + Task> OnlineSearchAsync(string queryText, List folders, CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs b/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs index 5d3df2f4..033ec012 100644 --- a/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs +++ b/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Models.Folders; @@ -11,4 +12,5 @@ public record MailListInitializationOptions(IEnumerable Folders bool CreateThreads, bool? IsFocusedOnly, string SearchQuery, - IEnumerable ExistingUniqueIds); + IEnumerable ExistingUniqueIds, + List PreFetchMailCopies = null); diff --git a/Wino.Core.Domain/Models/Synchronization/OnlineSearchResult.cs b/Wino.Core.Domain/Models/Synchronization/OnlineSearchResult.cs new file mode 100644 index 00000000..4dad6df8 --- /dev/null +++ b/Wino.Core.Domain/Models/Synchronization/OnlineSearchResult.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; +using Wino.Core.Domain.Entities.Mail; + +namespace Wino.Core.Domain.Models.Synchronization; + +public record OnlineSearchResult(List SearchResult); diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index d7902599..5ea22845 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -393,6 +393,9 @@ "Notifications_WinoUpdatedMessage": "Checkout new version {0}", "Notifications_WinoUpdatedTitle": "Wino Mail has been updated.", "Other": "Other", + "OnlineSearchFailed_Message": "Failed to perform search\n{0}\n\nListing offline mails.", + "OnlineSearchTry_Line1": "Can't find what you are looking for?", + "OnlineSearchTry_Line2": "Try online search.", "PaneLengthOption_Default": "Default", "PaneLengthOption_ExtraLarge": "Extra Large", "PaneLengthOption_Large": "Large", @@ -520,6 +523,10 @@ "SettingsAppPreferences_StartupBehavior_FatalError": "Fatal error occurred while changing the startup mode for Wino Mail.", "SettingsAppPreferences_StartupBehavior_Enable": "Enable", "SettingsAppPreferences_StartupBehavior_Disable": "Disable", + "SettingsAppPreferences_SearchMode_Title": "Default search mode", + "SettingsAppPreferences_SearchMode_Description": "Set whether Wino should check fetched mails first while doing a search or ask your mail server online. Local search is always faster and you can always do an online search if your mail is not in the results.", + "SettingsAppPreferences_SearchMode_Local": "Local", + "SettingsAppPreferences_SearchMode_Online": "Online", "SettingsReorderAccounts_Title": "Reorder Accounts", "SettingsReorderAccounts_Description": "Change the order of accounts in the account list.", "SettingsManageLink_Description": "Move items to add new link or remove existing link.", @@ -650,3 +657,4 @@ "QuickEventDialog_IsAllDay": "All day" } + diff --git a/Wino.Core.UWP/Dialogs/NewAccountDialog.xaml b/Wino.Core.UWP/Dialogs/NewAccountDialog.xaml index b53453f4..b6330156 100644 --- a/Wino.Core.UWP/Dialogs/NewAccountDialog.xaml +++ b/Wino.Core.UWP/Dialogs/NewAccountDialog.xaml @@ -72,6 +72,7 @@ SaveProperty(propertyName: nameof(DiagnosticId), value); } + public SearchMode DefaultSearchMode + { + get => _configurationService.Get(nameof(DefaultSearchMode), SearchMode.Local); + set => SaveProperty(propertyName: nameof(DefaultSearchMode), value); + } + public DayOfWeek FirstDayOfWeek { get => _configurationService.Get(nameof(FirstDayOfWeek), DayOfWeek.Monday); diff --git a/Wino.Core/Integration/Json/ServerRequestTypeInfoResolver.cs b/Wino.Core/Integration/Json/ServerRequestTypeInfoResolver.cs index ea33ea64..918b1e60 100644 --- a/Wino.Core/Integration/Json/ServerRequestTypeInfoResolver.cs +++ b/Wino.Core/Integration/Json/ServerRequestTypeInfoResolver.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization.Metadata; -using MailKit; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Requests.Folder; using Wino.Core.Requests.Mail; @@ -45,7 +45,7 @@ public class ServerRequestTypeInfoResolver : DefaultJsonTypeInfoResolver } }; } - else if (t.Type == typeof(IMailFolder)) + else if (t.Type == typeof(IMailItemFolder)) { t.PolymorphismOptions = new JsonPolymorphismOptions() { diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index 672759ac..a11ae05d 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -38,9 +38,17 @@ public interface IDefaultChangeProcessor Task> GetSynchronizationFoldersAsync(MailSynchronizationOptions options); Task MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId); Task UpdateFolderLastSyncDateAsync(Guid folderId); - Task> GetExistingFoldersAsync(Guid accountId); Task UpdateRemoteAliasInformationAsync(MailAccount account, List remoteAccountAliases); + /// + /// Interrupted initial synchronization may cause downloaded mails to be saved in the database twice. + /// Since downloading mime is costly in Outlook, we need to check if the actual copy of the message has been saved before. + /// This is also used in online search to prevent duplicate mails. + /// + /// MailCopyId of the message. + /// Whether mail exists or not. + Task IsMailExistsAsync(string messageId); + // Calendar Task> GetAccountCalendarsAsync(Guid accountId); @@ -51,6 +59,8 @@ public interface IDefaultChangeProcessor Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar); Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken); + Task GetMailCopyAsync(string mailCopyId); + Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package); } public interface IGmailChangeProcessor : IDefaultChangeProcessor @@ -62,14 +72,6 @@ public interface IGmailChangeProcessor : IDefaultChangeProcessor public interface IOutlookChangeProcessor : IDefaultChangeProcessor { - /// - /// Interrupted initial synchronization may cause downloaded mails to be saved in the database twice. - /// Since downloading mime is costly in Outlook, we need to check if the actual copy of the message has been saved before. - /// - /// MailCopyId of the message. - /// Whether the mime has b - Task IsMailExistsAsync(string messageId); - /// /// Checks whether the mail exists in the folder. /// When deciding Create or Update existing mail, we need to check if the mail exists in the folder. @@ -141,22 +143,26 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, public Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged) => MailService.ChangeFlagStatusAsync(mailCopyId, isFlagged); + public Task IsMailExistsAsync(string messageId) + => MailService.IsMailExistsAsync(messageId); + + public Task GetMailCopyAsync(string mailCopyId) + => MailService.GetSingleMailItemAsync(mailCopyId); + public Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead) => MailService.ChangeReadStatusAsync(mailCopyId, isRead); public Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId) => MailService.DeleteAssignmentAsync(accountId, mailCopyId, remoteFolderId); - - public Task DeleteMailAsync(Guid accountId, string mailId) => MailService.DeleteMailAsync(accountId, mailId); public Task CreateMailAsync(Guid accountId, NewMailItemPackage package) => MailService.CreateMailAsync(accountId, package); - public Task> GetExistingFoldersAsync(Guid accountId) - => FolderService.GetFoldersAsync(accountId); + public Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package) + => MailService.CreateMailRawAsync(account, mailItemFolder, package); public Task MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId) => MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId); diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs index a780aa2a..aaecf767 100644 --- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs @@ -20,8 +20,6 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, IMimeFileService mimeFileService) : DefaultChangeProcessor(databaseService, folderService, mailService, calendarService, accountService, mimeFileService) , IOutlookChangeProcessor { - public Task IsMailExistsAsync(string messageId) - => MailService.IsMailExistsAsync(messageId); public Task IsMailExistsInFolderAsync(string messageId, Guid folderId) => MailService.IsMailExistsAsync(messageId, folderId); diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index b87b6bc2..9ede3831 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -24,6 +24,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; +using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Extensions; @@ -903,6 +904,77 @@ public class GmailSynchronizer : WinoSynchronizer(networkCall, singleDraftRequest, singleDraftRequest)]; } + public override async Task> 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. + + string pageToken = null; + + var messagesToDownload = new List(); + + do + { + if (folders?.Any() ?? false) + { + request.LabelIds = folders.Select(a => a.RemoteFolderId).ToList(); + } + + if (!string.IsNullOrEmpty(pageToken)) + { + request.PageToken = pageToken; + } + + var response = await request.ExecuteAsync(cancellationToken); + if (response.Messages == null) break; + + // Handle skipping manually + foreach (var message in response.Messages) + { + messagesToDownload.Add(message); + } + + 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).ToList(); + + List downloadRequireMessageIds = new(); + + foreach (var messageId in messageIds) + { + var exists = await _gmailChangeProcessor.IsMailExistsAsync(messageId).ConfigureAwait(false); + + if (!exists) + { + downloadRequireMessageIds.Add(messageId); + } + } + + // Download missing messages. + await BatchDownloadMessagesAsync(downloadRequireMessageIds, cancellationToken); + + // Get results from database and return. + + var searchResults = new List(); + + foreach (var messageId in messageIds) + { + var copy = await _gmailChangeProcessor.GetMailCopyAsync(messageId).ConfigureAwait(false); + + if (copy == null) continue; + + searchResults.Add(copy); + } + + return searchResults; + + // TODO: Return the search result ids. + } + public override async Task DownloadMissingMimeMessageAsync(IMailItem mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) diff --git a/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyBase.cs b/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyBase.cs index 4e00d8d2..59ef6163 100644 --- a/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyBase.cs +++ b/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyBase.cs @@ -83,38 +83,19 @@ public abstract class ImapSynchronizationStrategyBase : IImapSynchronizerStrateg // Fetch the new mails in batch. - var batchedMessageIds = newMessageIds.Batch(50); + var batchedMessageIds = newMessageIds.Batch(50).ToList(); + var downloadTasks = new List(); + // Create tasks for each batch. foreach (var group in batchedMessageIds) { - var uniqueIdSet = new UniqueIdSet(group, SortOrder.Ascending); - - var summaries = await remoteFolder.FetchAsync(uniqueIdSet, MailSynchronizationFlags, cancellationToken).ConfigureAwait(false); - - foreach (var summary in summaries) - { - var mimeMessage = await remoteFolder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false); - - var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage); - - var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, Folder, cancellationToken).ConfigureAwait(false); - - if (mailPackages != null) - { - foreach (var package in mailPackages) - { - // Local draft is mapped. We don't need to create a new mail copy. - if (package == null) continue; - - bool isCreatedNew = await MailService.CreateMailAsync(Folder.MailAccountId, package).ConfigureAwait(false); - - // This is upsert. We are not interested in updated mails. - if (isCreatedNew) downloadedMessageIds.Add(package.Copy.Id); - } - } - } + downloadedMessageIds.AddRange(group.Select(a => MailkitClientExtensions.CreateUid(Folder.Id, a.Id))); + var task = DownloadMessagesAsync(synchronizer, remoteFolder, new UniqueIdSet(group), cancellationToken); + downloadTasks.Add(task); } + // Wait for all batches to complete. + await Task.WhenAll(downloadTasks).ConfigureAwait(false); return downloadedMessageIds; } @@ -183,4 +164,32 @@ public abstract class ImapSynchronizationStrategyBase : IImapSynchronizerStrateg await HandleMessageDeletedAsync(deletedUids).ConfigureAwait(false); } } + + public async Task DownloadMessagesAsync(IImapSynchronizer synchronizer, + IMailFolder folder, + UniqueIdSet uniqueIdSet, + CancellationToken cancellationToken = default) + { + var summaries = await folder.FetchAsync(uniqueIdSet, MailSynchronizationFlags, cancellationToken).ConfigureAwait(false); + + foreach (var summary in summaries) + { + var mimeMessage = await folder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false); + + var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage); + + var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, Folder, cancellationToken).ConfigureAwait(false); + + if (mailPackages != null) + { + foreach (var package in mailPackages) + { + // Local draft is mapped. We don't need to create a new mail copy. + if (package == null) continue; + + await MailService.CreateMailAsync(Folder.MailAccountId, package).ConfigureAwait(false); + } + } + } + } } diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index d7b0d784..788ff77a 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using MailKit; using MailKit.Net.Imap; +using MailKit.Search; using MoreLinq; using Serilog; using Wino.Core.Domain.Entities.Mail; @@ -16,6 +17,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Connectivity; +using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Extensions; @@ -628,6 +630,80 @@ public class ImapSynchronizer : WinoSynchronizer> OnlineSearchAsync(string queryText, List folders, CancellationToken cancellationToken = default) + { + IImapClient client = null; + IMailFolder activeFolder = null; + + try + { + client = await _clientPool.GetClientAsync().ConfigureAwait(false); + + var searchResults = new List(); + List searchResultFolderMailUids = new(); + + foreach (var folder in folders) + { + var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId).ConfigureAwait(false); + await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false); + + // Look for subject and body. + var query = SearchQuery.BodyContains(queryText).Or(SearchQuery.SubjectContains(queryText)); + + var searchResultsInFolder = await remoteFolder.SearchAsync(query, cancellationToken).ConfigureAwait(false); + var nonExisttingUniqueIds = new List(); + + foreach (var searchResultId in searchResultsInFolder) + { + var folderMailUid = MailkitClientExtensions.CreateUid(folder.Id, searchResultId.Id); + searchResultFolderMailUids.Add(folderMailUid); + + bool exists = await _imapChangeProcessor.IsMailExistsAsync(folderMailUid); + + if (!exists) + { + nonExisttingUniqueIds.Add(searchResultId); + } + } + + if (nonExisttingUniqueIds.Any()) + { + var syncStrategy = _imapSynchronizationStrategyProvider.GetSynchronizationStrategy(client); + await syncStrategy.DownloadMessagesAsync(this, remoteFolder, new UniqueIdSet(nonExisttingUniqueIds, SortOrder.Ascending), cancellationToken).ConfigureAwait(false); + } + + await remoteFolder.CloseAsync().ConfigureAwait(false); + } + + foreach (var messageId in searchResultFolderMailUids) + { + var copy = await _imapChangeProcessor.GetMailCopyAsync(messageId).ConfigureAwait(false); + + if (copy == null) continue; + + searchResults.Add(copy); + } + + return searchResults; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to perform online imap search."); + throw; + } + finally + { + if (activeFolder?.IsOpen ?? false) + { + await activeFolder.CloseAsync().ConfigureAwait(false); + } + + _clientPool.Release(client); + } + + return new List(); + } + private async Task> SynchronizeFolderInternalAsync(MailItemFolder folder, CancellationToken cancellationToken = default) { if (!folder.IsSynchronizationEnabled) return default; diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 380a07e1..dfca959d 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -28,6 +28,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; +using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Extensions; @@ -42,7 +43,7 @@ namespace Wino.Core.Synchronizers.Mail; [JsonSerializable(typeof(Microsoft.Graph.Me.Messages.Item.Move.MovePostRequestBody))] [JsonSerializable(typeof(OutlookFileAttachment))] -public partial class OutlookSynchronizerJsonContext: JsonSerializerContext; +public partial class OutlookSynchronizerJsonContext : JsonSerializerContext; public class OutlookSynchronizer : WinoSynchronizer { @@ -187,6 +188,33 @@ public class OutlookSynchronizer : WinoSynchronizer + { + config.QueryParameters.Select = outlookMessageSelectParameters; + }, cancellationToken).ConfigureAwait(false); + + var mailPackages = await CreateNewMailPackagesAsync(message, assignedFolder, cancellationToken).ConfigureAwait(false); + + if (mailPackages == null) return; + + foreach (var package in mailPackages) + { + cancellationToken.ThrowIfCancellationRequested(); + + await _outlookChangeProcessor.CreateMailRawAsync(Account, assignedFolder, package).ConfigureAwait(false); + } + } + private async Task> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken = default) { var downloadedMessageIds = new List(); @@ -927,6 +955,109 @@ public class OutlookSynchronizer : WinoSynchronizer> OnlineSearchAsync(string queryText, List folders, CancellationToken cancellationToken = default) + { + bool isFoldersIncluded = folders?.Any() ?? false; + + var messagesToDownload = new List(); + + // Perform search for each folder separately. + if (isFoldersIncluded) + { + var folderIds = folders.Select(a => a.RemoteFolderId); + + var tasks = folderIds.Select(async folderId => + { + var mailQuery = _graphClient.Me.MailFolders[folderId].Messages + .GetAsync(requestConfig => + { + requestConfig.QueryParameters.Search = $"\"{queryText}\""; + requestConfig.QueryParameters.Select = ["Id, ParentFolderId"]; + requestConfig.QueryParameters.Top = 1000; + }); + + var result = await mailQuery; + + if (result?.Value != null) + { + lock (messagesToDownload) + { + messagesToDownload.AddRange(result.Value); + } + } + }); + + await Task.WhenAll(tasks); + } + else + { + // Perform search for all messages without folder data. + var mailQuery = _graphClient.Me.Messages + .GetAsync(requestConfig => + { + requestConfig.QueryParameters.Search = $"\"{queryText}\""; + requestConfig.QueryParameters.Select = ["Id, ParentFolderId"]; + requestConfig.QueryParameters.Top = 1000; + }); + + var result = await mailQuery; + + if (result?.Value != null) + { + lock (messagesToDownload) + { + messagesToDownload.AddRange(result.Value); + } + } + } + + // Do not download messages that exists, but return them for listing. + + var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false); + + var existingMessageIds = new List(); + + //Download missing messages. + foreach (var message in messagesToDownload) + { + var messageId = message.Id; + var parentFolderId = message.ParentFolderId; + + if (!localFolders.Any(a => a.RemoteFolderId == parentFolderId)) + { + Log.Warning($"Search result returned a message from a folder that is not synchronized."); + continue; + } + + existingMessageIds.Add(messageId); + + var exists = await _outlookChangeProcessor.IsMailExistsAsync(messageId).ConfigureAwait(false); + + if (!exists) + { + // Check if folder exists. We can't download a mail without existing folder. + + var localFolder = localFolders.Find(a => a.RemoteFolderId == parentFolderId); + + await DownloadSearchResultMessageAsync(messageId, localFolder, cancellationToken).ConfigureAwait(false); + } + } + + // Get results from database and return. + var searchResults = new List(); + + foreach (var messageId in existingMessageIds) + { + var copy = await _outlookChangeProcessor.GetMailCopyAsync(messageId).ConfigureAwait(false); + + if (copy == null) continue; + + searchResults.Add(copy); + } + + return searchResults; + } + private async Task DownloadMimeMessageAsync(string messageId, CancellationToken cancellationToken = default) { var mimeContentStream = await _graphClient.Me.Messages[messageId].Content.GetAsync(null, cancellationToken).ConfigureAwait(false); @@ -935,7 +1066,6 @@ public class OutlookSynchronizer : WinoSynchronizer> CreateNewMailPackagesAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) { - var mimeMessage = await DownloadMimeMessageAsync(message.Id, cancellationToken).ConfigureAwait(false); var mailCopy = message.AsMailCopy(); diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs index 73cb72ff..8bf7567f 100644 --- a/Wino.Core/Synchronizers/WinoSynchronizer.cs +++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs @@ -14,6 +14,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; +using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Requests.Bundles; @@ -416,6 +417,16 @@ public abstract class WinoSynchronizerCancellation token. public virtual Task DownloadMissingMimeMessageAsync(IMailItem mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + /// + /// Performs an online search for the given query text in the given folders. + /// Downloads the missing messages from the server. + /// + /// Query to search for. + /// Which folders to include in. + /// Cancellation token. + /// + public virtual Task> OnlineSearchAsync(string queryText, List folders, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public List> CreateSingleTaskBundle(Func action, IRequestBase request, IUIChangeRequest uIChangeRequest) { return [new ImapRequestBundle(new ImapRequest(action, request), request, uIChangeRequest)]; diff --git a/Wino.Mail.ViewModels/AppPreferencesPageViewModel.cs b/Wino.Mail.ViewModels/AppPreferencesPageViewModel.cs index 18132e0f..f80f0ee3 100644 --- a/Wino.Mail.ViewModels/AppPreferencesPageViewModel.cs +++ b/Wino.Mail.ViewModels/AppPreferencesPageViewModel.cs @@ -18,6 +18,10 @@ public partial class AppPreferencesPageViewModel : MailBaseViewModel [ObservableProperty] private List _appTerminationBehavior; + + [ObservableProperty] + public partial List SearchModes { get; set; } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsStartupBehaviorDisabled))] [NotifyPropertyChangedFor(nameof(IsStartupBehaviorEnabled))] @@ -38,6 +42,18 @@ public partial class AppPreferencesPageViewModel : MailBaseViewModel } } + private string _selectedDefaultSearchMode; + public string SelectedDefaultSearchMode + { + get => _selectedDefaultSearchMode; + set + { + SetProperty(ref _selectedDefaultSearchMode, value); + + PreferencesService.DefaultSearchMode = (SearchMode)SearchModes.IndexOf(value); + } + } + private readonly IMailDialogService _dialogService; private readonly IWinoServerConnectionManager _winoServerConnectionManager; private readonly IStartupBehaviorService _startupBehaviorService; @@ -61,7 +77,14 @@ public partial class AppPreferencesPageViewModel : MailBaseViewModel Translator.SettingsAppPreferences_ServerBackgroundingMode_Terminate_Title // "Terminate" ]; + SearchModes = + [ + Translator.SettingsAppPreferences_SearchMode_Local, + Translator.SettingsAppPreferences_SearchMode_Online + ]; + SelectedAppTerminationBehavior = _appTerminationBehavior[(int)PreferencesService.ServerTerminationBehavior]; + SelectedDefaultSearchMode = SearchModes[(int)PreferencesService.DefaultSearchMode]; } [RelayCommand] diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 7d0907e6..08a6e602 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -22,6 +22,7 @@ using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Menus; using Wino.Core.Domain.Models.Reader; +using Wino.Core.Domain.Models.Server; using Wino.Core.Domain.Models.Synchronization; using Wino.Mail.ViewModels.Collections; using Wino.Mail.ViewModels.Data; @@ -71,12 +72,14 @@ public partial class MailListPageViewModel : MailBaseViewModel, public IThemeService ThemeService { get; } private readonly IAccountService _accountService; + private readonly IMailDialogService _mailDialogService; private readonly IMailService _mailService; private readonly IFolderService _folderService; private readonly IThreadingStrategyProvider _threadingStrategyProvider; private readonly IContextMenuItemService _contextMenuItemService; private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly IKeyPressService _keyPressService; + private readonly IWinoLogger _winoLogger; private readonly IWinoServerConnectionManager _winoServerConnectionManager; private MailItemViewModel _activeMailItem; @@ -100,7 +103,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, private bool isMultiSelectionModeEnabled; [ObservableProperty] - private string searchQuery; + public partial string SearchQuery { get; set; } [ObservableProperty] private FilterOption _selectedFilterOption; @@ -109,7 +112,6 @@ public partial class MailListPageViewModel : MailBaseViewModel, // Indicates state when folder is initializing. It can happen after folder navigation, search or filter change applied or loading more items. [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsEmpty))] - [NotifyPropertyChangedFor(nameof(IsCriteriaFailed))] [NotifyPropertyChangedFor(nameof(IsFolderEmpty))] [NotifyPropertyChangedFor(nameof(IsProgressRing))] private bool isInitializingFolder; @@ -147,6 +149,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, public MailListPageViewModel(IMailDialogService dialogService, INavigationService navigationService, IAccountService accountService, + IMailDialogService mailDialogService, IMailService mailService, IStatePersistanceService statePersistenceService, IFolderService folderService, @@ -156,14 +159,17 @@ public partial class MailListPageViewModel : MailBaseViewModel, IKeyPressService keyPressService, IPreferencesService preferencesService, IThemeService themeService, + IWinoLogger winoLogger, IWinoServerConnectionManager winoServerConnectionManager) { PreferencesService = preferencesService; ThemeService = themeService; + _winoLogger = winoLogger; _winoServerConnectionManager = winoServerConnectionManager; StatePersistenceService = statePersistenceService; NavigationService = navigationService; _accountService = accountService; + _mailDialogService = mailDialogService; _mailService = mailService; _folderService = folderService; _threadingStrategyProvider = threadingStrategyProvider; @@ -254,6 +260,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive; public string SelectedMessageText => HasSelectedItems ? string.Format(Translator.MailsSelected, SelectedItemCount) : Translator.NoMailSelected; + /// /// Indicates current state of the mail list. Doesn't matter it's loading or no. /// @@ -263,11 +270,21 @@ public partial class MailListPageViewModel : MailBaseViewModel, /// Progress ring only should be visible when the folder is initializing and there are no items. We don't need to show it when there are items. /// public bool IsProgressRing => IsInitializingFolder && IsEmpty; - private bool isFilters => IsInSearchMode || SelectedFilterOption.Type != FilterOptionType.All; - public bool IsCriteriaFailed => !IsInitializingFolder && IsEmpty && isFilters; - public bool IsFolderEmpty => !IsInitializingFolder && IsEmpty && !isFilters; + public bool IsFolderEmpty => !IsInitializingFolder && IsEmpty; - public bool IsInSearchMode { get; set; } + public bool HasNoOnlineSearchResult { get; private set; } + + [ObservableProperty] + public partial bool IsInSearchMode { get; set; } + + [ObservableProperty] + public partial bool IsOnlineSearchButtonVisible { get; set; } + + [ObservableProperty] + public partial bool IsOnlineSearchEnabled { get; set; } + + [ObservableProperty] + public partial bool AreSearchResultsOnline { get; set; } #endregion @@ -333,7 +350,6 @@ public partial class MailListPageViewModel : MailBaseViewModel, private void NotifyItemFoundState() { OnPropertyChanged(nameof(IsEmpty)); - OnPropertyChanged(nameof(IsCriteriaFailed)); OnPropertyChanged(nameof(IsFolderEmpty)); } @@ -378,41 +394,52 @@ public partial class MailListPageViewModel : MailBaseViewModel, private async Task UpdateFolderPivotsAsync() { + if (ActiveFolder == null) return; + PivotFolders.Clear(); SelectedFolderPivot = null; - if (ActiveFolder == null) return; - - // Merged folders don't support focused feature. - - if (ActiveFolder is IMergedAccountFolderMenuItem) + if (IsInSearchMode) { - PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null)); + var isFocused = SelectedFolderPivot?.IsFocused; + + PivotFolders.Add(new FolderPivotViewModel(Translator.SearchPivotName, isFocused)); } - else if (ActiveFolder is IFolderMenuItem singleFolderMenuItem) + else { - var parentAccount = singleFolderMenuItem.ParentAccount; + // Merged folders don't support focused feature. - bool isFocusedInboxEnabled = await _accountService.IsAccountFocusedEnabledAsync(parentAccount.Id); - bool isInboxFolder = ActiveFolder.SpecialFolderType == SpecialFolderType.Inbox; - - // Folder supports Focused - Other - if (isInboxFolder && isFocusedInboxEnabled) + if (ActiveFolder is IMergedAccountFolderMenuItem) { - // Can be passed as empty string. Focused - Other will be used regardless. - var focusedItem = new FolderPivotViewModel(string.Empty, true); - var otherItem = new FolderPivotViewModel(string.Empty, false); - - PivotFolders.Add(focusedItem); - PivotFolders.Add(otherItem); + PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null)); } - else + else if (ActiveFolder is IFolderMenuItem singleFolderMenuItem) { - // If the account and folder doesn't support focused feature, just add itself. - PivotFolders.Add(new FolderPivotViewModel(singleFolderMenuItem.FolderName, null)); + var parentAccount = singleFolderMenuItem.ParentAccount; + + bool isFocusedInboxEnabled = await _accountService.IsAccountFocusedEnabledAsync(parentAccount.Id); + bool isInboxFolder = ActiveFolder.SpecialFolderType == SpecialFolderType.Inbox; + + // Folder supports Focused - Other + if (isInboxFolder && isFocusedInboxEnabled) + { + // Can be passed as empty string. Focused - Other will be used regardless. + var focusedItem = new FolderPivotViewModel(string.Empty, true); + var otherItem = new FolderPivotViewModel(string.Empty, false); + + PivotFolders.Add(focusedItem); + PivotFolders.Add(otherItem); + } + else + { + // If the account and folder doesn't support focused feature, just add itself. + PivotFolders.Add(new FolderPivotViewModel(singleFolderMenuItem.FolderName, null)); + } } } + + // This will trigger refresh. SelectedFolderPivot = PivotFolders.FirstOrDefault(); } @@ -512,33 +539,16 @@ public partial class MailListPageViewModel : MailBaseViewModel, [RelayCommand] public async Task PerformSearchAsync() { - if (string.IsNullOrEmpty(SearchQuery) && IsInSearchMode) + IsOnlineSearchEnabled = false; + AreSearchResultsOnline = false; + IsInSearchMode = !string.IsNullOrEmpty(SearchQuery); + + if (IsInSearchMode) { - await UpdateFolderPivotsAsync(); - IsInSearchMode = false; - await InitializeFolderAsync(); + IsOnlineSearchButtonVisible = false; } - if (!string.IsNullOrEmpty(SearchQuery)) - { - - IsInSearchMode = true; - CreateSearchPivot(); - } - - void CreateSearchPivot() - { - PivotFolders.Clear(); - var isFocused = SelectedFolderPivot?.IsFocused; - SelectedFolderPivot = null; - - if (ActiveFolder == null) return; - - PivotFolders.Add(new FolderPivotViewModel(Translator.SearchPivotName, isFocused)); - - // This will trigger refresh. - SelectedFolderPivot = PivotFolders.FirstOrDefault(); - } + await UpdateFolderPivotsAsync(); } [RelayCommand] @@ -555,7 +565,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, [RelayCommand] private async Task LoadMoreItemsAsync() { - if (IsInitializingFolder) return; + if (IsInitializingFolder || IsOnlineSearchEnabled) return; await ExecuteUIThread(() => { IsInitializingFolder = true; }); @@ -763,6 +773,15 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } + [RelayCommand] + private async Task PerformOnlineSearchAsync() + { + IsOnlineSearchButtonVisible = false; + IsOnlineSearchEnabled = true; + + await InitializeFolderAsync(); + } + private async Task InitializeFolderAsync() { if (SelectedFilterOption == null || SelectedFolderPivot == null || SelectedSortingOption == null) @@ -783,14 +802,6 @@ public partial class MailListPageViewModel : MailBaseViewModel, // Folder is changed during initialization. // Just cancel the existing one and wait for new initialization. - //if (listManipulationSemepahore.CurrentCount == 0) - //{ - // Debug.WriteLine("Canceling initialization of mails."); - - // listManipulationCancellationTokenSource.Cancel(); - // listManipulationCancellationTokenSource.Token.ThrowIfCancellationRequested(); - //} - if (!listManipulationCancellationTokenSource.IsCancellationRequested) { listManipulationCancellationTokenSource.Cancel(); @@ -812,15 +823,77 @@ 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; + + if (isDoingSearch) + { + isDoingOnlineSearch = PreferencesService.DefaultSearchMode == SearchMode.Online || IsOnlineSearchEnabled; + + // Perform online search. + if (isDoingOnlineSearch) + { + WinoServerResponse onlineSearchResult = null; + string onlineSearchFailedMessage = null; + + try + { + var accountIds = ActiveFolder.HandlingFolders.Select(a => a.MailAccountId).ToList(); + var folders = ActiveFolder.HandlingFolders.ToList(); + var searchRequest = new OnlineSearchRequested(accountIds, SearchQuery, folders); + + onlineSearchResult = await _winoServerConnectionManager.GetResponseAsync(searchRequest, cancellationToken); + + 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 initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders, SelectedFilterOption.Type, SelectedSortingOption.Type, PreferencesService.IsThreadingEnabled, SelectedFolderPivot.IsFocused, SearchQuery, - MailCollection.MailCopyIdHashSet); + MailCollection.MailCopyIdHashSet, + onlineSearchItems); - var items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false); + items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false); if (!listManipulationCancellationTokenSource.IsCancellationRequested) { @@ -830,7 +903,15 @@ public partial class MailListPageViewModel : MailBaseViewModel, var viewModels = PrepareMailViewModels(items); - await ExecuteUIThread(() => { MailCollection.AddRange(viewModels, true); }); + await ExecuteUIThread(() => + { + MailCollection.AddRange(viewModels, true); + + if (isDoingSearch && !isDoingOnlineSearch) + { + IsOnlineSearchButtonVisible = true; + } + }); } } catch (OperationCanceledException) @@ -887,6 +968,10 @@ public partial class MailListPageViewModel : MailBaseViewModel, // Notify change for archive-unarchive app bar button. OnPropertyChanged(nameof(IsArchiveSpecialFolder)); + IsInSearchMode = false; + IsOnlineSearchButtonVisible = false; + AreSearchResultsOnline = false; + // Prepare Focused - Other or folder name tabs. await UpdateFolderPivotsAsync(); @@ -918,6 +1003,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, SelectedSortingOption = SortingOptions[0]; SearchQuery = string.Empty; IsInSearchMode = false; + IsOnlineSearchEnabled = false; } } diff --git a/Wino.Mail/Views/MailListPage.xaml b/Wino.Mail/Views/MailListPage.xaml index 1cb58a74..2e48b829 100644 --- a/Wino.Mail/Views/MailListPage.xaml +++ b/Wino.Mail/Views/MailListPage.xaml @@ -3,6 +3,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:abstract="using:Wino.Views.Abstract" + xmlns:animatedvisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals" xmlns:collections="using:CommunityToolkit.Mvvm.Collections" xmlns:controls="using:Wino.Controls" xmlns:converters="using:Wino.Converters" @@ -292,27 +293,38 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + Visibility="{x:Bind ViewModel.IsCriteriaFailed, Mode=OneWay}" />--> + Canvas.ZIndex="2000" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Messages/Server/OnlineSearchRequested.cs b/Wino.Messages/Server/OnlineSearchRequested.cs new file mode 100644 index 00000000..2b731485 --- /dev/null +++ b/Wino.Messages/Server/OnlineSearchRequested.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Folders; + +namespace Wino.Messaging.Server; + +/// +/// Raised when user performs search on the search bar. +/// +/// Accounts that performs the query. Multiple accounts for linked accounts. +/// Search query. +/// Folders to include in search. All folders if null. +public record OnlineSearchRequested(List AccountIds, string QueryText, List Folders) : IClientMessage; diff --git a/Wino.Server/Core/ServerMessageHandlerFactory.cs b/Wino.Server/Core/ServerMessageHandlerFactory.cs index e92d3711..abbb1e79 100644 --- a/Wino.Server/Core/ServerMessageHandlerFactory.cs +++ b/Wino.Server/Core/ServerMessageHandlerFactory.cs @@ -23,6 +23,7 @@ public class ServerMessageHandlerFactory : IServerMessageHandlerFactory nameof(TerminateServerRequested) => App.Current.Services.GetService(), nameof(ImapConnectivityTestRequested) => App.Current.Services.GetService(), nameof(KillAccountSynchronizerRequested) => App.Current.Services.GetService(), + nameof(OnlineSearchRequested) => App.Current.Services.GetService(), _ => throw new Exception($"Server handler for {typeName} is not registered."), }; } @@ -41,5 +42,6 @@ public class ServerMessageHandlerFactory : IServerMessageHandlerFactory serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); + serviceCollection.AddTransient(); } } diff --git a/Wino.Server/MessageHandlers/MailSynchronizationRequestHandler.cs b/Wino.Server/MessageHandlers/MailSynchronizationRequestHandler.cs index 47134a09..add369d5 100644 --- a/Wino.Server/MessageHandlers/MailSynchronizationRequestHandler.cs +++ b/Wino.Server/MessageHandlers/MailSynchronizationRequestHandler.cs @@ -51,6 +51,14 @@ public class MailSynchronizationRequestHandler : ServerMessageHandler.CreateSuccessResponse(MailSynchronizationResult.Canceled); + //} + var synchronizationResult = await synchronizer.SynchronizeMailsAsync(message.Options, cancellationToken).ConfigureAwait(false); if (synchronizationResult.DownloadedMessages?.Any() ?? false || !synchronizer.Account.Preferences.IsNotificationsEnabled) diff --git a/Wino.Server/MessageHandlers/OnlineSearchRequestHandler.cs b/Wino.Server/MessageHandlers/OnlineSearchRequestHandler.cs new file mode 100644 index 00000000..6ba4ca7b --- /dev/null +++ b/Wino.Server/MessageHandlers/OnlineSearchRequestHandler.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Server; +using Wino.Core.Domain.Models.Synchronization; +using Wino.Messaging.Server; +using Wino.Server.Core; + +namespace Wino.Server.MessageHandlers; + +public class OnlineSearchRequestHandler : ServerMessageHandler +{ + private readonly ISynchronizerFactory _synchronizerFactory; + + public OnlineSearchRequestHandler(ISynchronizerFactory synchronizerFactory) + { + _synchronizerFactory = synchronizerFactory; + } + + public override WinoServerResponse FailureDefaultResponse(Exception ex) + => WinoServerResponse.CreateErrorResponse(ex.Message); + + protected override async Task> HandleAsync(OnlineSearchRequested message, CancellationToken cancellationToken = default) + { + List synchronizers = new(); + + foreach (var accountId in message.AccountIds) + { + var synchronizer = await _synchronizerFactory.GetAccountSynchronizerAsync(accountId); + synchronizers.Add(synchronizer); + } + + var tasks = synchronizers.Select(s => s.OnlineSearchAsync(message.QueryText, message.Folders, cancellationToken)).ToList(); + var results = await Task.WhenAll(tasks); + + // Flatten the results from all synchronizers into a single list + var allResults = results.SelectMany(x => x).ToList(); + + return WinoServerResponse.CreateSuccessResponse(new OnlineSearchResult(allResults)); + } +} diff --git a/Wino.Server/ServerContext.cs b/Wino.Server/ServerContext.cs index 98eb31bc..46dd634d 100644 --- a/Wino.Server/ServerContext.cs +++ b/Wino.Server/ServerContext.cs @@ -43,7 +43,8 @@ public class ServerContext : IRecipient, IRecipient, IRecipient, - IRecipient + IRecipient, + IRecipient { private readonly System.Timers.Timer _timer; private static object connectionLock = new object(); @@ -141,8 +142,11 @@ public class ServerContext : public async void Receive(AccountFolderConfigurationUpdated message) => await SendMessageAsync(MessageType.UIMessage, message); public async void Receive(CopyAuthURLRequested message) => await SendMessageAsync(MessageType.UIMessage, message); + public async void Receive(NewMailSynchronizationRequested message) => await SendMessageAsync(MessageType.UIMessage, message); + public async void Receive(OnlineSearchRequested message) => await SendMessageAsync(MessageType.UIMessage, message); + #endregion private string GetAppPackagFamilyName() @@ -326,6 +330,9 @@ public class ServerContext : case nameof(KillAccountSynchronizerRequested): await ExecuteServerMessageSafeAsync(args, JsonSerializer.Deserialize(messageJson, _jsonSerializerOptions)); break; + case nameof(OnlineSearchRequested): + await ExecuteServerMessageSafeAsync(args, JsonSerializer.Deserialize(messageJson, _jsonSerializerOptions)); + break; default: Debug.WriteLine($"Missing handler for {typeName} in the server. Check ServerContext.cs - HandleServerMessageAsync."); break; diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index c497f633..983fc8a4 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -195,9 +195,20 @@ public class MailService : BaseDatabaseService, IMailService public async Task> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default) { - var query = BuildMailFetchQuery(options); + List mails = null; - var mails = await Connection.QueryAsync(query); + // If user performs an online search, mail copies are passed to options. + if (options.PreFetchMailCopies != null) + { + mails = options.PreFetchMailCopies; + } + else + { + // If not just do the query. + var query = BuildMailFetchQuery(options); + + mails = await Connection.QueryAsync(query); + } Dictionary folderCache = []; Dictionary accountCache = []; @@ -406,6 +417,27 @@ public class MailService : BaseDatabaseService, IMailService return mailCopy; } + /// + /// Using this override is dangerous. + /// Gmail stores multiple copies of same mail in different folders. + /// This one will always return the first one. Use with caution. + /// + /// Mail copy id. + public async Task GetSingleMailItemAsync(string mailCopyId) + { + var query = new Query("MailCopy") + .Where("MailCopy.Id", mailCopyId) + .SelectRaw("MailCopy.*") + .GetRawQuery(); + + var mailCopy = await Connection.FindWithQueryAsync(query); + if (mailCopy == null) return null; + + await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false); + + return mailCopy; + } + public async Task GetSingleMailItemAsync(string mailCopyId, string remoteFolderId) { var query = new Query("MailCopy") @@ -634,6 +666,24 @@ public class MailService : BaseDatabaseService, IMailService await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false); } + public async Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package) + { + var mailCopy = package.Copy; + var mimeMessage = package.Mime; + + mailCopy.UniqueId = Guid.NewGuid(); + mailCopy.AssignedAccount = account; + mailCopy.AssignedFolder = mailItemFolder; + mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false); + mailCopy.FolderId = mailItemFolder.Id; + + var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id); + var contactSaveTask = _contactService.SaveAddressInformationAsync(mimeMessage); + var insertMailTask = InsertMailAsync(mailCopy); + + await Task.WhenAll(mimeSaveTask, contactSaveTask, insertMailTask).ConfigureAwait(false); + } + public async Task CreateMailAsync(Guid accountId, NewMailItemPackage package) { var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);