Online Search (#576)

* Very basic online search for gmail.

* Server side of handling offline search and listing part in listing page.

* Default search mode implementation and search UI improvements.

* Online search for Outlook.

* Very basic online search for gmail.

* Server side of handling offline search and listing part in listing page.

* Default search mode implementation and search UI improvements.

* Online search for Outlook.

* Online search for imap without downloading the messages yet. TODO

* Completing imap search.
This commit is contained in:
Burak Kaan Köse
2025-02-22 00:22:00 +01:00
committed by GitHub
parent 42b695854b
commit f61bcb621b
30 changed files with 900 additions and 209 deletions

View File

@@ -1,4 +1,5 @@
namespace Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Enums;
public enum MailListDisplayMode
{

View File

@@ -0,0 +1,6 @@
namespace Wino.Core.Domain.Enums;
public enum SearchMode
{
Local,
Online
}

View File

@@ -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
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of new downloaded message ids that don't exist locally.</returns>
Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
/// <summary>
/// Downloads given set of messages from the folder.
/// Folder is expected to be opened and synchronizer is connected.
/// </summary>
/// <param name="synchronizer">Synchronizer that performs the action.</param>
/// <param name="folder">Remote folder to download messages from.</param>
/// <param name="uniqueIdSet">Set of message uniqueids.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DownloadMessagesAsync(IImapSynchronizer synchronizer, IMailFolder folder, UniqueIdSet uniqueIdSet, CancellationToken cancellationToken = default);
}

View File

@@ -13,6 +13,14 @@ public interface IMailService
{
Task<MailCopy> GetSingleMailItemAsync(string mailCopyId, string remoteFolderId);
Task<MailCopy> GetSingleMailItemAsync(Guid uniqueMailId);
/// <summary>
/// Returns the single mail item with the given mail copy id.
/// Caution: This method is not safe. Use other overrides.
/// </summary>
/// <param name="mailCopyId"></param>
/// <returns></returns>
Task<MailCopy> GetSingleMailItemAsync(string mailCopyId);
Task<List<IMailItem>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default);
/// <summary>
@@ -117,4 +125,14 @@ public interface IMailService
/// <param name="uniqueIds"></param>
/// <returns></returns>
Task<List<MailCopy>> GetExistingMailsAsync(Guid folderId, IEnumerable<UniqueId> uniqueIds);
/// <summary>
/// Creates a new mail from a package without doing any existence check.
/// Use it with caution.
/// </summary>
/// <param name="account">Account that mail belongs to.</param>
/// <param name="mailItemFolder">Assigned folder.</param>
/// <param name="package">Mail creation package.</param>
/// <returns></returns>
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
}

View File

@@ -39,6 +39,17 @@ public interface IPreferencesService
/// </summary>
bool Prefer24HourTimeFormat { get; set; }
/// <summary>
/// Diagnostic ID for the application.
/// Changes per-install.
/// </summary>
string DiagnosticId { get; set; }
/// <summary>
/// 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.
/// </summary>
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();

View File

@@ -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.
/// </summary>
Task KillSynchronizerAsync();
/// <summary>
/// Perform online search on the server.
/// </summary>
/// <param name="queryText">Search query.</param>
/// <param name="folders">Folders to include in search. All folders if null.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Search results after downloading missing mail copies from server.</returns>
Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> folders, CancellationToken cancellationToken = default);
}

View File

@@ -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<IMailItemFolder> Folders
bool CreateThreads,
bool? IsFocusedOnly,
string SearchQuery,
IEnumerable<Guid> ExistingUniqueIds);
IEnumerable<Guid> ExistingUniqueIds,
List<MailCopy> PreFetchMailCopies = null);

View File

@@ -0,0 +1,6 @@
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Mail;
namespace Wino.Core.Domain.Models.Synchronization;
public record OnlineSearchResult(List<MailCopy> SearchResult);

View File

@@ -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"
}

View File

@@ -72,6 +72,7 @@
<ListView
Grid.Row="2"
Margin="0,12"
Padding="0"
ItemTemplate="{StaticResource NewMailProviderTemplate}"
ItemsSource="{x:Bind Providers}"

View File

@@ -235,6 +235,12 @@ public class PreferencesService : ObservableObject, IPreferencesService
set => 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);

View File

