diff --git a/Wino.Core.Domain/Interfaces/IThreadingStrategy.cs b/Wino.Core.Domain/Interfaces/IThreadingStrategy.cs index ed5d57f0..cd0cec9c 100644 --- a/Wino.Core.Domain/Interfaces/IThreadingStrategy.cs +++ b/Wino.Core.Domain/Interfaces/IThreadingStrategy.cs @@ -7,6 +7,11 @@ namespace Wino.Core.Domain.Interfaces { public interface IThreadingStrategy { + /// + /// Attach thread mails to the list. + /// + /// Original mails. + /// Original mails with thread mails. Task> ThreadItemsAsync(List items); bool ShouldThreadWithItem(IMailItem originalItem, IMailItem targetItem); } diff --git a/Wino.Core/Integration/Threading/APIThreadingStrategy.cs b/Wino.Core/Integration/Threading/APIThreadingStrategy.cs index c1685f95..f1258f9d 100644 --- a/Wino.Core/Integration/Threading/APIThreadingStrategy.cs +++ b/Wino.Core/Integration/Threading/APIThreadingStrategy.cs @@ -26,87 +26,55 @@ namespace Wino.Core.Integration.Threading return originalItem.ThreadId != null && originalItem.ThreadId == targetItem.ThreadId; } + /// public async Task> ThreadItemsAsync(List items) { - var accountId = items.First().AssignedAccount.Id; + var assignedAccount = items[0].AssignedAccount; - var threads = new List(); - var assignedAccount = items.First().AssignedAccount; - - // TODO: Can be optimized by moving to the caller. - var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, Domain.Enums.SpecialFolderType.Sent); - var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, Domain.Enums.SpecialFolderType.Draft); + var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(assignedAccount.Id, SpecialFolderType.Sent); + var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(assignedAccount.Id, SpecialFolderType.Draft); if (sentFolder == null || draftFolder == null) return default; - // Child -> Parent approach. + // True: Non threaded items. + // False: Potentially threaded items. + var nonThreadedOrThreadedMails = items + .Distinct() + .GroupBy(x => string.IsNullOrEmpty(x.ThreadId)) + .ToDictionary(x => x.Key, x => x); - var potentialThreadItems = items.Distinct().Where(a => !string.IsNullOrEmpty(a.ThreadId)); + _ = nonThreadedOrThreadedMails.TryGetValue(true, out var nonThreadedMails); + var isThreadedItems = nonThreadedOrThreadedMails.TryGetValue(false, out var potentiallyThreadedMails); - var mailLookupTable = new Dictionary(); + List resultList = nonThreadedMails is null ? [] : [.. nonThreadedMails]; - // Fill up the mail lookup table to prevent double thread creation. - foreach (var mail in items) - if (!mailLookupTable.ContainsKey(mail.Id)) - mailLookupTable.Add(mail.Id, false); - - foreach (var potentialItem in potentialThreadItems) + if (isThreadedItems) { - if (mailLookupTable[potentialItem.Id]) - continue; + var threadItems = (await GetThreadItemsAsync(potentiallyThreadedMails.Select(x => (x.ThreadId, x.AssignedFolder)).ToList(), assignedAccount.Id, sentFolder.Id, draftFolder.Id)) + .GroupBy(x => x.ThreadId); - mailLookupTable[potentialItem.Id] = true; - - var allThreadItems = await GetThreadItemsAsync(potentialItem.ThreadId, accountId, potentialItem.AssignedFolder, sentFolder.Id, draftFolder.Id); - - if (allThreadItems.Count == 1) + foreach (var threadItem in threadItems) { - // It's a single item. - // Mark as not-processed as thread. - - mailLookupTable[potentialItem.Id] = false; - } - else - { - // Thread item. Mark all items as true in dict. - var threadItem = new ThreadMailItem(); - - foreach (var childThreadItem in allThreadItems) + if (threadItem.Count() == 1) { - if (mailLookupTable.ContainsKey(childThreadItem.Id)) - mailLookupTable[childThreadItem.Id] = true; - - childThreadItem.AssignedAccount = assignedAccount; - childThreadItem.AssignedFolder = await _folderService.GetFolderAsync(childThreadItem.FolderId); - - threadItem.AddThreadItem(childThreadItem); + resultList.Add(threadItem.First()); + continue; } - // Multiple mail copy ids from different folders are thing for Gmail. - if (threadItem.ThreadItems.Count == 1) - mailLookupTable[potentialItem.Id] = false; - else - threads.Add(threadItem); + var thread = new ThreadMailItem(); + foreach (var childThreadItem in threadItem) + { + thread.AddThreadItem(childThreadItem); + } + resultList.Add(thread); } } - // At this points all mails in the list belong to single items. - // Merge with threads. - // Last sorting will be done later on in MailService. - - // Remove single mails that are included in thread. - items.RemoveAll(a => mailLookupTable.ContainsKey(a.Id) && mailLookupTable[a.Id]); - - var finalList = new List(items); - - finalList.AddRange(threads); - - return finalList; + return resultList; } - private async Task> GetThreadItemsAsync(string threadId, + private async Task> GetThreadItemsAsync(List<(string threadId, MailItemFolder threadingFolder)> potentialThread, Guid accountId, - MailItemFolder threadingFolder, Guid sentFolderId, Guid draftFolderId) { @@ -118,24 +86,20 @@ namespace Wino.Core.Integration.Threading // TODO: Convert to SQLKata query. - string query = string.Empty; - - if (threadingFolder.SpecialFolderType == SpecialFolderType.Draft || threadingFolder.SpecialFolderType == SpecialFolderType.Sent) - { - query = @$"SELECT DISTINCT MC.* FROM MailCopy MC + var query = @$"SELECT DISTINCT MC.* FROM MailCopy MC INNER JOIN MailItemFolder MF on MF.Id = MC.FolderId - WHERE MF.MailAccountId == '{accountId}' AND MC.ThreadId = '{threadId}'"; - } - else - { - query = @$"SELECT DISTINCT MC.* FROM MailCopy MC - INNER JOIN MailItemFolder MF on MF.Id = MC.FolderId - WHERE MF.MailAccountId == '{accountId}' AND MC.FolderId IN ('{threadingFolder.Id}','{sentFolderId}','{draftFolderId}') - AND MC.ThreadId = '{threadId}'"; - } - + WHERE MF.MailAccountId == '{accountId}' AND + ({string.Join(" OR ", potentialThread.Select(x => ConditionForItem(x, sentFolderId, draftFolderId)))})"; return await _databaseService.Connection.QueryAsync(query); + + static string ConditionForItem((string threadId, MailItemFolder threadingFolder) potentialThread, Guid sentFolderId, Guid draftFolderId) + { + if (potentialThread.threadingFolder.SpecialFolderType == SpecialFolderType.Draft || potentialThread.threadingFolder.SpecialFolderType == SpecialFolderType.Sent) + return $"(MC.ThreadId = '{potentialThread.threadId}')"; + + return $"(MC.ThreadId = '{potentialThread.threadId}' AND MC.FolderId IN ('{potentialThread.threadingFolder.Id}','{sentFolderId}','{draftFolderId}'))"; + } } } } diff --git a/Wino.Core/Services/MailService.cs b/Wino.Core/Services/MailService.cs index 7cc0bf63..18be6ec9 100644 --- a/Wino.Core/Services/MailService.cs +++ b/Wino.Core/Services/MailService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions.Extensions; using MimeKit; using MimeKit.Text; using MoreLinq; @@ -186,73 +187,97 @@ namespace Wino.Core.Services var mails = await Connection.QueryAsync(query); - // Fill in assigned account and folder for each mail. - // To speed things up a bit, we'll load account and assigned folder in groups - // to reduce the query time. + Dictionary folderCache = []; + Dictionary accountCache = []; - var groupedByFolders = mails.GroupBy(a => a.FolderId); - - foreach (var group in groupedByFolders) + // Populate Folder Assignment for each single mail, to be able later group by "MailAccountId". + // This is needed to execute threading strategy by account type. + // Avoid DBs calls as possible, storing info in a dictionary. + foreach (var mail in mails) { - MailItemFolder folderAssignment = null; - MailAccount accountAssignment = null; - - folderAssignment = await _folderService.GetFolderAsync(group.Key).ConfigureAwait(false); - - if (folderAssignment != null) - { - accountAssignment = await _accountService.GetAccountAsync(folderAssignment.MailAccountId).ConfigureAwait(false); - } - - group.ForEach(a => - { - a.AssignedFolder = folderAssignment; - a.AssignedAccount = accountAssignment; - }); + await LoadAssignedPropertiesWithCacheAsync(mail, folderCache, accountCache).ConfigureAwait(false); } // Remove items that has no assigned account or folder. mails.RemoveAll(a => a.AssignedAccount == null || a.AssignedFolder == null); - // Each account items must be threaded separately. - - if (options.CreateThreads) - { - var threadedItems = new List(); - - var groupedByAccounts = mails.GroupBy(a => a.AssignedAccount.Id); - - foreach (var group in groupedByAccounts) - { - if (!group.Any()) continue; - - var accountId = group.Key; - var groupAccount = mails.First(a => a.AssignedAccount.Id == accountId).AssignedAccount; - - var threadingStrategy = _threadingStrategyProvider.GetStrategy(groupAccount.ProviderType); - - // Only thread items from Draft and Sent folders must present here. - // Otherwise this strategy will fetch the items that are in Deleted folder as well. - var accountThreadedItems = await threadingStrategy.ThreadItemsAsync(group.ToList()); - - if (accountThreadedItems != null) - { - threadedItems.AddRange(accountThreadedItems); - } - } - - threadedItems.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer()); - - return threadedItems; - } - else + if (!options.CreateThreads) { // Threading is disabled. Just return everything as it is. - mails.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer()); return new List(mails); } + + // Populate threaded items. + + var threadedItems = new List(); + + // Each account items must be threaded separately. + foreach (var group in mails.GroupBy(a => a.AssignedAccount.Id)) + { + var accountId = group.Key; + var groupAccount = mails.First(a => a.AssignedAccount.Id == accountId).AssignedAccount; + + var threadingStrategy = _threadingStrategyProvider.GetStrategy(groupAccount.ProviderType); + + // Only thread items from Draft and Sent folders must present here. + // Otherwise this strategy will fetch the items that are in Deleted folder as well. + var accountThreadedItems = await threadingStrategy.ThreadItemsAsync([.. group]); + + // Populate threaded items with folder and account assignments. + // Almost everything already should be in cache from initial population. + foreach (var mail in accountThreadedItems) + { + await LoadAssignedPropertiesWithCacheAsync(mail, folderCache, accountCache).ConfigureAwait(false); + } + + if (accountThreadedItems != null) + { + threadedItems.AddRange(accountThreadedItems); + } + } + + threadedItems.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer()); + + return threadedItems; + + // Recursive function to populate folder and account assignments for each mail item. + async Task LoadAssignedPropertiesWithCacheAsync(IMailItem mail, Dictionary folderCache, Dictionary accountCache) + { + if (mail is ThreadMailItem threadMailItem) + { + foreach (var childMail in threadMailItem.ThreadItems) + { + await LoadAssignedPropertiesWithCacheAsync(childMail, folderCache, accountCache).ConfigureAwait(false); + } + } + + if (mail is MailCopy mailCopy) + { + MailAccount accountAssignment = null; + + var isFolderCached = folderCache.TryGetValue(mailCopy.FolderId, out MailItemFolder folderAssignment); + accountAssignment = null; + if (!isFolderCached) + { + folderAssignment = await _folderService.GetFolderAsync(mailCopy.FolderId).ConfigureAwait(false); + _ = folderCache.TryAdd(mailCopy.FolderId, folderAssignment); + } + if (folderAssignment != null) + { + var isAccountCached = accountCache.TryGetValue(folderAssignment.MailAccountId, out accountAssignment); + if (!isAccountCached) + { + accountAssignment = await _accountService.GetAccountAsync(folderAssignment.MailAccountId).ConfigureAwait(false); + _ = accountCache.TryAdd(folderAssignment.MailAccountId, accountAssignment); + } + } + + mailCopy.AssignedFolder = folderAssignment; + mailCopy.AssignedAccount = accountAssignment; + } + } } private async Task> GetMailItemsAsync(string mailCopyId)