Search improvements.
This commit is contained in:
@@ -248,6 +248,20 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
// Gmail's Messages API doesn't expose Draft IDs, so we query the Drafts API separately.
|
||||
// This ensures DraftId is correctly set for both Wino-created and externally-created drafts.
|
||||
await MapDraftIdsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Keep virtual Archive folder assignments in sync with Gmail "in:archive" query.
|
||||
try
|
||||
{
|
||||
await MapArchivedMailsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to map Gmail archive folder for {Name}", Account.Name);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -1175,52 +1189,89 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
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.
|
||||
if (string.IsNullOrWhiteSpace(queryText))
|
||||
return [];
|
||||
|
||||
string pageToken = null;
|
||||
static bool IsArchiveFolder(IMailItemFolder folder)
|
||||
=> folder?.SpecialFolderType == SpecialFolderType.Archive || folder?.RemoteFolderId == ServiceConstants.ARCHIVE_LABEL_ID;
|
||||
|
||||
List<Message> messagesToDownload = [];
|
||||
var messageIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
do
|
||||
async Task CollectMessageIdsAsync(UsersResource.MessagesResource.ListRequest request)
|
||||
{
|
||||
if (queryText.StartsWith("label:") || queryText.StartsWith("in:"))
|
||||
string pageToken = null;
|
||||
|
||||
do
|
||||
{
|
||||
// Ignore the folders if the query starts with these keywords.
|
||||
// User is trying to list everything.
|
||||
}
|
||||
else if (folders?.Count > 0)
|
||||
if (!string.IsNullOrEmpty(pageToken))
|
||||
{
|
||||
request.PageToken = pageToken;
|
||||
}
|
||||
|
||||
var response = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (response.Messages == null || response.Messages.Count == 0) break;
|
||||
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(message.Id))
|
||||
{
|
||||
messageIds.Add(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = response.NextPageToken;
|
||||
} while (!string.IsNullOrEmpty(pageToken));
|
||||
}
|
||||
|
||||
bool hasScopedQuery = queryText.StartsWith("label:", StringComparison.OrdinalIgnoreCase) ||
|
||||
queryText.StartsWith("in:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (hasScopedQuery || folders?.Count == 0)
|
||||
{
|
||||
var request = _gmailService.Users.Messages.List("me");
|
||||
request.Q = queryText;
|
||||
request.MaxResults = 500;
|
||||
|
||||
await CollectMessageIdsAsync(request).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
request.LabelIds = folders.Select(a => a.RemoteFolderId).ToList();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var request = _gmailService.Users.Messages.List("me");
|
||||
request.MaxResults = 500;
|
||||
|
||||
if (IsArchiveFolder(folder))
|
||||
{
|
||||
// Gmail archive is virtual. Query via search operator instead of label id.
|
||||
request.Q = $"in:archive {queryText}".Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
request.Q = queryText;
|
||||
request.LabelIds = new List<string> { folder.RemoteFolderId };
|
||||
}
|
||||
|
||||
await CollectMessageIdsAsync(request).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(pageToken))
|
||||
{
|
||||
request.PageToken = pageToken;
|
||||
}
|
||||
if (messageIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var response = await request.ExecuteAsync(cancellationToken);
|
||||
if (response.Messages == null) break;
|
||||
var messageIdList = messageIds.ToList();
|
||||
|
||||
// Handle skipping manually
|
||||
messagesToDownload.AddRange(response.Messages);
|
||||
// Do not download messages that already exist locally.
|
||||
var existingMessageIds = await _gmailChangeProcessor.AreMailsExistsAsync(messageIdList).ConfigureAwait(false);
|
||||
var messagesToDownload = messageIdList.Except(existingMessageIds, StringComparer.Ordinal);
|
||||
|
||||
pageToken = response.NextPageToken;
|
||||
} while (!string.IsNullOrEmpty(pageToken));
|
||||
|
||||
// Do not download messages that exists, but return them for listing.
|
||||
|
||||
var messageIds = messagesToDownload.Select(a => a.Id);
|
||||
|
||||
var downloadRequireMessageIds = messageIds.Except(await _gmailChangeProcessor.AreMailsExistsAsync(messageIds));
|
||||
|
||||
// Download missing messages in batch.
|
||||
await DownloadMessagesInBatchAsync(downloadRequireMessageIds, cancellationToken).ConfigureAwait(false);
|
||||
// Download missing messages in batch with metadata only.
|
||||
await DownloadMessagesInBatchAsync(messagesToDownload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get results from database and return.
|
||||
|
||||
return await _gmailChangeProcessor.GetMailCopiesAsync(messageIds);
|
||||
return await _gmailChangeProcessor.GetMailCopiesAsync(messageIdList).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1644,41 +1695,62 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
private async Task MapArchivedMailsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!archiveFolderId.HasValue) return;
|
||||
|
||||
var request = _gmailService.Users.Messages.List("me");
|
||||
request.Q = "in:archive";
|
||||
request.MaxResults = InitialMessageDownloadCountPerFolder;
|
||||
request.MaxResults = 500;
|
||||
|
||||
string pageToken = null;
|
||||
|
||||
var archivedMessageIds = new List<string>();
|
||||
var archivedMessageIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
do
|
||||
{
|
||||
if (!string.IsNullOrEmpty(pageToken)) request.PageToken = pageToken;
|
||||
|
||||
var response = await request.ExecuteAsync(cancellationToken);
|
||||
var response = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (response.Messages == null) break;
|
||||
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
if (archivedMessageIds.Contains(message.Id)) continue;
|
||||
|
||||
archivedMessageIds.Add(message.Id);
|
||||
if (!string.IsNullOrEmpty(message.Id))
|
||||
{
|
||||
archivedMessageIds.Add(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = response.NextPageToken;
|
||||
} while (!string.IsNullOrEmpty(pageToken));
|
||||
|
||||
var result = await _gmailChangeProcessor.GetGmailArchiveComparisonResultAsync(archiveFolderId.Value, archivedMessageIds).ConfigureAwait(false);
|
||||
var result = await _gmailChangeProcessor.GetGmailArchiveComparisonResultAsync(archiveFolderId.Value, archivedMessageIds.ToList()).ConfigureAwait(false);
|
||||
|
||||
foreach (var archiveAddedItem in result.Added)
|
||||
var addedArchiveIds = result.Added.Distinct(StringComparer.Ordinal).ToList();
|
||||
var removedArchiveIds = result.Removed.Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
if (addedArchiveIds.Count > 0)
|
||||
{
|
||||
await HandleArchiveAssignmentAsync(archiveAddedItem);
|
||||
// Archive sync can surface messages that were never downloaded before.
|
||||
// Download metadata first so assignment creation can succeed.
|
||||
var existingBeforeDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false);
|
||||
var missingArchiveIds = addedArchiveIds.Except(existingBeforeDownload, StringComparer.Ordinal).ToList();
|
||||
|
||||
if (missingArchiveIds.Count > 0)
|
||||
{
|
||||
await DownloadMessagesInBatchAsync(missingArchiveIds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var existingAfterDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false);
|
||||
|
||||
foreach (var archiveAddedItem in existingAfterDownload)
|
||||
{
|
||||
await HandleArchiveAssignmentAsync(archiveAddedItem).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var unAarchivedRemovedItem in result.Removed)
|
||||
foreach (var unAarchivedRemovedItem in removedArchiveIds)
|
||||
{
|
||||
await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem);
|
||||
await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,16 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|
||||
public async Task DownloadSearchResultMessageAsync(string messageId, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(messageId) || assignedFolder == null) return;
|
||||
|
||||
// Online search can return the same message across repeated invocations/races.
|
||||
// Guard before network+MIME download and before database insert.
|
||||
var existing = await _outlookChangeProcessor.AreMailsExistsAsync([messageId]).ConfigureAwait(false);
|
||||
if (existing.Contains(messageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Information("Downloading search result message {messageId} for {Name} - {FolderName}", messageId, Account.Name, assignedFolder.FolderName);
|
||||
|
||||
// Outlook message handling was a little strange.
|
||||
@@ -292,7 +302,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await _outlookChangeProcessor.CreateMailRawAsync(Account, assignedFolder, package).ConfigureAwait(false);
|
||||
// Use safe upsert path to avoid duplicate rows when message already exists.
|
||||
await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1770,12 +1781,16 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|
||||
public override async Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> folders, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<Message> messagesReturnedByApi = [];
|
||||
var messagesById = new Dictionary<string, Message>(StringComparer.Ordinal);
|
||||
|
||||
// Perform search for each folder separately.
|
||||
if (folders?.Count > 0)
|
||||
{
|
||||
var folderIds = folders.Select(a => a.RemoteFolderId);
|
||||
var folderIds = folders
|
||||
.Where(a => a != null && !string.IsNullOrWhiteSpace(a.RemoteFolderId))
|
||||
.Select(a => a.RemoteFolderId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var tasks = folderIds.Select(async folderId =>
|
||||
{
|
||||
@@ -1785,15 +1800,19 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
requestConfig.QueryParameters.Search = $"\"{queryText}\"";
|
||||
requestConfig.QueryParameters.Select = ["Id, ParentFolderId"];
|
||||
requestConfig.QueryParameters.Top = 1000;
|
||||
});
|
||||
}, cancellationToken);
|
||||
|
||||
var result = await mailQuery;
|
||||
|
||||
if (result?.Value != null)
|
||||
{
|
||||
lock (messagesReturnedByApi)
|
||||
lock (messagesById)
|
||||
{
|
||||
messagesReturnedByApi.AddRange(result.Value);
|
||||
foreach (var message in result.Value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message?.Id)) continue;
|
||||
messagesById[message.Id] = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1815,22 +1834,24 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|
||||
if (result?.Value != null)
|
||||
{
|
||||
messagesReturnedByApi.AddRange(result.Value);
|
||||
foreach (var message in result.Value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message?.Id)) continue;
|
||||
messagesById[message.Id] = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messagesReturnedByApi.Count == 0) return [];
|
||||
if (messagesById.Count == 0) return [];
|
||||
|
||||
var localFolders = (await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false))
|
||||
.ToDictionary(x => x.RemoteFolderId);
|
||||
|
||||
var messagesDictionary = messagesReturnedByApi.ToDictionary(a => a.Id);
|
||||
|
||||
// Contains a list of message ids that potentially can be downloaded.
|
||||
List<string> messageIdsWithKnownFolder = [];
|
||||
var messageIdsWithKnownFolder = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Validate that all messages are in a known folder.
|
||||
foreach (var message in messagesReturnedByApi)
|
||||
foreach (var message in messagesById.Values)
|
||||
{
|
||||
if (!localFolders.ContainsKey(message.ParentFolderId))
|
||||
{
|
||||
@@ -1841,13 +1862,18 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
messageIdsWithKnownFolder.Add(message.Id);
|
||||
}
|
||||
|
||||
if (messageIdsWithKnownFolder.Count == 0) return [];
|
||||
|
||||
var locallyExistingMails = await _outlookChangeProcessor.AreMailsExistsAsync(messageIdsWithKnownFolder).ConfigureAwait(false);
|
||||
|
||||
// Find messages that are not downloaded yet.
|
||||
List<Message> messagesToDownload = [];
|
||||
foreach (var id in messagesDictionary.Keys.Except(locallyExistingMails))
|
||||
foreach (var id in messageIdsWithKnownFolder.Except(locallyExistingMails, StringComparer.Ordinal))
|
||||
{
|
||||
messagesToDownload.Add(messagesDictionary[id]);
|
||||
if (messagesById.TryGetValue(id, out var message))
|
||||
{
|
||||
messagesToDownload.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var message in messagesToDownload)
|
||||
|
||||
Reference in New Issue
Block a user