@@ -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()
{

View File

@@ -38,9 +38,17 @@ public interface IDefaultChangeProcessor
Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(MailSynchronizationOptions options);
Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId);
Task UpdateFolderLastSyncDateAsync(Guid folderId);
Task<List<MailItemFolder>> GetExistingFoldersAsync(Guid accountId);
Task UpdateRemoteAliasInformationAsync(MailAccount account, List<RemoteAccountAlias> remoteAccountAliases);
/// <summary>
/// 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.
/// </summary>
/// <param name="messageId">MailCopyId of the message.</param>
/// <returns>Whether mail exists or not.</returns>
Task<bool> IsMailExistsAsync(string messageId);
// Calendar
Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId);
@@ -51,6 +59,8 @@ public interface IDefaultChangeProcessor
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
Task<MailCopy> 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
{
/// <summary>
/// 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.
/// </summary>
/// <param name="messageId">MailCopyId of the message.</param>
/// <returns>Whether the mime has b</returns>
Task<bool> IsMailExistsAsync(string messageId);
/// <summary>
/// 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<bool> IsMailExistsAsync(string messageId)
=> MailService.IsMailExistsAsync(messageId);
public Task<MailCopy> 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<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
=> MailService.CreateMailAsync(accountId, package);
public Task<List<MailItemFolder>> GetExistingFoldersAsync(Guid accountId)
=> FolderService.GetFoldersAsync(accountId);
public Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package)
=> MailService.CreateMailRawAsync(account, mailItemFolder, package);
public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
=> MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId);

View File

@@ -20,8 +20,6 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
IMimeFileService mimeFileService) : DefaultChangeProcessor(databaseService, folderService, mailService, calendarService, accountService, mimeFileService)
, IOutlookChangeProcessor
{
public Task<bool> IsMailExistsAsync(string messageId)
=> MailService.IsMailExistsAsync(messageId);
public Task<bool> IsMailExistsInFolderAsync(string messageId, Guid folderId)
=> MailService.IsMailExistsAsync(messageId, folderId);

View File

@@ -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<IClientServiceRequest, Message
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, singleDraftRequest, singleDraftRequest)];
}
public override async Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> 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<Message>();
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<string> 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<MailCopy>();
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)

View File

@@ -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<Task>();
// 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);
}
}
}
}
}

View File

