Cleaning up the solution. Separating Shared.WinRT, Services and Synchronization. Removing synchronization from app. Reducing bundle size by 45mb.

This commit is contained in:
Burak Kaan Köse
2024-07-21 05:45:02 +02:00
parent f112f369a7
commit 495885e006
523 changed files with 2254 additions and 2375 deletions

View File

@@ -0,0 +1,313 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using MailKit;
using Serilog;
using Wino.Core.Integration;
using Wino.Core.Misc;
using Wino.Domain;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Synchronization;
using Wino.Messaging.Client.Mails;
using Wino.Messaging.Client.Synchronization;
using Wino.Services.Requests;
namespace Wino.Core.Synchronizers
{
public abstract class BaseSynchronizer<TBaseRequest, TMessageType> : BaseMailIntegrator<TBaseRequest>, IBaseSynchronizer
{
private SemaphoreSlim synchronizationSemaphore = new(1);
private CancellationToken activeSynchronizationCancellationToken;
protected ConcurrentBag<IRequestBase> changeRequestQueue = [];
protected ILogger Logger = Log.ForContext<BaseSynchronizer<TBaseRequest, TMessageType>>();
protected BaseSynchronizer(MailAccount account)
{
Account = account;
}
public MailAccount Account { get; }
private AccountSynchronizerState state;
public AccountSynchronizerState State
{
get { return state; }
private set
{
state = value;
WeakReferenceMessenger.Default.Send(new AccountSynchronizerStateChanged(this, value));
}
}
/// <summary>
/// Queues a single request to be executed in the next synchronization.
/// </summary>
/// <param name="request">Request to execute.</param>
public void QueueRequest(IRequestBase request) => changeRequestQueue.Add(request);
/// <summary>
/// Creates a new Wino Mail Item package out of native message type with full Mime.
/// </summary>
/// <param name="message">Native message type for the synchronizer.</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Package that encapsulates downloaded Mime and additional information for adding new mail.</returns>
public abstract Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default);
/// <summary>
/// Runs existing queued requests in the queue.
/// </summary>
/// <param name="batchedRequests">Batched requests to execute. Integrator methods will only receive batched requests.</param>
/// <param name="cancellationToken">Cancellation token</param>
public abstract Task ExecuteNativeRequestsAsync(IEnumerable<IRequestBundle<TBaseRequest>> batchedRequests, CancellationToken cancellationToken = default);
public abstract Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default);
public async Task<SynchronizationResult> SynchronizeAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
{
try
{
activeSynchronizationCancellationToken = cancellationToken;
var batches = CreateBatchRequests().Distinct();
if (batches.Any())
{
Logger.Information($"{batches?.Count() ?? 0} batched requests");
State = AccountSynchronizerState.ExecutingRequests;
var nativeRequests = CreateNativeRequestBundles(batches);
Console.WriteLine($"Prepared {nativeRequests.Count()} native requests");
await ExecuteNativeRequestsAsync(nativeRequests, activeSynchronizationCancellationToken);
PublishUnreadItemChanges();
// Execute request sync options should be re-calculated after execution.
// This is the part we decide which individual folders must be synchronized
// after the batch request execution.
if (options.Type == SynchronizationType.ExecuteRequests)
options = GetSynchronizationOptionsAfterRequestExecution(batches);
}
State = AccountSynchronizerState.Synchronizing;
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
// Let servers to finish their job. Sometimes the servers doesn't respond immediately.
bool shouldDelayExecution = batches.Any(a => a.DelayExecution);
if (shouldDelayExecution)
{
await Task.Delay(2000);
}
// Start the internal synchronization.
var synchronizationResult = await SynchronizeInternalAsync(options, activeSynchronizationCancellationToken).ConfigureAwait(false);
PublishUnreadItemChanges();
return synchronizationResult;
}
catch (OperationCanceledException)
{
Logger.Warning("Synchronization cancelled.");
return SynchronizationResult.Canceled;
}
catch (Exception)
{
// Disable maybe?
throw;
}
finally
{
// Reset account progress to hide the progress.
options.ProgressListener?.AccountProgressUpdated(Account.Id, 0);
State = AccountSynchronizerState.Idle;
synchronizationSemaphore.Release();
}
}
/// <summary>
/// Updates unread item counts for some folders and account.
/// Sends a message that shell can pick up and update the UI.
/// </summary>
private void PublishUnreadItemChanges()
=> WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(Account.Id));
/// <summary>
/// 1. Group all requests by operation type.
/// 2. Group all individual operation type requests with equality check.
/// Equality comparison in the records are done with RequestComparer
/// to ignore Item property. Each request can have their own logic for comparison.
/// For example, move requests for different mails from the same folder to the same folder
/// must be dispatched in the same batch. This is much faster for the server. Specially IMAP
/// since all folders must be asynchronously opened/closed.
/// </summary>
/// <returns>Batch request collection for all these single requests.</returns>
private List<IRequestBase> CreateBatchRequests()
{
var batchList = new List<IRequestBase>();
var comparer = new RequestComparer();
while (changeRequestQueue.Count > 0)
{
if (changeRequestQueue.TryPeek(out IRequestBase request))
{
// Mail request, must be batched.
if (request is IRequest mailRequest)
{
var equalItems = changeRequestQueue
.Where(a => a is IRequest && comparer.Equals(a, request))
.Cast<IRequest>()
.ToList();
batchList.Add(mailRequest.CreateBatch(equalItems));
// Remove these items from the queue.
foreach (var item in equalItems)
{
changeRequestQueue.TryTake(out _);
}
}
else if (changeRequestQueue.TryTake(out request))
{
// This is a folder operation.
// There is no need to batch them since Users can't do folder ops in bulk.
batchList.Add(request);
}
}
}
return batchList;
}
/// <summary>
/// Converts batched requests into HTTP/Task calls that derived synchronizers can execute.
/// </summary>
/// <param name="batchChangeRequests">Batch requests to be converted.</param>
/// <returns>Collection of native requests for individual synchronizer type.</returns>
private IEnumerable<IRequestBundle<TBaseRequest>> CreateNativeRequestBundles(IEnumerable<IRequestBase> batchChangeRequests)
{
IEnumerable<IEnumerable<IRequestBundle<TBaseRequest>>> GetNativeRequests()
{
foreach (var item in batchChangeRequests)
{
switch (item.Operation)
{
case MailSynchronizerOperation.Send:
yield return SendDraft((BatchSendDraftRequestRequest)item);
break;
case MailSynchronizerOperation.MarkRead:
yield return MarkRead((BatchMarkReadRequest)item);
break;
case MailSynchronizerOperation.Move:
yield return Move((BatchMoveRequest)item);
break;
case MailSynchronizerOperation.Delete:
yield return Delete((BatchDeleteRequest)item);
break;
case MailSynchronizerOperation.ChangeFlag:
yield return ChangeFlag((BatchChangeFlagRequest)item);
break;
case MailSynchronizerOperation.AlwaysMoveTo:
yield return AlwaysMoveTo((BatchAlwaysMoveToRequest)item);
break;
case MailSynchronizerOperation.MoveToFocused:
yield return MoveToFocused((BatchMoveToFocusedRequest)item);
break;
case MailSynchronizerOperation.CreateDraft:
yield return CreateDraft((BatchCreateDraftRequest)item);
break;
case MailSynchronizerOperation.RenameFolder:
yield return RenameFolder((RenameFolderRequest)item);
break;
case MailSynchronizerOperation.EmptyFolder:
yield return EmptyFolder((EmptyFolderRequest)item);
break;
case MailSynchronizerOperation.MarkFolderRead:
yield return MarkFolderAsRead((MarkFolderAsReadRequest)item);
break;
case MailSynchronizerOperation.Archive:
yield return Archive((BatchArchiveRequest)item);
break;
}
}
};
return GetNativeRequests().SelectMany(collections => collections);
}
/// <summary>
/// Attempts to find out the best possible synchronization options after the batch request execution.
/// </summary>
/// <param name="batches">Batch requests to run in synchronization.</param>
/// <returns>New synchronization options with minimal HTTP effort.</returns>
private SynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(IEnumerable<IRequestBase> requests)
{
bool isAllCustomSynchronizationRequests = requests.All(a => a is ICustomFolderSynchronizationRequest);
var options = new SynchronizationOptions()
{
AccountId = Account.Id,
Type = SynchronizationType.FoldersOnly
};
if (isAllCustomSynchronizationRequests)
{
// Gather FolderIds to synchronize.
options.Type = SynchronizationType.Custom;
options.SynchronizationFolderIds = requests.Cast<ICustomFolderSynchronizationRequest>().SelectMany(a => a.SynchronizationFolderIds).ToList();
}
else
{
// At this point it's a mix of everything. Do full sync.
options.Type = SynchronizationType.Full;
}
return options;
}
public virtual bool DelaySendOperationSynchronization() => false;
public virtual IEnumerable<IRequestBundle<TBaseRequest>> Move(BatchMoveRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> ChangeFlag(BatchChangeFlagRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> MarkRead(BatchMarkReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> Delete(BatchDeleteRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> AlwaysMoveTo(BatchAlwaysMoveToRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> MoveToFocused(BatchMoveToFocusedRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> CreateDraft(BatchCreateDraftRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> SendDraft(BatchSendDraftRequestRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> RenameFolder(RenameFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> EmptyFolder(EmptyFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> Archive(BatchArchiveRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
/// <summary>
/// Downloads a single missing message from synchronizer and saves it to given FileId from IMailItem.
/// </summary>
/// <param name="mailItem">Mail item that its mime file does not exist on the disk.</param>
/// <param name="transferProgress">Optional download progress for IMAP synchronizer.</param>
/// <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()));
public bool CancelActiveSynchronization()
{
// TODO: What if account is deleted during synchronization?
return true;
}
}
}

View File

@@ -0,0 +1,970 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;
using Google.Apis.Http;
using Google.Apis.Requests;
using Google.Apis.Services;
using MailKit;
using Microsoft.IdentityModel.Tokens;
using MimeKit;
using MoreLinq;
using Serilog;
using Wino.Core.Extensions;
using Wino.Core.Http;
using Wino.Domain;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Exceptions;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Synchronization;
using Wino.Services.Requests;
using Wino.Services.Requests.Bundles;
namespace Wino.Core.Synchronizers
{
public class GmailSynchronizer : BaseSynchronizer<IClientServiceRequest, Message>, IHttpClientFactory
{
public override uint BatchModificationSize => 1000;
public override uint InitialMessageDownloadCountPerFolder => 1200;
// It's actually 100. But Gmail SDK has internal bug for Out of Memory exception.
// https://github.com/googleapis/google-api-dotnet-client/issues/2603
private const uint MaximumAllowedBatchRequestSize = 10;
private readonly ConfigurableHttpClient _gmailHttpClient;
private readonly GmailService _gmailService;
private readonly IAuthenticator _authenticator;
private readonly IGmailChangeProcessor _gmailChangeProcessor;
private readonly ILogger _logger = Log.ForContext<GmailSynchronizer>();
public GmailSynchronizer(MailAccount account,
IAuthenticator authenticator,
IGmailChangeProcessor gmailChangeProcessor) : base(account)
{
var messageHandler = new GmailClientMessageHandler(() => _authenticator.GetTokenAsync(Account));
var initializer = new BaseClientService.Initializer()
{
HttpClientFactory = this
};
_gmailHttpClient = new ConfigurableHttpClient(messageHandler);
_gmailService = new GmailService(initializer);
_authenticator = authenticator;
_gmailChangeProcessor = gmailChangeProcessor;
}
public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => _gmailHttpClient;
public override async Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
{
_logger.Information("Internal synchronization started for {Name}", Account.Name);
// Gmail must always synchronize folders before because it doesn't have a per-folder sync.
bool shouldSynchronizeFolders = true;
if (shouldSynchronizeFolders)
{
_logger.Information("Synchronizing folders for {Name}", Account.Name);
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
_logger.Information("Synchronizing folders for {Name} is completed", Account.Name);
}
// There is no specific folder synchronization in Gmail.
// Therefore we need to stop the synchronization at this point
// if type is only folder metadata sync.
if (options.Type == SynchronizationType.FoldersOnly) return SynchronizationResult.Empty;
cancellationToken.ThrowIfCancellationRequested();
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
var missingMessageIds = new List<string>();
var deltaChanges = new List<ListHistoryResponse>(); // For tracking delta changes.
var listChanges = new List<ListMessagesResponse>(); // For tracking initial sync changes.
/* Processing flow order is important to preserve the validity of history.
* 1 - Process added mails. Because we need to create the mail first before assigning it to labels.
* 2 - Process label assignments.
* 3 - Process removed mails.
* This affects reporting progres if done individually for each history change.
* Therefore we need to process all changes in one go after the fetch.
*/
if (isInitialSync)
{
// Initial synchronization.
// Google sends message id and thread id in this query.
// We'll collect them and send a Batch request to get details of the messages.
var messageRequest = _gmailService.Users.Messages.List("me");
// Gmail doesn't do per-folder sync. So our per-folder count is the same as total message count.
messageRequest.MaxResults = InitialMessageDownloadCountPerFolder;
messageRequest.IncludeSpamTrash = true;
ListMessagesResponse result = null;
string nextPageToken = string.Empty;
while (true)
{
if (!string.IsNullOrEmpty(nextPageToken))
{
messageRequest.PageToken = nextPageToken;
}
result = await messageRequest.ExecuteAsync(cancellationToken);
nextPageToken = result.NextPageToken;
listChanges.Add(result);
// Nothing to fetch anymore. Break the loop.
if (nextPageToken == null)
break;
}
}
else
{
var startHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier);
var nextPageToken = ulong.Parse(Account.SynchronizationDeltaIdentifier).ToString();
var historyRequest = _gmailService.Users.History.List("me");
historyRequest.StartHistoryId = startHistoryId;
while (!string.IsNullOrEmpty(nextPageToken))
{
// If this is the first delta check, start from the last history id.
// Otherwise start from the next page token. We set them both to the same value for start.
// For each different page we set the page token to the next page token.
bool isFirstDeltaCheck = nextPageToken == startHistoryId.ToString();
if (!isFirstDeltaCheck)
historyRequest.PageToken = nextPageToken;
var historyResponse = await historyRequest.ExecuteAsync(cancellationToken);
nextPageToken = historyResponse.NextPageToken;
if (historyResponse.History == null)
continue;
deltaChanges.Add(historyResponse);
}
}
// Add initial message ids from initial sync.
missingMessageIds.AddRange(listChanges.Where(a => a.Messages != null).SelectMany(a => a.Messages).Select(a => a.Id));
// Add missing message ids from delta changes.
foreach (var historyResponse in deltaChanges)
{
var addedMessageIds = historyResponse.History
.Where(a => a.MessagesAdded != null)
.SelectMany(a => a.MessagesAdded)
.Where(a => a.Message != null)
.Select(a => a.Message.Id);
missingMessageIds.AddRange(addedMessageIds);
}
// Consolidate added/deleted elements.
// For example: History change might report downloading a mail first, then deleting it in another history change.
// In that case, downloading mail will return entity not found error.
// Plus, it's a redundant download the mail.
// Purge missing message ids from potentially deleted mails to prevent this.
var messageDeletedHistoryChanges = deltaChanges
.Where(a => a.History != null)
.SelectMany(a => a.History)
.Where(a => a.MessagesDeleted != null)
.SelectMany(a => a.MessagesDeleted);
var deletedMailIdsInHistory = messageDeletedHistoryChanges.Select(a => a.Message.Id);
if (deletedMailIdsInHistory.Any())
{
var mailIdsToConsolidate = missingMessageIds.Where(a => deletedMailIdsInHistory.Contains(a)).ToList();
int consolidatedMessageCount = missingMessageIds.RemoveAll(a => deletedMailIdsInHistory.Contains(a));
if (consolidatedMessageCount > 0)
{
// TODO: Also delete the history changes that are related to these mails.
// This will prevent unwanted logs and additional queries to look for them in processing.
_logger.Information($"Purged {consolidatedMessageCount} missing mail downloads. ({string.Join(",", mailIdsToConsolidate)})");
}
}
// Start downloading missing messages.
await BatchDownloadMessagesAsync(missingMessageIds, options.ProgressListener, cancellationToken).ConfigureAwait(false);
// Map remote drafts to local drafts.
await MapDraftIdsAsync(cancellationToken).ConfigureAwait(false);
// Start processing delta changes.
foreach (var historyResponse in deltaChanges)
{
await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false);
}
// Take the max history id from delta changes and update the account sync modifier.
var maxHistoryId = deltaChanges.Max(a => a.HistoryId);
if (maxHistoryId != null)
{
// TODO: This is not good. Centralize the identifier fetch and prevent direct access here.
Account.SynchronizationDeltaIdentifier = await _gmailChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, maxHistoryId.ToString()).ConfigureAwait(false);
_logger.Debug("Final sync identifier {SynchronizationDeltaIdentifier}", Account.SynchronizationDeltaIdentifier);
}
// Get all unred new downloaded items and return in the result.
// This is primarily used in notifications.
var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, missingMessageIds).ConfigureAwait(false);
return SynchronizationResult.Completed(unreadNewItems);
}
private async Task SynchronizeFoldersAsync(CancellationToken cancellationToken = default)
{
try
{
var localFolders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
var folderRequest = _gmailService.Users.Labels.List("me");
var labelsResponse = await folderRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (labelsResponse.Labels == null)
{
_logger.Warning("No folders found for {Name}", Account.Name);
return;
}
List<MailItemFolder> insertedFolders = new();
List<MailItemFolder> updatedFolders = new();
List<MailItemFolder> deletedFolders = new();
// 1. Handle deleted labels.
foreach (var localFolder in localFolders)
{
// Category folder is virtual folder for Wino. Skip it.
if (localFolder.SpecialFolderType == SpecialFolderType.Category) continue;
var remoteFolder = labelsResponse.Labels.FirstOrDefault(a => a.Id == localFolder.RemoteFolderId);
if (remoteFolder == null)
{
// Local folder doesn't exists remotely. Delete local copy.
await _gmailChangeProcessor.DeleteFolderAsync(Account.Id, localFolder.RemoteFolderId).ConfigureAwait(false);
deletedFolders.Add(localFolder);
}
}
// Delete the deleted folders from local list.
deletedFolders.ForEach(a => localFolders.Remove(a));
// 2. Handle update/insert based on remote folders.
foreach (var remoteFolder in labelsResponse.Labels)
{
var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.Id);
if (existingLocalFolder == null)
{
// Insert new folder.
var localFolder = remoteFolder.GetLocalFolder(labelsResponse, Account.Id);
insertedFolders.Add(localFolder);
}
else
{
// Update existing folder. Right now we only update the name.
// TODO: Moving folders around different parents. This is not supported right now.
// We will need more comphrensive folder update mechanism to support this.
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
{
existingLocalFolder.FolderName = remoteFolder.Name;
updatedFolders.Add(existingLocalFolder);
}
else
{
// Remove it from the local folder list to skip additional folder updates.
localFolders.Remove(existingLocalFolder);
}
}
}
// 3.Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach (var folder in insertedFolders)
{
await _gmailChangeProcessor.InsertFolderAsync(folder).ConfigureAwait(false);
}
foreach (var folder in updatedFolders)
{
await _gmailChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
}
}
catch (Exception)
{
throw;
}
}
private bool ShouldUpdateFolder(Label remoteFolder, MailItemFolder existingLocalFolder)
=> existingLocalFolder.FolderName.Equals(GoogleIntegratorExtensions.GetFolderName(remoteFolder), StringComparison.OrdinalIgnoreCase) == false;
/// <summary>
/// Returns a single get request to retrieve the raw message with the given id
/// </summary>
/// <param name="messageId">Message to download.</param>
/// <returns>Get request for raw mail.</returns>
private UsersResource.MessagesResource.GetRequest CreateSingleMessageGet(string messageId)
{
var singleRequest = _gmailService.Users.Messages.Get("me", messageId);
singleRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
return singleRequest;
}
/// <summary>
/// Downloads given message ids per batch and processes them.
/// </summary>
/// <param name="messageIds">Gmail message ids to download.</param>
/// <param name="cancellationToken">Cancellation token.</param>
private async Task BatchDownloadMessagesAsync(IEnumerable<string> messageIds, ISynchronizationProgress progressListener = null, CancellationToken cancellationToken = default)
{
var totalDownloadCount = messageIds.Count();
if (totalDownloadCount == 0) return;
var downloadedItemCount = 0;
_logger.Debug("Batch downloading {Count} messages for {Name}", messageIds.Count(), Account.Name);
var allDownloadRequests = messageIds.Select(CreateSingleMessageGet);
// Respect the batch size limit for batch requests.
var batchedDownloadRequests = allDownloadRequests.Batch((int)MaximumAllowedBatchRequestSize);
_logger.Debug("Total items to download: {TotalDownloadCount}. Created {Count} batch download requests for {Name}.", batchedDownloadRequests.Count(), Account.Name, totalDownloadCount);
// Gmail SDK's BatchRequest has Action delegate for callback, not Task.
// Therefore it's not possible to make sure that downloaded item is processed in the database before this
// async callback is finished. Therefore we need to wrap all local database processings into task list and wait all of them to finish
// Batch execution finishes after response parsing is done.
var batchProcessCallbacks = new List<Task>();
foreach (var batchBundle in batchedDownloadRequests)
{
cancellationToken.ThrowIfCancellationRequested();
var batchRequest = new BatchRequest(_gmailService);
// Queue each request into this batch.
batchBundle.ForEach(request =>
{
batchRequest.Queue<Message>(request, (content, error, index, message) =>
{
var downloadingMessageId = messageIds.ElementAt(index);
batchProcessCallbacks.Add(HandleSingleItemDownloadedCallbackAsync(content, error, downloadingMessageId, cancellationToken));
downloadedItemCount++;
var progressValue = downloadedItemCount * 100 / Math.Max(1, totalDownloadCount);
progressListener?.AccountProgressUpdated(Account.Id, progressValue);
});
});
_logger.Information("Executing batch download with {Count} items.", batchRequest.Count);
await batchRequest.ExecuteAsync(cancellationToken);
// This is important due to bug in Gmail SDK.
// We force GC here to prevent Out of Memory exception.
// https://github.com/googleapis/google-api-dotnet-client/issues/2603
GC.Collect();
}
// Wait for all processing to finish.
await Task.WhenAll(batchProcessCallbacks).ConfigureAwait(false);
}
/// <summary>
/// Processes the delta changes for the given history changes.
/// Message downloads are not handled here since it's better to batch them.
/// </summary>
/// <param name="listHistoryResponse">List of history changes.</param>
private async Task ProcessHistoryChangesAsync(ListHistoryResponse listHistoryResponse)
{
_logger.Debug("Processing delta change {HistoryId} for {Name}", Account.Name, listHistoryResponse.HistoryId.GetValueOrDefault());
foreach (var history in listHistoryResponse.History)
{
// Handle label additions.
if (history.LabelsAdded is not null)
{
foreach (var addedLabel in history.LabelsAdded)
{
await HandleLabelAssignmentAsync(addedLabel);
}
}
// Handle label removals.
if (history.LabelsRemoved is not null)
{
foreach (var removedLabel in history.LabelsRemoved)
{
await HandleLabelRemovalAsync(removedLabel);
}
}
// Handle removed messages.
if (history.MessagesDeleted is not null)
{
foreach (var deletedMessage in history.MessagesDeleted)
{
var messageId = deletedMessage.Message.Id;
_logger.Debug("Processing message deletion for {MessageId}", messageId);
await _gmailChangeProcessor.DeleteMailAsync(Account.Id, messageId).ConfigureAwait(false);
}
}
}
}
private async Task HandleLabelAssignmentAsync(HistoryLabelAdded addedLabel)
{
var messageId = addedLabel.Message.Id;
_logger.Debug("Processing label assignment for message {MessageId}", messageId);
foreach (var labelId in addedLabel.LabelIds)
{
// When UNREAD label is added mark the message as un-read.
if (labelId == GoogleIntegratorExtensions.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
// When STARRED label is added mark the message as flagged.
if (labelId == GoogleIntegratorExtensions.STARRED_LABEL_ID)
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, true).ConfigureAwait(false);
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
}
}
private async Task HandleLabelRemovalAsync(HistoryLabelRemoved removedLabel)
{
var messageId = removedLabel.Message.Id;
_logger.Debug("Processing label removed for message {MessageId}", messageId);
foreach (var labelId in removedLabel.LabelIds)
{
// When UNREAD label is removed mark the message as read.
if (labelId == GoogleIntegratorExtensions.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
// When STARRED label is removed mark the message as un-flagged.
if (labelId == GoogleIntegratorExtensions.STARRED_LABEL_ID)
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, false).ConfigureAwait(false);
// For other labels remove the mail assignment.
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
}
}
/// <summary>
/// Prepares Gmail Draft object from Google SDK.
/// If provided, ThreadId ties the draft to a thread. Used when replying messages.
/// If provided, DraftId updates the draft instead of creating a new one.
/// </summary>
/// <param name="mimeMessage">MailKit MimeMessage to include as raw message into Gmail request.</param>
/// <param name="messageThreadId">ThreadId that this draft should be tied to.</param>
/// <param name="messageDraftId">Existing DraftId from Gmail to update existing draft.</param>
/// <returns></returns>
private Draft PrepareGmailDraft(MimeMessage mimeMessage, string messageThreadId = "", string messageDraftId = "")
{
mimeMessage.Prepare(EncodingConstraint.None);
var mimeString = mimeMessage.ToString();
var base64UrlEncodedMime = Base64UrlEncoder.Encode(mimeString);
var nativeMessage = new Message()
{
Raw = base64UrlEncodedMime,
};
if (!string.IsNullOrEmpty(messageThreadId))
nativeMessage.ThreadId = messageThreadId;
var draft = new Draft()
{
Message = nativeMessage,
Id = messageDraftId
};
return draft;
}
#region Mail Integrations
public override IEnumerable<IRequestBundle<IClientServiceRequest>> Move(BatchMoveRequest request)
{
return CreateBatchedHttpBundleFromGroup(request, (items) =>
{
var batchModifyRequest = new BatchModifyMessagesRequest
{
Ids = items.Select(a => a.Item.Id.ToString()).ToList(),
AddLabelIds = new[] { request.ToFolder.RemoteFolderId },
RemoveLabelIds = new[] { request.FromFolder.RemoteFolderId }
};
return _gmailService.Users.Messages.BatchModify(batchModifyRequest, "me");
});
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> ChangeFlag(BatchChangeFlagRequest request)
{
return CreateBatchedHttpBundleFromGroup(request, (items) =>
{
var batchModifyRequest = new BatchModifyMessagesRequest
{
Ids = items.Select(a => a.Item.Id.ToString()).ToList(),
};
if (request.IsFlagged)
batchModifyRequest.AddLabelIds = new List<string>() { GoogleIntegratorExtensions.STARRED_LABEL_ID };
else
batchModifyRequest.RemoveLabelIds = new List<string>() { GoogleIntegratorExtensions.STARRED_LABEL_ID };
return _gmailService.Users.Messages.BatchModify(batchModifyRequest, "me");
});
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> MarkRead(BatchMarkReadRequest request)
{
return CreateBatchedHttpBundleFromGroup(request, (items) =>
{
var batchModifyRequest = new BatchModifyMessagesRequest
{
Ids = items.Select(a => a.Item.Id.ToString()).ToList(),
};
if (request.IsRead)
batchModifyRequest.RemoveLabelIds = new List<string>() { GoogleIntegratorExtensions.UNREAD_LABEL_ID };
else
batchModifyRequest.AddLabelIds = new List<string>() { GoogleIntegratorExtensions.UNREAD_LABEL_ID };
return _gmailService.Users.Messages.BatchModify(batchModifyRequest, "me");
});
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> Delete(BatchDeleteRequest request)
{
return CreateBatchedHttpBundleFromGroup(request, (items) =>
{
var batchModifyRequest = new BatchDeleteMessagesRequest
{
Ids = items.Select(a => a.Item.Id.ToString()).ToList(),
};
return _gmailService.Users.Messages.BatchDelete(batchModifyRequest, "me");
});
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> CreateDraft(BatchCreateDraftRequest request)
{
return CreateHttpBundle(request, (item) =>
{
if (item is not CreateDraftRequest singleRequest)
throw new ArgumentException("BatchCreateDraftRequest collection must be of type CreateDraftRequest.");
Draft draft = null;
// It's new mail. Not a reply
if (singleRequest.DraftPreperationRequest.ReferenceMailCopy == null)
draft = PrepareGmailDraft(singleRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage);
else
draft = PrepareGmailDraft(singleRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage,
singleRequest.DraftPreperationRequest.ReferenceMailCopy.ThreadId,
singleRequest.DraftPreperationRequest.ReferenceMailCopy.DraftId);
return _gmailService.Users.Drafts.Create(draft, "me");
});
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> Archive(BatchArchiveRequest request)
{
return CreateBatchedHttpBundleFromGroup(request, (items) =>
{
var batchModifyRequest = new BatchModifyMessagesRequest
{
Ids = items.Select(a => a.Item.Id.ToString()).ToList()
};
if (request.IsArchiving)
{
batchModifyRequest.RemoveLabelIds = new[] { GoogleIntegratorExtensions.INBOX_LABEL_ID };
}
else
{
batchModifyRequest.AddLabelIds = new[] { GoogleIntegratorExtensions.INBOX_LABEL_ID };
}
return _gmailService.Users.Messages.BatchModify(batchModifyRequest, "me");
});
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> SendDraft(BatchSendDraftRequestRequest request)
{
return CreateHttpBundle(request, (item) =>
{
if (item is not SendDraftRequest singleDraftRequest)
throw new ArgumentException("BatchSendDraftRequestRequest collection must be of type SendDraftRequest.");
var message = new Message();
if (!string.IsNullOrEmpty(singleDraftRequest.Item.ThreadId))
{
message.ThreadId = singleDraftRequest.Item.ThreadId;
}
singleDraftRequest.Request.Mime.Prepare(EncodingConstraint.None);
var mimeString = singleDraftRequest.Request.Mime.ToString();
var base64UrlEncodedMime = Base64UrlEncoder.Encode(mimeString);
message.Raw = base64UrlEncodedMime;
var draft = new Draft()
{
Id = singleDraftRequest.Request.MailItem.DraftId,
Message = message
};
return _gmailService.Users.Drafts.Send(draft, "me");
});
}
public override async Task DownloadMissingMimeMessageAsync(IMailItem mailItem,
ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
{
var request = _gmailService.Users.Messages.Get("me", mailItem.Id);
request.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
var gmailMessage = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
var mimeMessage = gmailMessage.GetGmailMimeMessage();
if (mimeMessage == null)
{
_logger.Warning("Tried to download Gmail Raw Mime with {Id} id and server responded without a data.", mailItem.Id);
return;
}
await _gmailChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> RenameFolder(RenameFolderRequest request)
{
return CreateHttpBundleWithResponse<Label>(request, (item) =>
{
if (item is not RenameFolderRequest renameFolderRequest)
throw new ArgumentException($"Renaming folder must be handled with '{nameof(RenameFolderRequest)}'");
var label = new Label()
{
Name = renameFolderRequest.NewFolderName
};
return _gmailService.Users.Labels.Update(label, "me", request.Folder.RemoteFolderId);
});
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> EmptyFolder(EmptyFolderRequest request)
{
// Create batch delete request.
var deleteRequests = request.MailsToDelete.Select(a => new DeleteRequest(a));
return Delete(new BatchDeleteRequest(deleteRequests));
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request)
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true)), true));
#endregion
#region Request Execution
public override async Task ExecuteNativeRequestsAsync(IEnumerable<IRequestBundle<IClientServiceRequest>> batchedRequests,
CancellationToken cancellationToken = default)
{
var batchedBundles = batchedRequests.Batch((int)MaximumAllowedBatchRequestSize);
var bundleCount = batchedBundles.Count();
for (int i = 0; i < bundleCount; i++)
{
var bundle = batchedBundles.ElementAt(i);
var nativeBatchRequest = new BatchRequest(_gmailService);
var bundleRequestCount = bundle.Count();
for (int k = 0; k < bundleRequestCount; k++)
{
var requestBundle = bundle.ElementAt(k);
var nativeRequest = requestBundle.NativeRequest;
var request = requestBundle.Request;
request.ApplyUIChanges();
// TODO: Queue is synchronous. Create a task bucket to await all processing.
nativeBatchRequest.Queue<object>(nativeRequest, async (content, error, index, message)
=> await ProcessSingleNativeRequestResponseAsync(requestBundle, error, message, cancellationToken).ConfigureAwait(false));
}
await nativeBatchRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
}
}
private void ProcessGmailRequestError(RequestError error)
{
if (error == null) return;
// OutOfMemoryException is a known bug in Gmail SDK.
if (error.Code == 0)
{
throw new OutOfMemoryException(error.Message);
}
// Entity not found.
if (error.Code == 404)
{
throw new SynchronizerEntityNotFoundException(error.Message);
}
if (!string.IsNullOrEmpty(error.Message))
{
error.Errors?.ForEach(error => _logger.Error("Unknown Gmail SDK error for {Name}\n{Error}", Account.Name, error));
// TODO: Debug
// throw new SynchronizerException(error.Message);
}
}
/// <summary>
/// Handles after each single message download.
/// This involves adding the Gmail message into Wino database.
/// </summary>
/// <param name="message"></param>
/// <param name="error"></param>
/// <param name="httpResponseMessage"></param>
/// <param name="cancellationToken"></param>
private async Task HandleSingleItemDownloadedCallbackAsync(Message message,
RequestError error,
string downloadingMessageId,
CancellationToken cancellationToken = default)
{
try
{
ProcessGmailRequestError(error);
}
catch (OutOfMemoryException)
{
_logger.Warning("Gmail SDK got OutOfMemoryException due to bug in the SDK");
}
catch (SynchronizerEntityNotFoundException)
{
_logger.Warning("Resource not found for {DownloadingMessageId}", downloadingMessageId);
}
catch (SynchronizerException synchronizerException)
{
_logger.Error("Gmail SDK returned error for {DownloadingMessageId}\n{SynchronizerException}", downloadingMessageId, synchronizerException);
}
if (message == null)
{
_logger.Warning("Skipped GMail message download for {DownloadingMessageId}", downloadingMessageId);
return;
}
// Gmail has LabelId property for each message.
// Therefore we can pass null as the assigned folder safely.
var mailPackage = await CreateNewMailPackagesAsync(message, null, cancellationToken);
// If CreateNewMailPackagesAsync returns null it means local draft mapping is done.
// We don't need to insert anything else.
if (mailPackage == null)
return;
foreach (var package in mailPackage)
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
// Try updating the history change identifier if any.
if (message.HistoryId == null) return;
// Delta changes also has history id but the maximum id is preserved in the account service.
// TODO: This is not good. Centralize the identifier fetch and prevent direct access here.
Account.SynchronizationDeltaIdentifier = await _gmailChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, message.HistoryId.ToString());
}
private async Task ProcessSingleNativeRequestResponseAsync(IRequestBundle<IClientServiceRequest> bundle,
RequestError error,
HttpResponseMessage httpResponseMessage,
CancellationToken cancellationToken = default)
{
ProcessGmailRequestError(error);
if (bundle is HttpRequestBundle<IClientServiceRequest, Message> messageBundle)
{
var gmailMessage = await messageBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken).ConfigureAwait(false);
if (gmailMessage == null) return;
await HandleSingleItemDownloadedCallbackAsync(gmailMessage, error, "unknown", cancellationToken);
}
else if (bundle is HttpRequestBundle<IClientServiceRequest, Label> folderBundle)
{
var gmailLabel = await folderBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken).ConfigureAwait(false);
if (gmailLabel == null) return;
// TODO: Handle new Gmail Label added or updated.
}
else if (bundle is HttpRequestBundle<IClientServiceRequest, Draft> draftBundle && draftBundle.Request is CreateDraftRequest createDraftRequest)
{
// New draft mail is created.
var messageDraft = await draftBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken).ConfigureAwait(false);
if (messageDraft == null) return;
var localDraftCopy = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftCopy;
// Here we have DraftId, MessageId and ThreadId.
// Update the local copy properties and re-synchronize to get the original message and update history.
// We don't fetch the single message here because it may skip some of the history changes when the
// fetch updates the historyId. Therefore we need to re-synchronize to get the latest history changes
// which will have the original message downloaded eventually.
await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopy.UniqueId, messageDraft.Message.Id, messageDraft.Id, messageDraft.Message.ThreadId);
var options = new SynchronizationOptions()
{
AccountId = Account.Id,
Type = SynchronizationType.Full
};
await SynchronizeInternalAsync(options, cancellationToken);
}
}
/// <summary>
/// Maps existing Gmail Draft resources to local mail copies.
/// This uses indexed search, therefore it's quite fast.
/// It's safe to execute this after each Draft creation + batch message download.
/// </summary>
private async Task MapDraftIdsAsync(CancellationToken cancellationToken = default)
{
// TODO: This call is not necessary if we don't have any local drafts.
// Remote drafts will be downloaded in missing message batches anyways.
// Fix it by checking whether we need to do this or not.
var drafts = await _gmailService.Users.Drafts.List("me").ExecuteAsync(cancellationToken);
if (drafts.Drafts == null)
{
_logger.Information("There are no drafts to map for {Name}", Account.Name);
return;
}
foreach (var draft in drafts.Drafts)
{
await _gmailChangeProcessor.MapLocalDraftAsync(draft.Message.Id, draft.Id, draft.Message.ThreadId);
}
}
/// <summary>
/// Creates new mail packages for the given message.
/// AssignedFolder is null since the LabelId is parsed out of the Message.
/// </summary>
/// <param name="message">Gmail message to create package for.</param>
/// <param name="assignedFolder">Null, not used.</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>New mail package that change processor can use to insert new mail into database.</returns>
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(Message message,
MailItemFolder assignedFolder,
CancellationToken cancellationToken = default)
{
var packageList = new List<NewMailItemPackage>();
MimeMessage mimeMessage = message.GetGmailMimeMessage();
var mailCopy = message.AsMailCopy(mimeMessage);
// Check whether this message is mapped to any local draft.
// Previously we were using Draft resource response as mapping drafts.
// This seem to be a worse approach. Now both Outlook and Gmail use X-Wino-Draft-Id header to map drafts.
// This is a better approach since we don't need to fetch the draft resource to get the draft id.
if (mailCopy.IsDraft
&& mimeMessage.Headers.Contains(Constants.WinoLocalDraftHeader)
&& Guid.TryParse(mimeMessage.Headers[Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId))
{
// This message belongs to existing local draft copy.
// We don't need to create a new mail copy for this message, just update the existing one.
bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId);
if (isMappingSuccesfull) return null;
// Local copy doesn't exists. Continue execution to insert mail copy.
}
if (message.LabelIds is not null)
{
foreach (var labelId in message.LabelIds)
{
packageList.Add(new NewMailItemPackage(mailCopy, mimeMessage, labelId));
}
}
return packageList;
}
#endregion
}
}

View File

@@ -0,0 +1,999 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using MimeKit;
using MoreLinq;
using Serilog;
using Wino.Core.Extensions;
using Wino.Core.Integration;
using Wino.Core.Mime;
using Wino.Domain;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Exceptions;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Synchronization;
using Wino.Services.Requests;
using Wino.Services.Requests.Bundles;
namespace Wino.Core.Synchronizers
{
public class ImapSynchronizer : BaseSynchronizer<ImapRequest, ImapMessageCreationPackage>
{
private CancellationTokenSource idleDoneToken;
private CancellationTokenSource cancelInboxListeningToken = new CancellationTokenSource();
private IMailFolder inboxFolder;
private readonly ILogger _logger = Log.ForContext<ImapSynchronizer>();
private readonly ImapClientPool _clientPool;
private readonly IImapChangeProcessor _imapChangeProcessor;
// Minimum summary items to Fetch for mail synchronization from IMAP.
private readonly MessageSummaryItems mailSynchronizationFlags =
MessageSummaryItems.Flags |
MessageSummaryItems.UniqueId |
MessageSummaryItems.ThreadId |
MessageSummaryItems.EmailId |
MessageSummaryItems.Headers |
MessageSummaryItems.PreviewText |
MessageSummaryItems.GMailThreadId |
MessageSummaryItems.References |
MessageSummaryItems.ModSeq;
/// <summary>
/// Timer that keeps the <see cref="InboxClient"/> alive for the lifetime of the pool.
/// Sends NOOP command to the server periodically.
/// </summary>
private Timer _noOpTimer;
/// <summary>
/// ImapClient that keeps the Inbox folder opened all the time for listening notifications.
/// </summary>
private ImapClient _inboxIdleClient;
public override uint BatchModificationSize => 1000;
public override uint InitialMessageDownloadCountPerFolder => 250;
public ImapSynchronizer(MailAccount account, IImapChangeProcessor imapChangeProcessor) : base(account)
{
_clientPool = new ImapClientPool(Account.ServerInformation);
_imapChangeProcessor = imapChangeProcessor;
idleDoneToken = new CancellationTokenSource();
}
// TODO
// private async void NoOpTimerTriggered(object state) => await AwaitInboxIdleAsync();
private async Task AwaitInboxIdleAsync()
{
if (_inboxIdleClient == null)
{
_logger.Warning("InboxClient is null. Cannot send NOOP command.");
return;
}
await _clientPool.EnsureConnectedAsync(_inboxIdleClient);
await _clientPool.EnsureAuthenticatedAsync(_inboxIdleClient);
try
{
if (inboxFolder == null)
{
inboxFolder = _inboxIdleClient.Inbox;
await inboxFolder.OpenAsync(FolderAccess.ReadOnly, cancelInboxListeningToken.Token);
}
idleDoneToken = new CancellationTokenSource();
await _inboxIdleClient.IdleAsync(idleDoneToken.Token, cancelInboxListeningToken.Token);
}
finally
{
idleDoneToken.Dispose();
idleDoneToken = null;
}
}
private async Task StopInboxListeningAsync()
{
if (inboxFolder != null)
{
inboxFolder.CountChanged -= InboxFolderCountChanged;
inboxFolder.MessageExpunged -= InboxFolderMessageExpunged;
inboxFolder.MessageFlagsChanged -= InboxFolderMessageFlagsChanged;
}
if (_noOpTimer != null)
{
_noOpTimer.Dispose();
_noOpTimer = null;
}
if (idleDoneToken != null)
{
idleDoneToken.Cancel();
idleDoneToken.Dispose();
idleDoneToken = null;
}
if (_inboxIdleClient != null)
{
await _inboxIdleClient.DisconnectAsync(true);
_inboxIdleClient.Dispose();
_inboxIdleClient = null;
}
}
/// <summary>
/// Tries to connect & authenticate with the given credentials.
/// Prepares synchronizer for active listening of Inbox folder.
/// </summary>
public async Task StartInboxListeningAsync()
{
_inboxIdleClient = await _clientPool.GetClientAsync();
// Run it every 8 minutes after 1 minute delay.
// _noOpTimer = new Timer(NoOpTimerTriggered, null, 60000, 8 * 60 * 1000);
await _clientPool.EnsureConnectedAsync(_inboxIdleClient);
await _clientPool.EnsureAuthenticatedAsync(_inboxIdleClient);
if (!_inboxIdleClient.Capabilities.HasFlag(ImapCapabilities.Idle))
{
_logger.Information("Imap server does not support IDLE command. Listening live changes is not supported for {Name}", Account.Name);
return;
}
inboxFolder = _inboxIdleClient.Inbox;
if (inboxFolder == null)
{
_logger.Information("Inbox folder is null. Cannot listen for changes.");
return;
}
inboxFolder.CountChanged += InboxFolderCountChanged;
inboxFolder.MessageExpunged += InboxFolderMessageExpunged;
inboxFolder.MessageFlagsChanged += InboxFolderMessageFlagsChanged;
while (!cancelInboxListeningToken.IsCancellationRequested)
{
await AwaitInboxIdleAsync();
}
await StopInboxListeningAsync();
}
private void InboxFolderMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs e)
{
Console.WriteLine("Flags have changed for message #{0} ({1}).", e.Index, e.Flags);
}
private void InboxFolderMessageExpunged(object sender, MessageEventArgs e)
{
_logger.Information("Inbox folder message expunged");
}
private void InboxFolderCountChanged(object sender, EventArgs e)
{
_logger.Information("Inbox folder count changed.");
}
/// <summary>
/// Parses List of string of mail copy ids and return valid uIds.
/// Follow the rules for creating arbitrary unique id for mail copies.
/// </summary>
private UniqueIdSet GetUniqueIds(IEnumerable<string> mailCopyIds)
=> new(mailCopyIds.Select(a => new UniqueId(Wino.Domain.Extensions.MailkitClientExtensions.ResolveUid(a))));
#region Mail Integrations
// Items are grouped before being passed to this method.
// Meaning that all items will come from and to the same folder.
// It's fine to assume that here.
public override IEnumerable<IRequestBundle<ImapRequest>> Move(BatchMoveRequest request)
{
return CreateTaskBundle(async (ImapClient client) =>
{
var uniqueIds = GetUniqueIds(request.Items.Select(a => a.Item.Id));
var sourceFolder = await client.GetFolderAsync(request.FromFolder.RemoteFolderId);
var destinationFolder = await client.GetFolderAsync(request.ToFolder.RemoteFolderId);
// Only opening source folder is enough.
await sourceFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
await sourceFolder.MoveToAsync(uniqueIds, destinationFolder).ConfigureAwait(false);
await sourceFolder.CloseAsync().ConfigureAwait(false);
}, request);
}
public override IEnumerable<IRequestBundle<ImapRequest>> ChangeFlag(BatchChangeFlagRequest request)
{
return CreateTaskBundle(async (ImapClient client) =>
{
var folder = request.Items.First().Item.AssignedFolder;
var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId);
var uniqueIds = GetUniqueIds(request.Items.Select(a => a.Item.Id));
await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
await remoteFolder.StoreAsync(uniqueIds, new StoreFlagsRequest(request.IsFlagged ? StoreAction.Add : StoreAction.Remove, MessageFlags.Flagged) { Silent = true }).ConfigureAwait(false);
await remoteFolder.CloseAsync().ConfigureAwait(false);
}, request);
}
public override IEnumerable<IRequestBundle<ImapRequest>> Delete(BatchDeleteRequest request)
{
return CreateTaskBundle(async (ImapClient client) =>
{
var folder = request.Items.First().Item.AssignedFolder;
var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId).ConfigureAwait(false);
var uniqueIds = GetUniqueIds(request.Items.Select(a => a.Item.Id));
await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
await remoteFolder.StoreAsync(uniqueIds, new StoreFlagsRequest(StoreAction.Add, MessageFlags.Deleted) { Silent = true }).ConfigureAwait(false);
await remoteFolder.ExpungeAsync().ConfigureAwait(false);
await remoteFolder.CloseAsync().ConfigureAwait(false);
}, request);
}
public override IEnumerable<IRequestBundle<ImapRequest>> MarkRead(BatchMarkReadRequest request)
{
return CreateTaskBundle(async (ImapClient client) =>
{
var folder = request.Items.First().Item.AssignedFolder;
var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId);
var uniqueIds = GetUniqueIds(request.Items.Select(a => a.Item.Id));
await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
await remoteFolder.StoreAsync(uniqueIds, new StoreFlagsRequest(request.IsRead ? StoreAction.Add : StoreAction.Remove, MessageFlags.Seen) { Silent = true }).ConfigureAwait(false);
await remoteFolder.CloseAsync().ConfigureAwait(false);
}, request);
}
public override IEnumerable<IRequestBundle<ImapRequest>> CreateDraft(BatchCreateDraftRequest request)
{
return CreateTaskBundle(async (ImapClient client) =>
{
var remoteDraftFolder = await client.GetFolderAsync(request.DraftPreperationRequest.CreatedLocalDraftCopy.AssignedFolder.RemoteFolderId).ConfigureAwait(false);
await remoteDraftFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
await remoteDraftFolder.AppendAsync(request.DraftPreperationRequest.CreatedLocalDraftMimeMessage, MessageFlags.Draft).ConfigureAwait(false);
await remoteDraftFolder.CloseAsync().ConfigureAwait(false);
}, request);
}
public override IEnumerable<IRequestBundle<ImapRequest>> Archive(BatchArchiveRequest request)
=> Move(new BatchMoveRequest(request.Items, request.FromFolder, request.ToFolder));
public override IEnumerable<IRequestBundle<ImapRequest>> EmptyFolder(EmptyFolderRequest request)
=> Delete(new BatchDeleteRequest(request.MailsToDelete.Select(a => new DeleteRequest(a))));
public override IEnumerable<IRequestBundle<ImapRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request)
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true)), true));
public override IEnumerable<IRequestBundle<ImapRequest>> SendDraft(BatchSendDraftRequestRequest request)
{
return CreateTaskBundle(async (ImapClient client) =>
{
// Batch sending is not supported. It will always be a single request therefore no need for a loop here.
var singleRequest = request.Request;
singleRequest.Mime.Prepare(EncodingConstraint.None);
using var smtpClient = new MailKit.Net.Smtp.SmtpClient();
if (smtpClient.IsConnected && client.IsAuthenticated) return;
if (!smtpClient.IsConnected)
await smtpClient.ConnectAsync(Account.ServerInformation.OutgoingServer, int.Parse(Account.ServerInformation.OutgoingServerPort), MailKit.Security.SecureSocketOptions.Auto);
if (!smtpClient.IsAuthenticated)
await smtpClient.AuthenticateAsync(Account.ServerInformation.OutgoingServerUsername, Account.ServerInformation.OutgoingServerPassword);
// TODO: Transfer progress implementation as popup in the UI.
await smtpClient.SendAsync(singleRequest.Mime, default);
await smtpClient.DisconnectAsync(true);
// SMTP sent the message, but we need to remove it from the Draft folder.
var draftFolder = singleRequest.MailItem.AssignedFolder;
var folder = await client.GetFolderAsync(draftFolder.RemoteFolderId);
await folder.OpenAsync(FolderAccess.ReadWrite);
var notUpdatedIds = await folder.StoreAsync(new UniqueId(Wino.Domain.Extensions.MailkitClientExtensions.ResolveUid(singleRequest.MailItem.Id)), new StoreFlagsRequest(StoreAction.Add, MessageFlags.Deleted) { Silent = true });
await folder.ExpungeAsync();
await folder.CloseAsync();
// Check whether we need to create a copy of the message to Sent folder.
// This comes from the account preferences.
if (singleRequest.AccountPreferences.ShouldAppendMessagesToSentFolder && singleRequest.SentFolder != null)
{
var sentFolder = await client.GetFolderAsync(singleRequest.SentFolder.RemoteFolderId);
await sentFolder.OpenAsync(FolderAccess.ReadWrite);
// Delete local Wino draft header. Otherwise mapping will be applied on re-sync.
singleRequest.Mime.Headers.Remove(Constants.WinoLocalDraftHeader);
await sentFolder.AppendAsync(singleRequest.Mime, MessageFlags.Seen);
await sentFolder.CloseAsync();
}
}, request);
}
public override async Task DownloadMissingMimeMessageAsync(IMailItem mailItem,
ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
{
var folder = mailItem.AssignedFolder;
var remoteFolderId = folder.RemoteFolderId;
var client = await _clientPool.GetClientAsync().ConfigureAwait(false);
var remoteFolder = await client.GetFolderAsync(remoteFolderId, cancellationToken).ConfigureAwait(false);
var uniqueId = new UniqueId(Wino.Domain.Extensions.MailkitClientExtensions.ResolveUid(mailItem.Id));
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
var message = await remoteFolder.GetMessageAsync(uniqueId, cancellationToken, transferProgress).ConfigureAwait(false);
await _imapChangeProcessor.SaveMimeFileAsync(mailItem.FileId, message, Account.Id).ConfigureAwait(false);
await remoteFolder.CloseAsync(false, cancellationToken).ConfigureAwait(false);
_clientPool.Release(client);
}
public override IEnumerable<IRequestBundle<ImapRequest>> RenameFolder(RenameFolderRequest request)
{
return CreateTaskBundle(async (ImapClient client) =>
{
var folder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false);
await folder.RenameAsync(folder.ParentFolder, request.NewFolderName).ConfigureAwait(false);
}, request);
}
#endregion
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
var imapFolder = message.MailFolder;
var summary = message.MessageSummary;
var mimeMessage = await imapFolder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false);
var mailCopy = summary.GetMailDetails(assignedFolder, mimeMessage);
// Draft folder message updates must be updated as IsDraft.
// I couldn't find it in MimeMessage...
mailCopy.IsDraft = assignedFolder.SpecialFolderType == SpecialFolderType.Draft;
// Check draft mapping.
// This is the same implementation as in the OutlookSynchronizer.
if (mimeMessage.Headers.Contains(Constants.WinoLocalDraftHeader)
&& Guid.TryParse(mimeMessage.Headers[Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId))
{
// This message belongs to existing local draft copy.
// We don't need to create a new mail copy for this message, just update the existing one.
bool isMappingSuccessful = await _imapChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId);
if (isMappingSuccessful) return null;
// Local copy doesn't exists. Continue execution to insert mail copy.
}
var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId);
return
[
package
];
}
public override async Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
{
// options.Type = SynchronizationType.FoldersOnly;
var downloadedMessageIds = new List<string>();
_logger.Information("Internal synchronization started for {Name}", Account.Name);
_logger.Information("Options: {Options}", options);
options.ProgressListener?.AccountProgressUpdated(Account.Id, 1);
// Only do folder sync for these types.
// Opening folder and checking their UidValidity is slow.
// Therefore this should be avoided as many times as possible.
// This may create some inconsistencies, but nothing we can do...
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
if (options.Type != SynchronizationType.FoldersOnly)
{
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
for (int i = 0; i < synchronizationFolders.Count; i++)
{
var folder = synchronizationFolders[i];
var progress = (int)Math.Round((double)(i + 1) / synchronizationFolders.Count * 100);
options.ProgressListener?.AccountProgressUpdated(Account.Id, progress);
var folderDownloadedMessageIds = await SynchronizeFolderInternalAsync(folder, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
}
}
options.ProgressListener?.AccountProgressUpdated(Account.Id, 100);
// Get all unread new downloaded items and return in the result.
// This is primarily used in notifications.
var unreadNewItems = await _imapChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
return SynchronizationResult.Completed(unreadNewItems);
}
public override async Task ExecuteNativeRequestsAsync(IEnumerable<IRequestBundle<ImapRequest>> batchedRequests, CancellationToken cancellationToken = default)
{
// First apply the UI changes for each bundle.
// This is important to reflect changes to the UI before the network call is done.
foreach (var item in batchedRequests)
{
item.Request.ApplyUIChanges();
}
// All task bundles will execute on the same client.
// Tasks themselves don't pull the client from the pool
// because exception handling is easier this way.
// Also we might parallelize these bundles later on for additional performance.
foreach (var item in batchedRequests)
{
// At this point this client is ready to execute async commands.
// Each task bundle will await and execution will continue in case of error.
ImapClient executorClient = null;
bool isCrashed = false;
try
{
executorClient = await _clientPool.GetClientAsync();
}
catch (ImapClientPoolException)
{
// Client pool failed to get a client.
// Requests may not be executed at this point.
item.Request.RevertUIChanges();
isCrashed = true;
throw;
}
finally
{
// Make sure that the client is released from the pool for next usages if error occurs.
if (isCrashed && executorClient != null)
{
_clientPool.Release(executorClient);
}
}
// TODO: Retry pattern.
// TODO: Error handling.
try
{
await item.NativeRequest.IntegratorTask(executorClient).ConfigureAwait(false);
}
catch (Exception)
{
item.Request.RevertUIChanges();
throw;
}
finally
{
_clientPool.Release(executorClient);
}
}
}
/// <summary>
/// Assigns special folder type for the given local folder.
/// If server doesn't support special folders, we can't determine the type. MailKit will throw for GetFolder.
/// Default type is Other.
/// </summary>
/// <param name="executorClient">ImapClient from the pool</param>
/// <param name="remoteFolder">Assigning remote folder.</param>
/// <param name="localFolder">Assigning local folder.</param>
private void AssignSpecialFolderType(ImapClient executorClient, IMailFolder remoteFolder, MailItemFolder localFolder)
{
// Inbox is awlawys available. Don't miss it for assignment even though XList or SpecialUser is not supported.
if (executorClient.Inbox == remoteFolder)
{
localFolder.SpecialFolderType = SpecialFolderType.Inbox;
return;
}
bool isSpecialFoldersSupported = executorClient.Capabilities.HasFlag(ImapCapabilities.SpecialUse) || executorClient.Capabilities.HasFlag(ImapCapabilities.XList);
if (!isSpecialFoldersSupported)
{
localFolder.SpecialFolderType = SpecialFolderType.Other;
return;
}
if (remoteFolder == executorClient.Inbox)
localFolder.SpecialFolderType = SpecialFolderType.Inbox;
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Drafts))
localFolder.SpecialFolderType = SpecialFolderType.Draft;
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Junk))
localFolder.SpecialFolderType = SpecialFolderType.Junk;
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Trash))
localFolder.SpecialFolderType = SpecialFolderType.Deleted;
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Sent))
localFolder.SpecialFolderType = SpecialFolderType.Sent;
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Archive))
localFolder.SpecialFolderType = SpecialFolderType.Archive;
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Important))
localFolder.SpecialFolderType = SpecialFolderType.Important;
else if (remoteFolder == executorClient.GetFolder(SpecialFolder.Flagged))
localFolder.SpecialFolderType = SpecialFolderType.Starred;
}
private async Task SynchronizeFoldersAsync(CancellationToken cancellationToken = default)
{
// https://www.rfc-editor.org/rfc/rfc4549#section-1.1
var localFolders = await _imapChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
ImapClient executorClient = null;
try
{
List<MailItemFolder> insertedFolders = new();
List<MailItemFolder> updatedFolders = new();
List<MailItemFolder> deletedFolders = new();
executorClient = await _clientPool.GetClientAsync().ConfigureAwait(false);
var remoteFolders = (await executorClient.GetFoldersAsync(executorClient.PersonalNamespaces[0], cancellationToken: cancellationToken)).ToList();
// 1. First check deleted folders.
// 1.a If local folder doesn't exists remotely, delete it.
// 1.b If local folder exists remotely, check if it is still a valid folder. If UidValidity is changed, delete it.
foreach (var localFolder in localFolders)
{
IMailFolder remoteFolder = null;
try
{
remoteFolder = remoteFolders.FirstOrDefault(a => a.FullName == localFolder.RemoteFolderId);
bool shouldDeleteLocalFolder = false;
// Check UidValidity of the remote folder if exists.
if (remoteFolder != null)
{
// UidValidity won't be available until it's opened.
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
shouldDeleteLocalFolder = remoteFolder.UidValidity != localFolder.UidValidity;
}
else
{
// Remote folder doesn't exist. Delete it.
shouldDeleteLocalFolder = true;
}
if (shouldDeleteLocalFolder)
{
await _imapChangeProcessor.DeleteFolderAsync(Account.Id, localFolder.RemoteFolderId).ConfigureAwait(false);
deletedFolders.Add(localFolder);
}
}
catch (Exception)
{
throw;
}
finally
{
if (remoteFolder != null)
{
await remoteFolder.CloseAsync().ConfigureAwait(false);
}
}
}
deletedFolders.ForEach(a => localFolders.Remove(a));
// 2. Get all remote folders and insert/update each of them.
var nameSpace = executorClient.PersonalNamespaces[0];
IMailFolder inbox = executorClient.Inbox;
// Sometimes Inbox is the root namespace. We need to check for that.
if (inbox != null && !remoteFolders.Contains(inbox))
remoteFolders.Add(inbox);
foreach (var remoteFolder in remoteFolders)
{
// Namespaces are not needed as folders.
// Non-existed folders don't need to be synchronized.
if ((remoteFolder.IsNamespace && !remoteFolder.Attributes.HasFlag(FolderAttributes.Inbox)) || !remoteFolder.Exists)
continue;
var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.FullName);
if (existingLocalFolder == null)
{
// Folder doesn't exist locally. Insert it.
var localFolder = remoteFolder.GetLocalFolder();
// Check whether this is a special folder.
AssignSpecialFolderType(executorClient, remoteFolder, localFolder);
bool isSystemFolder = localFolder.SpecialFolderType != SpecialFolderType.Other;
localFolder.IsSynchronizationEnabled = isSystemFolder;
localFolder.IsSticky = isSystemFolder;
// By default, all special folders update unread count in the UI except Trash.
localFolder.ShowUnreadCount = localFolder.SpecialFolderType != SpecialFolderType.Deleted || localFolder.SpecialFolderType != SpecialFolderType.Other;
localFolder.MailAccountId = Account.Id;
// Sometimes sub folders are parented under Inbox.
// Even though this makes sense in server level, in the client it sucks.
// That will make sub folders to be parented under Inbox in the client.
// Instead, we will mark them as non-parented folders.
// This is better. Model allows personalized folder structure anyways
// even though we don't have the page/control to adjust it.
if (remoteFolder.ParentFolder == executorClient.Inbox)
localFolder.ParentRemoteFolderId = string.Empty;
// Set UidValidity for cache expiration.
// Folder must be opened for this.
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken);
localFolder.UidValidity = remoteFolder.UidValidity;
await remoteFolder.CloseAsync(cancellationToken: cancellationToken);
insertedFolders.Add(localFolder);
}
else
{
// Update existing folder. Right now we only update the name.
// TODO: Moving folders around different parents. This is not supported right now.
// We will need more comphrensive folder update mechanism to support this.
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
{
existingLocalFolder.FolderName = remoteFolder.Name;
updatedFolders.Add(existingLocalFolder);
}
else
{
// Remove it from the local folder list to skip additional folder updates.
localFolders.Remove(existingLocalFolder);
}
}
}
// Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach (var folder in insertedFolders)
{
await _imapChangeProcessor.InsertFolderAsync(folder).ConfigureAwait(false);
}
foreach (var folder in updatedFolders)
{
await _imapChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Synchronizing IMAP folders failed.");
throw;
}
finally
{
if (executorClient != null)
{
_clientPool.Release(executorClient);
}
}
}
private async Task<IEnumerable<string>> SynchronizeFolderInternalAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
{
if (!folder.IsSynchronizationEnabled) return default;
var downloadedMessageIds = new List<string>();
// STEP1: Ask for flag changes for older mails.
// STEP2: Get new mail changes.
// https://www.rfc-editor.org/rfc/rfc4549 - Section 4.3
var _synchronizationClient = await _clientPool.GetClientAsync();
IMailFolder imapFolder = null;
var knownMailIds = new UniqueIdSet();
var locallyKnownMailUids = await _imapChangeProcessor.GetKnownUidsForFolderAsync(folder.Id);
knownMailIds.AddRange(locallyKnownMailUids.Select(a => new UniqueId(a)));
var highestUniqueId = Math.Max(0, locallyKnownMailUids.Count == 0 ? 0 : locallyKnownMailUids.Max());
var missingMailIds = new UniqueIdSet();
var uidValidity = folder.UidValidity;
var highestModeSeq = folder.HighestModeSeq;
var logger = Log.ForContext("FolderName", folder.FolderName);
logger.Verbose("HighestModeSeq: {HighestModeSeq}, HighestUniqueId: {HighestUniqueId}, UIDValidity: {UIDValidity}", highestModeSeq, highestUniqueId, uidValidity);
// Event handlers are placed here to handle existing MailItemFolder and IIMailFolder from MailKit.
// MailKit doesn't expose folder data when these events are emitted.
// Use local folder's UidValidty because cache might've been expired for remote IMAP folder.
// That will make our mail copy id invalid.
EventHandler<MessagesVanishedEventArgs> MessageVanishedHandler = async (s, e) =>
{
if (imapFolder == null) return;
foreach (var uniqueId in e.UniqueIds)
{
var localMailCopyId = Extensions.MailkitClientExtensions.CreateUid(folder.Id, uniqueId.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, localMailCopyId);
}
};
EventHandler<MessageFlagsChangedEventArgs> MessageFlagsChangedHandler = async (s, e) =>
{
if (imapFolder == null) return;
var localMailCopyId = Extensions.MailkitClientExtensions.CreateUid(folder.Id, e.UniqueId.Value.Id);
var isFlagged = Extensions.MailkitClientExtensions.GetIsFlagged(e.Flags);
var isRead = Extensions.MailkitClientExtensions.GetIsRead(e.Flags);
await _imapChangeProcessor.ChangeMailReadStatusAsync(localMailCopyId, isRead);
await _imapChangeProcessor.ChangeFlagStatusAsync(localMailCopyId, isFlagged);
};
EventHandler<MessageEventArgs> MessageExpungedHandler = async (s, e) =>
{
if (imapFolder == null) return;
if (e.UniqueId == null) return;
var localMailCopyId = Extensions.MailkitClientExtensions.CreateUid(folder.Id, e.UniqueId.Value.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, localMailCopyId);
};
try
{
imapFolder = await _synchronizationClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken);
imapFolder.MessageFlagsChanged += MessageFlagsChangedHandler;
// TODO: Bug: Enabling quick re-sync actually doesn't enable it.
var qsyncEnabled = false; // _synchronizationClient.Capabilities.HasFlag(ImapCapabilities.QuickResync);
var condStoreEnabled = _synchronizationClient.Capabilities.HasFlag(ImapCapabilities.CondStore);
if (qsyncEnabled)
{
imapFolder.MessagesVanished += MessageVanishedHandler;
await imapFolder.OpenAsync(FolderAccess.ReadWrite, uidValidity, (ulong)highestModeSeq, knownMailIds, cancellationToken);
// Check the folder validity.
// We'll delete our existing cache if it's not.
// Get all messages after the last successful synchronization date.
// This is fine for Wino synchronization because we're not really looking to
// synchronize all folder.
var allMessageIds = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken);
if (uidValidity != imapFolder.UidValidity)
{
// TODO: Cache is invalid. Delete all local cache.
//await ChangeProcessor.FolderService.ClearImapFolderCacheAsync(folder.Id);
folder.UidValidity = imapFolder.UidValidity;
missingMailIds.AddRange(allMessageIds);
}
else
{
// Cache is valid.
// Add missing mails only.
missingMailIds.AddRange(allMessageIds.Except(knownMailIds).Where(a => a.Id > highestUniqueId));
}
}
else
{
// QSYNC extension is not enabled for the server.
// We rely on ConditionalStore.
imapFolder.MessageExpunged += MessageExpungedHandler;
await imapFolder.OpenAsync(FolderAccess.ReadWrite, cancellationToken);
// Get all messages after the last succesful synchronization date.
// This is fine for Wino synchronization because we're not really looking to
// synchronize all folder.
var allMessageIds = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken);
if (uidValidity != imapFolder.UidValidity)
{
// TODO: Cache is invalid. Delete all local cache.
// await ChangeProcessor.FolderService.ClearImapFolderCacheAsync(folder.Id);
folder.UidValidity = imapFolder.UidValidity;
missingMailIds.AddRange(allMessageIds);
}
else
{
// Cache is valid.
var purgedMessages = knownMailIds.Except(allMessageIds);
foreach (var purgedMessage in purgedMessages)
{
var mailId = Extensions.MailkitClientExtensions.CreateUid(folder.Id, purgedMessage.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, mailId);
}
IList<IMessageSummary> changed;
if (knownMailIds.Count > 0)
{
// CONDSTORE enabled. Fetch items with highest mode seq for known items
// to track flag changes. Otherwise just get changes without the mode seq.
if (condStoreEnabled)
changed = await imapFolder.FetchAsync(knownMailIds, (ulong)highestModeSeq, MessageSummaryItems.Flags | MessageSummaryItems.ModSeq | MessageSummaryItems.UniqueId);
else
changed = await imapFolder.FetchAsync(knownMailIds, MessageSummaryItems.Flags | MessageSummaryItems.UniqueId);
foreach (var changedItem in changed)
{
var localMailCopyId = Extensions.MailkitClientExtensions.CreateUid(folder.Id, changedItem.UniqueId.Id);
var isFlagged = Extensions.MailkitClientExtensions.GetIsFlagged(changedItem.Flags);
var isRead = Extensions.MailkitClientExtensions.GetIsRead(changedItem.Flags);
await _imapChangeProcessor.ChangeMailReadStatusAsync(localMailCopyId, isRead);
await _imapChangeProcessor.ChangeFlagStatusAsync(localMailCopyId, isFlagged);
}
}
// We're only interested in items that has highier known uid than we fetched before.
// Others are just older messages.
missingMailIds.AddRange(allMessageIds.Except(knownMailIds).Where(a => a.Id > highestUniqueId));
}
}
// Fetch completely missing new items in the end.
// Limit check.
if (missingMailIds.Count > InitialMessageDownloadCountPerFolder)
{
missingMailIds = new UniqueIdSet(missingMailIds.TakeLast((int)InitialMessageDownloadCountPerFolder));
}
// In case of the high input, we'll batch them by 50 to reflect changes quickly.
var batchedMissingMailIds = missingMailIds.Batch(50).Select(a => new UniqueIdSet(a, SortOrder.Descending));
foreach (var batchMissingMailIds in batchedMissingMailIds)
{
var summaries = await imapFolder.FetchAsync(batchMissingMailIds, mailSynchronizationFlags, cancellationToken).ConfigureAwait(false);
foreach (var summary in summaries)
{
// We pass the opened folder and summary to retrieve raw MimeMessage.
var creationPackage = new ImapMessageCreationPackage(summary, imapFolder);
var createdMailPackages = await CreateNewMailPackagesAsync(creationPackage, folder, cancellationToken).ConfigureAwait(false);
// Local draft is mapped. We don't need to create a new mail copy.
if (createdMailPackages == null)
continue;
foreach (var mailPackage in createdMailPackages)
{
await _imapChangeProcessor.CreateMailAsync(Account.Id, mailPackage).ConfigureAwait(false);
}
}
}
if (folder.HighestModeSeq != (long)imapFolder.HighestModSeq)
{
folder.HighestModeSeq = (long)imapFolder.HighestModSeq;
await _imapChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
}
// Update last synchronization date for the folder..
await _imapChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
return downloadedMessageIds;
}
catch (FolderNotFoundException)
{
await _imapChangeProcessor.DeleteFolderAsync(Account.Id, folder.RemoteFolderId).ConfigureAwait(false);
return default;
}
catch (Exception)
{
throw;
}
finally
{
if (imapFolder != null)
{
imapFolder.MessageFlagsChanged -= MessageFlagsChangedHandler;
imapFolder.MessageExpunged -= MessageExpungedHandler;
imapFolder.MessagesVanished -= MessageVanishedHandler;
if (imapFolder.IsOpen)
await imapFolder.CloseAsync();
}
_clientPool.Release(_synchronizationClient);
}
}
/// <summary>
/// Whether the local folder should be updated with the remote folder.
/// IMAP only compares folder name for now.
/// </summary>
/// <param name="remoteFolder">Remote folder</param>
/// <param name="localFolder">Local folder.</param>
public bool ShouldUpdateFolder(IMailFolder remoteFolder, MailItemFolder localFolder) => remoteFolder.Name != localFolder.FolderName;
}
}