@@ -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<ImapRequest, ImapMessageCreatio
}
}
public override async Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> folders, CancellationToken cancellationToken = default)
{
IImapClient client = null;
IMailFolder activeFolder = null;
try
{
client = await _clientPool.GetClientAsync().ConfigureAwait(false);
var searchResults = new List<MailCopy>();
List<string> 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<UniqueId>();
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<MailCopy>();
}
private async Task<IEnumerable<string>> SynchronizeFolderInternalAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
{
if (!folder.IsSynchronizationEnabled) return default;

View File

@@ -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<RequestInformation, Message, Event>
{
@@ -187,6 +188,33 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
return MailSynchronizationResult.Completed(unreadNewItems);
}
public async Task DownloadSearchResultMessageAsync(string messageId, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
Log.Information("Downloading search result message {messageId} for {Name} - {FolderName}", messageId, Account.Name, assignedFolder.FolderName);
// Outlook message handling was a little strange.
// Instead of changing it from the scratch, we will just download the message and process it.
// Search results will only return Id for the messages.
// This method will download the raw mime, get the required enough metadata from the service and create
// the mail locally. Message ids passed to this method is expected to be non-existent locally.
var message = await _graphClient.Me.Messages[messageId].GetAsync((config) =>
{
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<IEnumerable<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
@@ -927,6 +955,109 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
}
}
public override async Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> folders, CancellationToken cancellationToken = default)
{
bool isFoldersIncluded = folders?.Any() ?? false;
var messagesToDownload = new List<Message>();
// 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<string>();
//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<MailCopy>();
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<MimeMessage> 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<RequestInformation, Message,
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
var mimeMessage = await DownloadMimeMessageAsync(message.Id, cancellationToken).ConfigureAwait(false);
var mailCopy = message.AsMailCopy();

View File

@@ -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 WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
/// <param name="cancellationToken">Cancellation token.</param>
public virtual Task DownloadMissingMimeMessageAsync(IMailItem mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
/// <summary>
/// Performs an online search for the given query text in the given folders.
/// Downloads the missing messages from the server.
/// </summary>
/// <param name="queryText">Query to search for.</param>
/// <param name="folders">Which folders to include in.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="NotSupportedException"></exception>
public virtual Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> folders, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public List<IRequestBundle<ImapRequest>> CreateSingleTaskBundle(Func<IImapClient, IRequestBase, Task> action, IRequestBase request, IUIChangeRequest uIChangeRequest)
{
return [new ImapRequestBundle(new ImapRequest(action, request), request, uIChangeRequest)];

View File

@@ -18,6 +18,10 @@ public partial class AppPreferencesPageViewModel : MailBaseViewModel
[ObservableProperty]
private List<string> _appTerminationBehavior;
[ObservableProperty]
public partial List<string> 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]

View File

@@ -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;
/// <summary>
/// Indicates current state of the mail list. Doesn't matter it's loading or no.
/// </summary>
@@ -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.
/// </summary>
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,11 +394,19 @@ public partial class MailListPageViewModel : MailBaseViewModel,
private async Task UpdateFolderPivotsAsync()
{
if (ActiveFolder == null) return;
PivotFolders.Clear();
SelectedFolderPivot = null;
if (ActiveFolder == null) return;
if (IsInSearchMode)
{
var isFocused = SelectedFolderPivot?.IsFocused;
PivotFolders.Add(new FolderPivotViewModel(Translator.SearchPivotName, isFocused));
}
else
{
// Merged folders don't support focused feature.
if (ActiveFolder is IMergedAccountFolderMenuItem)
@@ -412,6 +436,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
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)
{
IsOnlineSearchButtonVisible = false;
}
await UpdateFolderPivotsAsync();
IsInSearchMode = false;
await InitializeFolderAsync();
}
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();
}
}
[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<IMailItem> items = null;
List<MailCopy> 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> 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<OnlineSearchResult, OnlineSearchRequested>(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;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -438,8 +438,10 @@ public sealed partial class MailListPage : MailListPageAbstract,
private async void SearchBar_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
// User clicked 'x' button to clearout the search text.
if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput && string.IsNullOrWhiteSpace(sender.Text))
{
ViewModel.IsOnlineSearchButtonVisible = false;
await ViewModel.PerformSearchAsync();
}
}

View File

@@ -21,13 +21,22 @@
<PathIcon Data="F1 M 9.375 8.125 L 9.375 0.625 C 9.375 0.45573 9.436849 0.309246 9.560547 0.185547 C 9.684244 0.06185 9.830729 0 10 0 C 10.169271 0 10.315755 0.06185 10.439453 0.185547 C 10.56315 0.309246 10.625 0.45573 10.625 0.625 L 10.625 8.125 C 10.625 8.294271 10.56315 8.440756 10.439453 8.564453 C 10.315755 8.688151 10.169271 8.75 10 8.75 C 9.830729 8.75 9.684244 8.688151 9.560547 8.564453 C 9.436849 8.440756 9.375 8.294271 9.375 8.125 Z M 0.625 10.625 C 0.625 9.811198 0.732422 9.008789 0.947266 8.217773 C 1.162109 7.426758 1.469727 6.678061 1.870117 5.97168 C 2.270508 5.265301 2.755534 4.617514 3.325195 4.02832 C 3.894857 3.439129 4.534505 2.942709 5.244141 2.539062 C 5.341797 2.480469 5.449219 2.451172 5.566406 2.451172 C 5.735677 2.451172 5.882161 2.513021 6.005859 2.636719 C 6.129557 2.760418 6.191406 2.906902 6.191406 3.076172 C 6.191406 3.206381 6.163737 3.310547 6.108398 3.388672 C 6.05306 3.466797 5.976562 3.541668 5.878906 3.613281 C 5.651042 3.769531 5.42806 3.920898 5.209961 4.067383 C 4.991862 4.213867 4.778646 4.381511 4.570312 4.570312 C 4.153646 4.947917 3.779297 5.367839 3.447266 5.830078 C 3.115234 6.292318 2.832031 6.7806 2.597656 7.294922 C 2.363281 7.809245 2.184245 8.3431 2.060547 8.896484 C 1.936849 9.44987 1.875 10.009766 1.875 10.576172 C 1.875 11.331381 1.971029 12.055664 2.163086 12.749023 C 2.355143 13.442383 2.625325 14.091797 2.973633 14.697266 C 3.32194 15.302734 3.74349 15.854492 4.238281 16.352539 C 4.733073 16.850586 5.281575 17.277018 5.883789 17.631836 C 6.486002 17.986654 7.133789 18.261719 7.827148 18.457031 C 8.520508 18.652344 9.244791 18.75 10 18.75 C 10.755208 18.75 11.479492 18.652344 12.172852 18.457031 C 12.86621 18.261719 13.513996 17.986654 14.116211 17.631836 C 14.718424 17.277018 15.266927 16.850586 15.761719 16.352539 C 16.25651 15.854492 16.678059 15.302734 17.026367 14.697266 C 17.374674 14.091797 17.644855 13.440756 17.836914 12.744141 C 18.028971 12.047526 18.125 11.32487 18.125 10.576172 C 18.125 10.009766 18.06315 9.44987 17.939453 8.896484 C 17.815754 8.3431 17.636719 7.809245 17.402344 7.294922 C 17.167969 6.7806 16.884766 6.292318 16.552734 5.830078 C 16.220703 5.367839 15.846354 4.947917 15.429688 4.570312 C 15.221354 4.381511 15.008137 4.213867 14.790039 4.067383 C 14.571939 3.920898 14.348958 3.769531 14.121094 3.613281 C 14.023437 3.541668 13.946939 3.466797 13.891602 3.388672 C 13.836263 3.310547 13.808594 3.206381 13.808594 3.076172 C 13.808594 2.906902 13.870442 2.760418 13.994141 2.636719 C 14.117838 2.513021 14.264322 2.451172 14.433594 2.451172 C 14.550781 2.451172 14.658203 2.480469 14.755859 2.539062 C 15.465494 2.942709 16.105143 3.439129 16.674805 4.02832 C 17.244465 4.617514 17.729492 5.265301 18.129883 5.97168 C 18.530273 6.678061 18.837891 7.426758 19.052734 8.217773 C 19.267578 9.008789 19.375 9.811198 19.375 10.625 C 19.375 11.484375 19.262695 12.312826 19.038086 13.110352 C 18.813477 13.907878 18.497721 14.654948 18.09082 15.351562 C 17.683918 16.048178 17.195637 16.681316 16.625977 17.250977 C 16.056314 17.820639 15.423177 18.30892 14.726562 18.71582 C 14.029947 19.122721 13.282877 19.438477 12.485352 19.663086 C 11.687825 19.887695 10.859375 20 10 20 C 9.134114 20 8.302408 19.887695 7.504883 19.663086 C 6.707356 19.438477 5.961914 19.122721 5.268555 18.71582 C 4.575195 18.30892 3.943685 17.820639 3.374023 17.250977 C 2.804362 16.681316 2.316081 16.049805 1.90918 15.356445 C 1.502279 14.663086 1.186523 13.916016 0.961914 13.115234 C 0.737305 12.314453 0.625 11.484375 0.625 10.625 Z " />
</controls:SettingsCard.HeaderIcon>
</controls:SettingsCard>
<controls:SettingsCard Description="{x:Bind domain:Translator.SettingsAppPreferences_CloseBehavior_Description}" Header="{x:Bind domain:Translator.SettingsAppPreferences_CloseBehavior_Title}">
<ComboBox ItemsSource="{x:Bind ViewModel.AppTerminationBehavior, Mode=OneWay}" SelectedItem="{x:Bind ViewModel.SelectedAppTerminationBehavior, Mode=TwoWay}" />
<controls:SettingsCard.HeaderIcon>
<PathIcon Data="F1 M 1.591797 15.244141 C 1.591797 15.055339 1.635742 14.912109 1.723633 14.814453 C 1.811523 14.716797 1.930339 14.625651 2.080078 14.541016 C 2.26237 14.443359 2.457682 14.363607 2.666016 14.301758 C 2.874349 14.239909 3.072917 14.169922 3.261719 14.091797 C 3.834635 13.863933 4.394531 13.606771 4.941406 13.320312 C 5.488281 13.033854 6.005859 12.705078 6.494141 12.333984 C 7.106119 11.871745 7.643229 11.352539 8.105469 10.776367 C 8.567708 10.200195 8.95345 9.584961 9.262695 8.930664 C 9.571939 8.276367 9.804688 7.587891 9.960938 6.865234 C 10.117188 6.142578 10.195312 5.400391 10.195312 4.638672 C 10.195312 4.007162 10.144856 3.390301 10.043945 2.788086 C 9.943033 2.185873 9.830729 1.578777 9.707031 0.966797 C 9.700521 0.934246 9.695638 0.904949 9.692383 0.878906 C 9.689127 0.852865 9.6875 0.823568 9.6875 0.791016 C 9.6875 0.582684 9.759114 0.406902 9.902344 0.263672 C 10.045572 0.120443 10.221354 0.048828 10.429688 0.048828 C 10.800781 0.048828 11.183268 0.083008 11.577148 0.151367 C 11.971028 0.219727 12.361653 0.314129 12.749023 0.43457 C 13.136393 0.555014 13.515624 0.694988 13.886719 0.854492 C 14.257812 1.013998 14.602863 1.184896 14.921875 1.367188 C 15.690104 1.809896 16.383463 2.342123 17.001953 2.963867 C 17.620441 3.585613 18.144531 4.270834 18.574219 5.019531 C 19.003906 5.76823 19.334309 6.567384 19.56543 7.416992 C 19.796549 8.266602 19.912109 9.134115 19.912109 10.019531 C 19.912109 10.9375 19.793293 11.821289 19.555664 12.670898 C 19.318033 13.520508 18.981119 14.314779 18.544922 15.053711 C 18.108723 15.792644 17.587891 16.464844 16.982422 17.070312 C 16.376953 17.675781 15.703125 18.194986 14.960938 18.62793 C 14.21875 19.060873 13.422852 19.396158 12.573242 19.633789 C 11.723633 19.87142 10.839844 19.990234 9.921875 19.990234 C 9.140625 19.990234 8.352864 19.890951 7.558594 19.692383 C 6.764323 19.493814 6.005859 19.208984 5.283203 18.837891 C 4.560547 18.466797 3.891602 18.014322 3.276367 17.480469 C 2.661133 16.946615 2.141927 16.3444 1.71875 15.673828 C 1.634115 15.54362 1.591797 15.400391 1.591797 15.244141 Z M 9.921875 18.730469 C 10.722656 18.730469 11.495768 18.626303 12.241211 18.417969 C 12.986652 18.209635 13.683268 17.918295 14.331055 17.543945 C 14.978841 17.169598 15.568033 16.717123 16.098633 16.186523 C 16.62923 15.655925 17.084961 15.068359 17.46582 14.423828 C 17.84668 13.779297 18.141275 13.084311 18.349609 12.338867 C 18.557941 11.593425 18.662109 10.820312 18.662109 10.019531 C 18.662109 9.290365 18.575846 8.583984 18.40332 7.900391 C 18.230793 7.216798 17.983398 6.569012 17.661133 5.957031 C 17.338867 5.345053 16.951496 4.778646 16.499023 4.257812 C 16.046549 3.73698 15.538736 3.276367 14.975586 2.875977 C 14.412435 2.475586 13.802083 2.145184 13.144531 1.884766 C 12.486979 1.62435 11.796874 1.448568 11.074219 1.357422 C 11.184896 1.897787 11.274414 2.439779 11.342773 2.983398 C 11.411133 3.52702 11.445312 4.075521 11.445312 4.628906 C 11.445312 5.579428 11.336263 6.500652 11.118164 7.392578 C 10.900064 8.284506 10.569661 9.150391 10.126953 9.990234 C 9.749349 10.706381 9.309896 11.342773 8.808594 11.899414 C 8.307291 12.456055 7.758789 12.954102 7.163086 13.393555 C 6.567382 13.833008 5.929361 14.222006 5.249023 14.560547 C 4.568685 14.899089 3.863932 15.208334 3.134766 15.488281 C 3.544922 16.002604 4.005534 16.461588 4.516602 16.865234 C 5.027669 17.268881 5.574544 17.609049 6.157227 17.885742 C 6.739908 18.162436 7.348632 18.372396 7.983398 18.515625 C 8.618164 18.658854 9.264322 18.730469 9.921875 18.730469 Z " />
</controls:SettingsCard.HeaderIcon>
</controls:SettingsCard>
<controls:SettingsCard Description="{x:Bind domain:Translator.SettingsAppPreferences_SearchMode_Description}" Header="{x:Bind domain:Translator.SettingsAppPreferences_SearchMode_Title}">
<ComboBox ItemsSource="{x:Bind ViewModel.SearchModes, Mode=OneWay}" SelectedItem="{x:Bind ViewModel.SelectedDefaultSearchMode, Mode=TwoWay}" />
<controls:SettingsCard.HeaderIcon>
<PathIcon Data="F1 M 18.75 18.125 C 18.75 18.294271 18.68815 18.440756 18.564453 18.564453 C 18.440754 18.68815 18.29427 18.75 18.125 18.75 C 17.955729 18.75 17.809244 18.68815 17.685547 18.564453 L 12.519531 13.398438 C 11.907552 13.912761 11.22233 14.308269 10.463867 14.584961 C 9.705403 14.861654 8.925781 15 8.125 15 C 7.493489 15 6.884765 14.91862 6.298828 14.755859 C 5.712891 14.5931 5.166016 14.361979 4.658203 14.0625 C 4.150391 13.763021 3.686523 13.40332 3.266602 12.983398 C 2.84668 12.563477 2.486979 12.099609 2.1875 11.591797 C 1.888021 11.083984 1.656901 10.537109 1.494141 9.951172 C 1.33138 9.365234 1.25 8.756511 1.25 8.125 C 1.25 7.49349 1.33138 6.884766 1.494141 6.298828 C 1.656901 5.712891 1.888021 5.166016 2.1875 4.658203 C 2.486979 4.150391 2.84668 3.686523 3.266602 3.266602 C 3.686523 2.84668 4.150391 2.48698 4.658203 2.1875 C 5.166016 1.888021 5.712891 1.656902 6.298828 1.494141 C 6.884765 1.331381 7.493489 1.25 8.125 1.25 C 8.75651 1.25 9.365234 1.331381 9.951172 1.494141 C 10.537109 1.656902 11.083984 1.888021 11.591797 2.1875 C 12.099609 2.48698 12.563477 2.84668 12.983398 3.266602 C 13.40332 3.686523 13.763021 4.150391 14.0625 4.658203 C 14.361979 5.166016 14.593099 5.712891 14.755859 6.298828 C 14.918619 6.884766 14.999999 7.49349 15 8.125 C 14.999999 8.925781 14.861652 9.705404 14.584961 10.463867 C 14.308268 11.222331 13.91276 11.907553 13.398438 12.519531 L 18.564453 17.685547 C 18.68815 17.809244 18.75 17.955729 18.75 18.125 Z M 13.75 8.125 C 13.75 7.610678 13.683268 7.114258 13.549805 6.635742 C 13.416341 6.157227 13.227539 5.709636 12.983398 5.292969 C 12.739258 4.876303 12.444661 4.495443 12.099609 4.150391 C 11.754557 3.80534 11.373697 3.510742 10.957031 3.266602 C 10.540364 3.022461 10.092773 2.83366 9.614258 2.700195 C 9.135742 2.566732 8.639322 2.5 8.125 2.5 C 7.35026 2.5 6.621094 2.648113 5.9375 2.944336 C 5.253906 3.240561 4.658203 3.642578 4.150391 4.150391 C 3.642578 4.658204 3.24056 5.253907 2.944336 5.9375 C 2.648112 6.621095 2.5 7.350262 2.5 8.125 C 2.5 8.90625 2.646484 9.638672 2.939453 10.322266 C 3.232422 11.005859 3.632812 11.601562 4.140625 12.109375 C 4.648438 12.617188 5.244141 13.017578 5.927734 13.310547 C 6.611328 13.603516 7.34375 13.75 8.125 13.75 C 8.899739 13.75 9.628906 13.601889 10.3125 13.305664 C 10.996094 13.00944 11.591797 12.607422 12.099609 12.099609 C 12.607421 11.591797 13.009439 10.996094 13.305664 10.3125 C 13.601888 9.628906 13.75 8.89974 13.75 8.125 Z " />
</controls:SettingsCard.HeaderIcon>
</controls:SettingsCard>
</StackPanel>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="StartupBehaviorStates">
<VisualState x:Name="StartupEnabled" />

View File

@@ -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;
/// <summary>
/// Raised when user performs search on the search bar.
/// </summary>
/// <param name="AccountIds">Accounts that performs the query. Multiple accounts for linked accounts.</param>
/// <param name="QueryText">Search query.</param>
/// <param name="Folders">Folders to include in search. All folders if null.</param>
public record OnlineSearchRequested(List<Guid> AccountIds, string QueryText, List<IMailItemFolder> Folders) : IClientMessage;

View File

@@ -23,6 +23,7 @@ public class ServerMessageHandlerFactory : IServerMessageHandlerFactory
nameof(TerminateServerRequested) => App.Current.Services.GetService<TerminateServerRequestHandler>(),
nameof(ImapConnectivityTestRequested) => App.Current.Services.GetService<ImapConnectivityTestHandler>(),
nameof(KillAccountSynchronizerRequested) => App.Current.Services.GetService<KillAccountSynchronizerHandler>(),
nameof(OnlineSearchRequested) => App.Current.Services.GetService<OnlineSearchRequestHandler>(),
_ => throw new Exception($"Server handler for {typeName} is not registered."),
};
}
@@ -41,5 +42,6 @@ public class ServerMessageHandlerFactory : IServerMessageHandlerFactory
serviceCollection.AddTransient<TerminateServerRequestHandler>();
serviceCollection.AddTransient<ImapConnectivityTestHandler>();
serviceCollection.AddTransient<KillAccountSynchronizerHandler>();
serviceCollection.AddTransient<OnlineSearchRequestHandler>();
}
}