View File

@@ -0,0 +1,753 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.Kiota.Abstractions;
using Microsoft.Kiota.Abstractions.Authentication;
using MimeKit;
using MoreLinq.Extensions;
using Serilog;
using Wino.Domain;
using Wino.Domain.Exceptions;
using Wino.Core.Extensions;
using Wino.Core.Http;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Synchronization;
using Wino.Services.Requests;
using Wino.Services.Requests.Bundles;
namespace Wino.Core.Synchronizers
{
public class OutlookSynchronizer : BaseSynchronizer<RequestInformation, Message>
{
public override uint BatchModificationSize => 20;
public override uint InitialMessageDownloadCountPerFolder => 250;
private const uint MaximumAllowedBatchRequestSize = 20;
private const string INBOX_NAME = "inbox";
private const string SENT_NAME = "sentitems";
private const string DELETED_NAME = "deleteditems";
private const string JUNK_NAME = "junkemail";
private const string DRAFTS_NAME = "drafts";
private const string ARCHIVE_NAME = "archive";
private readonly string[] outlookMessageSelectParameters =
[
"InferenceClassification",
"Flag",
"Importance",
"IsRead",
"IsDraft",
"ReceivedDateTime",
"HasAttachments",
"BodyPreview",
"Id",
"ConversationId",
"From",
"Subject",
"ParentFolderId",
"InternetMessageId",
];
private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new(1);
private readonly ILogger _logger = Log.ForContext<OutlookSynchronizer>();
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
private readonly GraphServiceClient _graphClient;
public OutlookSynchronizer(MailAccount account,
IAuthenticator authenticator,
IOutlookChangeProcessor outlookChangeProcessor) : base(account)
{
var tokenProvider = new MicrosoftTokenProvider(Account, authenticator);
// Add immutable id preffered client.
var handlers = GraphClientFactory.CreateDefaultHandlers();
handlers.Add(new MicrosoftImmutableIdHandler());
var httpClient = GraphClientFactory.Create(handlers);
_graphClient = new GraphServiceClient(httpClient, new BaseBearerTokenAuthenticationProvider(tokenProvider));
_outlookChangeProcessor = outlookChangeProcessor;
// Specify to use TLS 1.2 as default connection
System.Net.ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
}
public override async Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
_logger.Information("Internal synchronization started for {Name}", Account.Name);
_logger.Information("Options: {Options}", options);
try
{
options.ProgressListener?.AccountProgressUpdated(Account.Id, 1);
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
if (options.Type != SynchronizationType.FoldersOnly)
{
var synchronizationFolders = await _outlookChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
_logger.Information("Found {Count} folders to synchronize.", synchronizationFolders.Count);
_logger.Information(string.Format("Folders: {0}", string.Join(",", synchronizationFolders.Select(a => a.FolderName))));
for (int i = 0; i < synchronizationFolders.Count; i++)
{
var folder = synchronizationFolders[i];
var progress = (int)Math.Round((double)(i + 1) / synchronizationFolders.Count * 100);
options.ProgressListener?.AccountProgressUpdated(Account.Id, progress);
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
}
}
}
catch (Exception ex)
{
_logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
throw;
}
finally
{
options.ProgressListener?.AccountProgressUpdated(Account.Id, 100);
}
// Get all unred new downloaded items and return in the result.
// This is primarily used in notifications.
var unreadNewItems = await _outlookChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
return SynchronizationResult.Completed(unreadNewItems);
}
private async Task<IEnumerable<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
_logger.Debug("Started synchronization for folder {FolderName}", folder.FolderName);
cancellationToken.ThrowIfCancellationRequested();
string latestDeltaLink = string.Empty;
bool isInitialSync = string.IsNullOrEmpty(folder.DeltaToken);
Microsoft.Graph.Me.MailFolders.Item.Messages.Delta.DeltaGetResponse messageCollectionPage = null;
if (isInitialSync)
{
_logger.Debug("No sync identifier for Folder {FolderName}. Performing initial sync.", folder.FolderName);
// No delta link. Performing initial sync.
messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) =>
{
config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
config.QueryParameters.Select = outlookMessageSelectParameters;
config.QueryParameters.Orderby = ["receivedDateTime desc"];
}, cancellationToken).ConfigureAwait(false);
}
else
{
var currentDeltaToken = folder.DeltaToken;
_logger.Debug("Sync identifier found for Folder {FolderName}. Performing delta sync.", folder.FolderName);
_logger.Debug("Current delta token: {CurrentDeltaToken}", currentDeltaToken);
var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
{
config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
config.QueryParameters.Select = outlookMessageSelectParameters;
config.QueryParameters.Orderby = ["receivedDateTime desc"];
});
requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken");
requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken);
messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation, Microsoft.Graph.Me.MailFolders.Item.Messages.Delta.DeltaGetResponse.CreateFromDiscriminatorValue);
}
var messageIteratorAsync = PageIterator<Message, Microsoft.Graph.Me.MailFolders.Item.Messages.Delta.DeltaGetResponse>.CreatePageIterator(_graphClient, messageCollectionPage, async (item) =>
{
try
{
await _handleItemRetrievalSemaphore.WaitAsync();
return await HandleItemRetrievedAsync(item, folder, downloadedMessageIds, cancellationToken);
}
catch (Exception ex)
{
_logger.Error(ex, "Error occurred while handling item {Id} for folder {FolderName}", item.Id, folder.FolderName);
}
finally
{
_handleItemRetrievalSemaphore.Release();
}
return true;
});
await messageIteratorAsync
.IterateAsync(cancellationToken)
.ConfigureAwait(false);
latestDeltaLink = messageIteratorAsync.Deltalink;
if (downloadedMessageIds.Any())
{
_logger.Debug("Downloaded {Count} messages for folder {FolderName}", downloadedMessageIds.Count, folder.FolderName);
}
_logger.Debug("Iterator completed for folder {FolderName}", folder.FolderName);
_logger.Debug("Extracted latest delta link is {LatestDeltaLink}", latestDeltaLink);
//Store delta link for tracking new changes.
if (!string.IsNullOrEmpty(latestDeltaLink))
{
// Parse Delta Token from Delta Link since v5 of Graph SDK works based on the token, not the link.
var deltaToken = GetDeltaTokenFromDeltaLink(latestDeltaLink);
await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false);
}
await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
return downloadedMessageIds;
}
private string GetDeltaTokenFromDeltaLink(string deltaLink)
=> Regex.Split(deltaLink, "deltatoken=")[1];
private bool IsResourceDeleted(IDictionary<string, object> additionalData)
=> additionalData != null && additionalData.ContainsKey("@removed");
private bool IsResourceUpdated(IDictionary<string, object> additionalData)
=> additionalData == null || !additionalData.Any();
private async Task<bool> HandleFolderRetrievedAsync(MailFolder folder, OutlookSpecialFolderIdInformation outlookSpecialFolderIdInformation, CancellationToken cancellationToken = default)
{
if (IsResourceDeleted(folder.AdditionalData))
{
await _outlookChangeProcessor.DeleteFolderAsync(Account.Id, folder.Id).ConfigureAwait(false);
}
else if (IsResourceUpdated(folder.AdditionalData))
{
// TODO
Debugger.Break();
}
else
{
// New folder created.
var item = folder.GetLocalFolder(Account.Id);
if (item.RemoteFolderId.Equals(outlookSpecialFolderIdInformation.InboxId))
item.SpecialFolderType = SpecialFolderType.Inbox;
else if (item.RemoteFolderId.Equals(outlookSpecialFolderIdInformation.SentId))
item.SpecialFolderType = SpecialFolderType.Sent;
else if (item.RemoteFolderId.Equals(outlookSpecialFolderIdInformation.DraftId))
item.SpecialFolderType = SpecialFolderType.Draft;
else if (item.RemoteFolderId.Equals(outlookSpecialFolderIdInformation.TrashId))
item.SpecialFolderType = SpecialFolderType.Deleted;
else if (item.RemoteFolderId.Equals(outlookSpecialFolderIdInformation.JunkId))
item.SpecialFolderType = SpecialFolderType.Junk;
else if (item.RemoteFolderId.Equals(outlookSpecialFolderIdInformation.ArchiveId))
item.SpecialFolderType = SpecialFolderType.Archive;
else
item.SpecialFolderType = SpecialFolderType.Other;
// Automatically mark special folders as Sticky for better visibility.
item.IsSticky = item.SpecialFolderType != SpecialFolderType.Other;
// By default, all non-others are system folder.
item.IsSystemFolder = item.SpecialFolderType != SpecialFolderType.Other;
// By default, all special folders update unread count in the UI except Trash.
item.ShowUnreadCount = item.SpecialFolderType != SpecialFolderType.Deleted || item.SpecialFolderType != SpecialFolderType.Other;
await _outlookChangeProcessor.InsertFolderAsync(item).ConfigureAwait(false);
}
return true;
}
private async Task<bool> HandleItemRetrievedAsync(Message item, MailItemFolder folder, IList<string> downloadedMessageIds, CancellationToken cancellationToken = default)
{
if (IsResourceDeleted(item.AdditionalData))
{
// Deleting item with this override instead of the other one that deletes all mail copies.
// Outlook mails have 1 assignment per-folder, unlike Gmail that has one to many.
await _outlookChangeProcessor.DeleteAssignmentAsync(Account.Id, item.Id, folder.RemoteFolderId).ConfigureAwait(false);
}
else if (IsResourceUpdated(item.AdditionalData))
{
// Some of the properties of the item are updated.
if (item.IsRead != null)
{
await _outlookChangeProcessor.ChangeMailReadStatusAsync(item.Id, item.IsRead.GetValueOrDefault()).ConfigureAwait(false);
}
if (item.Flag?.FlagStatus != null)
{
await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, item.Flag.FlagStatus.GetValueOrDefault() == FollowupFlagStatus.Flagged)
.ConfigureAwait(false);
}
}
else
{
// Package may return null on some cases mapping the remote draft to existing local draft.
var newMailPackages = await CreateNewMailPackagesAsync(item, folder, cancellationToken);
if (newMailPackages != null)
{
foreach (var package in newMailPackages)
{
// Only add to downloaded message ids if it's inserted successfuly.
// Updates should not be added to the list because they are not new.
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
if (isInserted)
{
downloadedMessageIds.Add(package.Copy.Id);
}
}
}
}
return true;
}
private async Task SynchronizeFoldersAsync(CancellationToken cancellationToken = default)
{
// Gather special folders by default.
// Others will be other type.
// Get well known folder ids by batch.
var wellKnownFolderIdBatch = new BatchRequestContentCollection(_graphClient);
var inboxRequest = _graphClient.Me.MailFolders[INBOX_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; });
var sentRequest = _graphClient.Me.MailFolders[SENT_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; });
var deletedRequest = _graphClient.Me.MailFolders[DELETED_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; });
var junkRequest = _graphClient.Me.MailFolders[JUNK_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; });
var draftsRequest = _graphClient.Me.MailFolders[DRAFTS_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; });
var archiveRequest = _graphClient.Me.MailFolders[ARCHIVE_NAME].ToGetRequestInformation((t) => { t.QueryParameters.Select = ["id"]; });
var inboxId = await wellKnownFolderIdBatch.AddBatchRequestStepAsync(inboxRequest);
var sentId = await wellKnownFolderIdBatch.AddBatchRequestStepAsync(sentRequest);
var deletedId = await wellKnownFolderIdBatch.AddBatchRequestStepAsync(deletedRequest);
var junkId = await wellKnownFolderIdBatch.AddBatchRequestStepAsync(junkRequest);
var draftsId = await wellKnownFolderIdBatch.AddBatchRequestStepAsync(draftsRequest);
var archiveId = await wellKnownFolderIdBatch.AddBatchRequestStepAsync(archiveRequest);
var returnedResponse = await _graphClient.Batch.PostAsync(wellKnownFolderIdBatch, cancellationToken).ConfigureAwait(false);
var inboxFolderId = (await returnedResponse.GetResponseByIdAsync<MailFolder>(inboxId)).Id;
var sentFolderId = (await returnedResponse.GetResponseByIdAsync<MailFolder>(sentId)).Id;
var deletedFolderId = (await returnedResponse.GetResponseByIdAsync<MailFolder>(deletedId)).Id;
var junkFolderId = (await returnedResponse.GetResponseByIdAsync<MailFolder>(junkId)).Id;
var draftsFolderId = (await returnedResponse.GetResponseByIdAsync<MailFolder>(draftsId)).Id;
var archiveFolderId = (await returnedResponse.GetResponseByIdAsync<MailFolder>(archiveId)).Id;
var specialFolderInfo = new OutlookSpecialFolderIdInformation(inboxFolderId, deletedFolderId, junkFolderId, draftsFolderId, sentFolderId, archiveFolderId);
Microsoft.Graph.Me.MailFolders.Delta.DeltaGetResponse graphFolders = null;
if (string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier))
{
// Initial folder sync.
var deltaRequest = _graphClient.Me.MailFolders.Delta.ToGetRequestInformation();
deltaRequest.UrlTemplate = deltaRequest.UrlTemplate.Insert(deltaRequest.UrlTemplate.Length - 1, ",includehiddenfolders");
deltaRequest.QueryParameters.Add("includehiddenfolders", "true");
graphFolders = await _graphClient.RequestAdapter.SendAsync(deltaRequest,
Microsoft.Graph.Me.MailFolders.Delta.DeltaGetResponse.CreateFromDiscriminatorValue,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
var currentDeltaLink = Account.SynchronizationDeltaIdentifier;
var deltaRequest = _graphClient.Me.MailFolders.Delta.ToGetRequestInformation();
deltaRequest.UrlTemplate = deltaRequest.UrlTemplate.Insert(deltaRequest.UrlTemplate.Length - 1, ",%24deltaToken");
deltaRequest.QueryParameters.Add("%24deltaToken", currentDeltaLink);
graphFolders = await _graphClient.RequestAdapter.SendAsync(deltaRequest,
Microsoft.Graph.Me.MailFolders.Delta.DeltaGetResponse.CreateFromDiscriminatorValue,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
var iterator = PageIterator<MailFolder, Microsoft.Graph.Me.MailFolders.Delta.DeltaGetResponse>.CreatePageIterator(_graphClient, graphFolders, (folder) =>
{
return HandleFolderRetrievedAsync(folder, specialFolderInfo, cancellationToken);
});
await iterator.IterateAsync();
if (!string.IsNullOrEmpty(iterator.Deltalink))
{
// Get the second part of the query that its the deltaToken
var deltaToken = iterator.Deltalink.Split('=')[1];
var latestAccountDeltaToken = await _outlookChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, deltaToken);
if (!string.IsNullOrEmpty(latestAccountDeltaToken))
{
Account.SynchronizationDeltaIdentifier = latestAccountDeltaToken;
}
}
}
#region Mail Integration
public override bool DelaySendOperationSynchronization() => true;
public override IEnumerable<IRequestBundle<RequestInformation>> Move(BatchMoveRequest request)
{
var requestBody = new Microsoft.Graph.Me.Messages.Item.Move.MovePostRequestBody()
{
DestinationId = request.ToFolder.RemoteFolderId
};
return CreateBatchedHttpBundle(request, (item) =>
{
return _graphClient.Me.Messages[item.Item.Id.ToString()].Move.ToPostRequestInformation(requestBody);
});
}
public override IEnumerable<IRequestBundle<RequestInformation>> ChangeFlag(BatchChangeFlagRequest request)
{
return CreateBatchedHttpBundle(request, (item) =>
{
var message = new Message()
{
Flag = new FollowupFlag() { FlagStatus = request.IsFlagged ? FollowupFlagStatus.Flagged : FollowupFlagStatus.NotFlagged }
};
return _graphClient.Me.Messages[item.Item.Id.ToString()].ToPatchRequestInformation(message);
});
}
public override IEnumerable<IRequestBundle<RequestInformation>> MarkRead(BatchMarkReadRequest request)
{
return CreateBatchedHttpBundle(request, (item) =>
{
var message = new Message()
{
IsRead = request.IsRead
};
return _graphClient.Me.Messages[item.Item.Id].ToPatchRequestInformation(message);
});
}
public override IEnumerable<IRequestBundle<RequestInformation>> Delete(BatchDeleteRequest request)
{
return CreateBatchedHttpBundle(request, (item) =>
{
return _graphClient.Me.Messages[item.Item.Id].ToDeleteRequestInformation();
});
}
public override IEnumerable<IRequestBundle<RequestInformation>> MoveToFocused(BatchMoveToFocusedRequest request)
{
return CreateBatchedHttpBundleFromGroup(request, (item) =>
{
if (item is MoveToFocusedRequest moveToFocusedRequest)
{
var message = new Message()
{
InferenceClassification = moveToFocusedRequest.MoveToFocused ? InferenceClassificationType.Focused : InferenceClassificationType.Other
};
return _graphClient.Me.Messages[moveToFocusedRequest.Item.Id].ToPatchRequestInformation(message);
}
throw new Exception("Invalid request type.");
});
}
public override IEnumerable<IRequestBundle<RequestInformation>> AlwaysMoveTo(BatchAlwaysMoveToRequest request)
{
return CreateBatchedHttpBundle<Message>(request, (item) =>
{
if (item is AlwaysMoveToRequest alwaysMoveToRequest)
{
var inferenceClassificationOverride = new InferenceClassificationOverride
{
ClassifyAs = alwaysMoveToRequest.MoveToFocused ? InferenceClassificationType.Focused : InferenceClassificationType.Other,
SenderEmailAddress = new EmailAddress
{
Name = alwaysMoveToRequest.Item.FromName,
Address = alwaysMoveToRequest.Item.FromAddress
}
};
return _graphClient.Me.InferenceClassification.Overrides.ToPostRequestInformation(inferenceClassificationOverride);
}
throw new Exception("Invalid request type.");
});
}
public override IEnumerable<IRequestBundle<RequestInformation>> CreateDraft(BatchCreateDraftRequest request)
{
return CreateHttpBundle<Message>(request, (item) =>
{
if (item is CreateDraftRequest createDraftRequest)
{
createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.Prepare(EncodingConstraint.None);
var plainTextBytes = Encoding.UTF8.GetBytes(createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.ToString());
var base64Encoded = Convert.ToBase64String(plainTextBytes);
var requestInformation = _graphClient.Me.Messages.ToPostRequestInformation(new Message());
requestInformation.Headers.Clear();// replace the json content header
requestInformation.Headers.Add("Content-Type", "text/plain");
requestInformation.SetStreamContent(new MemoryStream(Encoding.UTF8.GetBytes(base64Encoded)), "text/plain");
return requestInformation;
}
return default;
});
}
public override IEnumerable<IRequestBundle<RequestInformation>> SendDraft(BatchSendDraftRequestRequest request)
{
var sendDraftPreparationRequest = request.Request;
// 1. Delete draft
// 2. Create new Message with new MIME.
// 3. Make sure that conversation id is tagged correctly for replies.
var mailCopyId = sendDraftPreparationRequest.MailItem.Id;
var mimeMessage = sendDraftPreparationRequest.Mime;
var batchDeleteRequest = new BatchDeleteRequest(new List<IRequest>()
{
new DeleteRequest(sendDraftPreparationRequest.MailItem)
});
var deleteBundle = Delete(batchDeleteRequest).ElementAt(0);
mimeMessage.Prepare(EncodingConstraint.None);
var plainTextBytes = Encoding.UTF8.GetBytes(mimeMessage.ToString());
var base64Encoded = Convert.ToBase64String(plainTextBytes);
var outlookMessage = new Message()
{
ConversationId = sendDraftPreparationRequest.MailItem.ThreadId
};
// Apply importance here as well just in case.
if (mimeMessage.Importance != MessageImportance.Normal)
outlookMessage.Importance = mimeMessage.Importance == MessageImportance.High ? Importance.High : Importance.Low;
var body = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody()
{
Message = outlookMessage
};
var sendRequest = _graphClient.Me.SendMail.ToPostRequestInformation(body);
sendRequest.Headers.Clear();
sendRequest.Headers.Add("Content-Type", "text/plain");
var stream = new MemoryStream(Encoding.UTF8.GetBytes(base64Encoded));
sendRequest.SetStreamContent(stream, "text/plain");
var sendMailRequest = new HttpRequestBundle<RequestInformation>(sendRequest, request);
return [deleteBundle, sendMailRequest];
}
public override IEnumerable<IRequestBundle<RequestInformation>> Archive(BatchArchiveRequest request)
=> Move(new BatchMoveRequest(request.Items, request.FromFolder, request.ToFolder));
public override async Task DownloadMissingMimeMessageAsync(IMailItem mailItem,
MailKit.ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
{
var mimeMessage = await DownloadMimeMessageAsync(mailItem.Id, cancellationToken).ConfigureAwait(false);
await _outlookChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
}
public override IEnumerable<IRequestBundle<RequestInformation>> RenameFolder(RenameFolderRequest request)
{
return CreateHttpBundleWithResponse<MailFolder>(request, (item) =>
{
if (item is not RenameFolderRequest renameFolderRequest)
throw new ArgumentException($"Renaming folder must be handled with '{nameof(RenameFolderRequest)}'");
var requestBody = new MailFolder
{
DisplayName = request.NewFolderName,
};
return _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ToPatchRequestInformation(requestBody);
});
}
public override IEnumerable<IRequestBundle<RequestInformation>> EmptyFolder(EmptyFolderRequest request)
=> Delete(new BatchDeleteRequest(request.MailsToDelete.Select(a => new DeleteRequest(a))));
public override IEnumerable<IRequestBundle<RequestInformation>> MarkFolderAsRead(MarkFolderAsReadRequest request)
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true)), true));
#endregion
public override async Task ExecuteNativeRequestsAsync(IEnumerable<IRequestBundle<RequestInformation>> batchedRequests, CancellationToken cancellationToken = default)
{
var batchRequestInformations = BatchExtension.Batch(batchedRequests, (int)MaximumAllowedBatchRequestSize);
foreach (var batch in batchRequestInformations)
{
var batchContent = new BatchRequestContentCollection(_graphClient);
var itemCount = batch.Count();
for (int i = 0; i < itemCount; i++)
{
var bundle = batch.ElementAt(i);
var request = bundle.Request;
var nativeRequest = bundle.NativeRequest;
request.ApplyUIChanges();
await batchContent.AddBatchRequestStepAsync(nativeRequest).ConfigureAwait(false);
// Map BundleId to batch request step's key.
// This is how we can identify which step succeeded or failed in the bundle.
bundle.BundleId = batchContent.BatchRequestSteps.ElementAt(i).Key;
}
if (!batchContent.BatchRequestSteps.Any())
continue;
// Execute batch. This will collect responses from network call for each batch step.
var batchRequestResponse = await _graphClient.Batch.PostAsync(batchContent).ConfigureAwait(false);
// Check responses for each bundle id.
// Each bundle id must return some HttpResponseMessage ideally.
var bundleIds = batchContent.BatchRequestSteps.Select(a => a.Key);
// TODO: Handling responses. They used to work in v1 core, but not in v2.
foreach (var bundleId in bundleIds)
{
var bundle = batch.FirstOrDefault(a => a.BundleId == bundleId);
if (bundle == null)
continue;
var httpResponseMessage = await batchRequestResponse.GetResponseByIdAsync(bundleId);
using (httpResponseMessage)
{
await ProcessSingleNativeRequestResponseAsync(bundle, httpResponseMessage, cancellationToken).ConfigureAwait(false);
}
}
}
}
private async Task ProcessSingleNativeRequestResponseAsync(IRequestBundle<RequestInformation> bundle,
HttpResponseMessage httpResponseMessage,
CancellationToken cancellationToken = default)
{
if (!httpResponseMessage.IsSuccessStatusCode)
{
throw new SynchronizerException(string.Format(Translator.Exception_SynchronizerFailureHTTP, httpResponseMessage.StatusCode));
}
else if (bundle is HttpRequestBundle<RequestInformation, Message> messageBundle)
{
var outlookMessage = await messageBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken);
if (outlookMessage == null) return;
// TODO: Handle new message added or updated.
}
else if (bundle is HttpRequestBundle<RequestInformation, Microsoft.Graph.Models.MailFolder> folderBundle)
{
var outlookFolder = await folderBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken);
if (outlookFolder == null) return;
// TODO: Handle new folder added or updated.
}
else if (bundle is HttpRequestBundle<RequestInformation, MimeMessage> mimeBundle)
{
// TODO: Handle mime retrieve message.
}
}
private async Task<MimeMessage> DownloadMimeMessageAsync(string messageId, CancellationToken cancellationToken = default)
{
var mimeContentStream = await _graphClient.Me.Messages[messageId].Content.GetAsync(null, cancellationToken).ConfigureAwait(false);
return await MimeMessage.LoadAsync(mimeContentStream).ConfigureAwait(false);
}
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
bool isMailExists = await _outlookChangeProcessor.IsMailExistsAsync(message.Id);
if (isMailExists)
{
return null;
}
var mimeMessage = await DownloadMimeMessageAsync(message.Id, cancellationToken).ConfigureAwait(false);
var mailCopy = message.AsMailCopy();
if (message.IsDraft.GetValueOrDefault()
&& mimeMessage.Headers.Contains(Wino.Domain.Constants.WinoLocalDraftHeader)
&& Guid.TryParse(mimeMessage.Headers[Wino.Domain.Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId))
{
// This message belongs to existing local draft copy.
// We don't need to create a new mail copy for this message, just update the existing one.
bool isMappingSuccessful = await _outlookChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId);
if (isMappingSuccessful) return null;
// Local copy doesn't exists. Continue execution to insert mail copy.
}
// Outlook messages can only be assigned to 1 folder at a time.
// Therefore we don't need to create multiple copies of the same message for different folders.
var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId);
return [package];
}
}
}