View File

@@ -51,6 +51,14 @@ public class MailSynchronizationRequestHandler : ServerMessageHandler<NewMailSyn
try
{
// test
//if (synchronizer.Account.ProviderType == MailProviderType.Gmail)
//{
// await synchronizer.OnlineSearchAsync("label:unread", null, cancellationToken);
// return WinoServerResponse<MailSynchronizationResult>.CreateSuccessResponse(MailSynchronizationResult.Canceled);
//}
var synchronizationResult = await synchronizer.SynchronizeMailsAsync(message.Options, cancellationToken).ConfigureAwait(false);
if (synchronizationResult.DownloadedMessages?.Any() ?? false || !synchronizer.Account.Preferences.IsNotificationsEnabled)

View File

@@ -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<OnlineSearchRequested, OnlineSearchResult>
{
private readonly ISynchronizerFactory _synchronizerFactory;
public OnlineSearchRequestHandler(ISynchronizerFactory synchronizerFactory)
{
_synchronizerFactory = synchronizerFactory;
}
public override WinoServerResponse<OnlineSearchResult> FailureDefaultResponse(Exception ex)
=> WinoServerResponse<OnlineSearchResult>.CreateErrorResponse(ex.Message);
protected override async Task<WinoServerResponse<OnlineSearchResult>> HandleAsync(OnlineSearchRequested message, CancellationToken cancellationToken = default)
{
List<IWinoSynchronizerBase> 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<OnlineSearchResult>.CreateSuccessResponse(new OnlineSearchResult(allResults));
}
}

View File

@@ -43,7 +43,8 @@ public class ServerContext :
IRecipient<AccountSynchronizationProgressUpdatedMessage>,
IRecipient<AccountFolderConfigurationUpdated>,
IRecipient<CopyAuthURLRequested>,
IRecipient<NewMailSynchronizationRequested>
IRecipient<NewMailSynchronizationRequested>,
IRecipient<OnlineSearchRequested>
{
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<KillAccountSynchronizerRequested>(messageJson, _jsonSerializerOptions));
break;
case nameof(OnlineSearchRequested):
await ExecuteServerMessageSafeAsync(args, JsonSerializer.Deserialize<OnlineSearchRequested>(messageJson, _jsonSerializerOptions));
break;
default:
Debug.WriteLine($"Missing handler for {typeName} in the server. Check ServerContext.cs - HandleServerMessageAsync.");
break;

View File

@@ -195,9 +195,20 @@ public class MailService : BaseDatabaseService, IMailService
public async Task<List<IMailItem>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
{
List<MailCopy> mails = null;
// 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);
var mails = await Connection.QueryAsync<MailCopy>(query);
mails = await Connection.QueryAsync<MailCopy>(query);
}
Dictionary<Guid, MailItemFolder> folderCache = [];
Dictionary<Guid, MailAccount> accountCache = [];
@@ -406,6 +417,27 @@ public class MailService : BaseDatabaseService, IMailService
return mailCopy;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="mailCopyId">Mail copy id.</param>
public async Task<MailCopy> GetSingleMailItemAsync(string mailCopyId)
{
var query = new Query("MailCopy")
.Where("MailCopy.Id", mailCopyId)
.SelectRaw("MailCopy.*")
.GetRawQuery();
var mailCopy = await Connection.FindWithQueryAsync<MailCopy>(query);
if (mailCopy == null) return null;
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
return mailCopy;
}
public async Task<MailCopy> 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<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
{
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);