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.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Graph; using Microsoft.Graph.Me.MailFolders.Item.Messages.Delta; using Microsoft.Graph.Models; using Microsoft.Graph.Models.ODataErrors; using Microsoft.Kiota.Abstractions; using Microsoft.Kiota.Abstractions.Authentication; using MimeKit; using MoreLinq.Extensions; using Serilog; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Extensions; using Wino.Core.Http; using Wino.Core.Helpers; using Wino.Core.Integration.Processors; using Wino.Core.Misc; using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Calendar; using Wino.Core.Requests.Folder; using Wino.Core.Requests.Mail; using Wino.Messaging.UI; namespace Wino.Core.Synchronizers.Mail; [JsonSerializable(typeof(Microsoft.Graph.Me.Messages.Item.Move.MovePostRequestBody))] public partial class OutlookSynchronizerJsonContext : JsonSerializerContext; /// /// Outlook synchronizer implementation with delta token synchronization. /// /// SYNCHRONIZATION STRATEGY: /// - Uses delta API for both initial and incremental sync /// - Initial sync: Downloads last 30 days of emails with metadata only /// - Incremental sync: Uses delta token to get only changes since last sync /// - Messages are downloaded with metadata only (no MIME content during sync) /// - MIME files are downloaded on-demand when user explicitly reads a message /// /// Key implementation details: /// - SynchronizeFolderAsync: Main entry point for per-folder synchronization /// - DownloadMailsForInitialSyncAsync: Downloads last 30 days using delta API with filter /// - ProcessDeltaChangesAsync: Processes incremental changes using delta token /// - DownloadMessageMetadataBatchAsync: Downloads metadata in batches using Graph batch API /// - CreateMailCopyFromMessageAsync: Creates MailCopy from Message metadata /// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested /// public class OutlookSynchronizer : WinoSynchronizer { public override uint BatchModificationSize => 20; public override uint InitialMessageDownloadCountPerFolder => 1000; private const uint MaximumAllowedBatchRequestSize = 20; private const int SimpleAttachmentUploadLimitBytes = 3 * 1024 * 1024; private const int MaximumUploadSessionAttachmentSizeBytes = 150 * 1024 * 1024; private const int LargeAttachmentUploadChunkSizeBytes = 320 * 1024; 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", "Sender", "ToRecipients", "CcRecipients", "BccRecipients", "ReplyTo", "Subject", "ParentFolderId", "InternetMessageId", "InternetMessageHeaders", ]; private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new(1); private readonly SemaphoreSlim _handleCalendarEventRetrievalSemaphore = new(1); private readonly ILogger _logger = Log.ForContext(); private readonly IOutlookChangeProcessor _outlookChangeProcessor; private readonly GraphServiceClient _graphClient; private readonly IOutlookSynchronizerErrorHandlerFactory _errorHandlingFactory; private bool _isFolderStructureChanged; private readonly SemaphoreSlim _concurrentDownloadSemaphore = new(10); // Limit to 10 concurrent downloads public OutlookSynchronizer(MailAccount account, IAuthenticator authenticator, IOutlookChangeProcessor outlookChangeProcessor, IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory) : base(account, WeakReferenceMessenger.Default) { var tokenProvider = new MicrosoftTokenProvider(Account, authenticator); // Update request handlers for Graph client. var handlers = GraphClientFactory.CreateDefaultHandlers(); handlers.Add(GetMicrosoftImmutableIdHandler()); handlers.Add(GetGraphRateLimitHandler()); var httpClient = GraphClientFactory.Create(handlers); _graphClient = new GraphServiceClient(httpClient, new BaseBearerTokenAuthenticationProvider(tokenProvider)); _outlookChangeProcessor = outlookChangeProcessor; _errorHandlingFactory = errorHandlingFactory; } #region MS Graph Handlers private MicrosoftImmutableIdHandler GetMicrosoftImmutableIdHandler() => new(); private GraphRateLimitHandler GetGraphRateLimitHandler() => new(); #endregion protected override async Task SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default) { var downloadedMessageIds = new List(); var folderResults = new List(); _logger.Information("Internal synchronization started for {Name}", Account.Name); _logger.Information("Options: {Options}", options); try { // Set indeterminate progress initially UpdateSyncProgress(0, 0, "Synchronizing folders..."); await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false); if (options.Type != MailSynchronizationType.FoldersOnly) { var synchronizationFolders = await _outlookChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false); _logger.Information(string.Format("{1} Folders: {0}", string.Join(",", synchronizationFolders.Select(a => a.FolderName)), synchronizationFolders.Count)); var totalFolders = synchronizationFolders.Count; for (int i = 0; i < totalFolders; i++) { var folder = synchronizationFolders[i]; // Update progress based on folder completion var progressPercentage = (int)Math.Round((double)(i + 1) / totalFolders * 100); var statusMessage = string.Format(Translator.Sync_SynchronizingFolder, folder.FolderName, progressPercentage); UpdateSyncProgress(totalFolders, totalFolders - (i + 1), statusMessage); try { var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false); downloadedMessageIds.AddRange(folderDownloadedMessageIds); folderResults.Add(FolderSyncResult.Successful(folder.Id, folder.FolderName, folderDownloadedMessageIds.Count())); } catch (OperationCanceledException) { // Cancellation should stop the entire sync throw; } catch (ODataError odataError) { // Handle OData errors - determine if we should continue or stop var errorContext = new SynchronizerErrorContext { Account = Account, ErrorCode = (int?)odataError.ResponseStatusCode, ErrorMessage = odataError.Error?.Message ?? odataError.Message, Exception = odataError, FolderId = folder.Id, FolderName = folder.FolderName, OperationType = "FolderSync" }; var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); if (errorContext.CanContinueSync) { _logger.Warning("Folder {FolderName} sync failed with recoverable error, continuing with other folders. Error: {Error}", folder.FolderName, odataError.Error?.Message); folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext)); } else { _logger.Error(odataError, "Folder {FolderName} sync failed with fatal error, stopping sync", folder.FolderName); folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext)); throw; } } catch (Exception ex) { // For unexpected exceptions, try to classify and decide if we should continue var errorContext = new SynchronizerErrorContext { Account = Account, ErrorMessage = ex.Message, Exception = ex, FolderId = folder.Id, FolderName = folder.FolderName, OperationType = "FolderSync", Severity = SynchronizerErrorSeverity.Recoverable, // Default to recoverable for individual folders Category = SynchronizerErrorCategory.Unknown }; _logger.Warning(ex, "Folder {FolderName} sync failed, continuing with other folders", folder.FolderName); folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext)); } } } } catch (OperationCanceledException) { _logger.Information("Synchronization was canceled for {Name}", Account.Name); return MailSynchronizationResult.Canceled; } catch (Exception ex) { _logger.Error(ex, "Synchronizing folders for {Name}", Account.Name); return MailSynchronizationResult.Failed(ex); } finally { // Reset progress at the end ResetSyncProgress(); } // Get all unread 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 MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults); } public async Task DownloadSearchResultMessageAsync(string messageId, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(messageId) || assignedFolder == null) return; // Online search can return the same message across repeated invocations/races. // Guard before network+MIME download and before database insert. var existing = await _outlookChangeProcessor.AreMailsExistsAsync([messageId]).ConfigureAwait(false); if (existing.Contains(messageId)) { return; } Log.Information("Downloading search result message {messageId} for {Name} - {FolderName}", messageId, Account.Name, assignedFolder.FolderName); // Outlook message handling was a little strange. // Instead of changing it from the scratch, we will just download the message and process it. // Search results will only return Id for the messages. // This method will download the raw mime, get the required enough metadata from the service and create // the mail locally. Message ids passed to this method is expected to be non-existent locally. var message = await _graphClient.Me.Messages[messageId].GetAsync((config) => { config.QueryParameters.Select = outlookMessageSelectParameters; }, cancellationToken).ConfigureAwait(false); // Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted) if (Account.IsCalendarAccessGranted && message is EventMessage) { message = await FetchEventMessageAsync(message.Id, cancellationToken).ConfigureAwait(false); if (message == null) { _logger.Warning("Failed to fetch EventMessage {MessageId}, skipping", messageId); return; } } var mailPackages = await CreateNewMailPackagesAsync(message, assignedFolder, cancellationToken).ConfigureAwait(false); if (mailPackages == null) return; foreach (var package in mailPackages) { cancellationToken.ThrowIfCancellationRequested(); // Use safe upsert path to avoid duplicate rows when message already exists. await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); } } private async Task> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken = default) { var downloadedMessageIds = new List(); cancellationToken.ThrowIfCancellationRequested(); _logger.Debug("Synchronizing {FolderName} using delta API", folder.FolderName); // Check if we have a delta token if (string.IsNullOrEmpty(folder.DeltaToken)) { _logger.Debug("No delta token for folder {FolderName}. Starting initial sync (last 30 days).", folder.FolderName); // Download mails for initial sync (last 30 days) await DownloadMailsForInitialSyncAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false); } else { // Initial sync is completed, process delta changes _logger.Debug("Delta token exists for folder {FolderName}. Processing incremental changes.", folder.FolderName); await ProcessDeltaChangesAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false); } await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false); if (downloadedMessageIds.Any()) { _logger.Information("Downloaded {Count} messages for folder {FolderName}", downloadedMessageIds.Count, folder.FolderName); } return downloadedMessageIds; } /// /// Downloads mails for initial synchronization using Delta API with 30-day filter. /// Downloads metadata only (no MIME content) for messages received in the last 30 days. /// private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken) { _logger.Debug("Starting initial mail download for folder {FolderName} (last 6 months)", folder.FolderName); try { // Calculate date 6 months ago var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6); var filterDate = sixMonthsAgo.ToString("yyyy-MM-ddTHH:mm:ssZ"); _logger.Information("Downloading messages received after {FilterDate} for folder {FolderName}", filterDate, folder.FolderName); // Use Delta API with receivedDateTime filter for last 6 months var messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) => { config.QueryParameters.Select = outlookMessageSelectParameters; config.QueryParameters.Orderby = ["receivedDateTime desc"]; config.QueryParameters.Filter = $"receivedDateTime ge {filterDate}"; }, cancellationToken).ConfigureAwait(false); var totalProcessed = 0; // Use PageIterator to process all messages var messageIterator = PageIterator.CreatePageIterator(_graphClient, messageCollectionPage, async (message) => { try { await _handleItemRetrievalSemaphore.WaitAsync(); if (!IsResourceDeleted(message.AdditionalData) && !IsNotRealMessageType(message)) { // Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted) if (Account.IsCalendarAccessGranted && message is EventMessage) { message = await FetchEventMessageAsync(message.Id, cancellationToken).ConfigureAwait(false); if (message == null) { return true; // Skip this message if fetch failed } } // Check if message already exists bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(message.Id, folder.Id).ConfigureAwait(false); if (!mailExists) { // For drafts and calendar invitations, download MIME during initial sync like delta sync. var itemType = Account.IsCalendarAccessGranted ? message.GetMailItemType() : MailItemType.Mail; if (folder.SpecialFolderType == SpecialFolderType.Draft || itemType == MailItemType.CalendarInvitation) { var draftPackages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false); if (draftPackages != null) { foreach (var package in draftPackages) { bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); if (isInserted) { downloadedMessageIds.Add(package.Copy.Id); totalProcessed++; } } } } else { // Create MailCopy from metadata var mailCopy = await CreateMailCopyFromMessageAsync(message, folder).ConfigureAwait(false); if (mailCopy != null) { // Create package without MIME var contacts = ExtractContactsFromOutlookMessage(message); var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId, contacts); bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); if (isInserted) { downloadedMessageIds.Add(mailCopy.Id); totalProcessed++; } } } // Update progress periodically if (totalProcessed > 0 && totalProcessed % 50 == 0) { var statusMessage = string.Format(Translator.Sync_DownloadedMessages, totalProcessed, folder.FolderName); UpdateSyncProgress(0, 0, statusMessage); } } else { _logger.Debug("Mail {MailId} already exists in folder {FolderName}, skipping", message.Id, folder.FolderName); } } return true; // Continue processing } catch (Exception ex) { _logger.Error(ex, "Failed to process message {MessageId} during initial sync for folder {FolderName}", message.Id, folder.FolderName); return true; // Continue despite error } finally { _handleItemRetrievalSemaphore.Release(); } }); await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false); // Extract and store delta token for future incremental syncs if (!string.IsNullOrEmpty(messageIterator.Deltalink)) { var deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink); await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false); await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false); folder.DeltaToken = deltaToken; _logger.Information("Stored delta token for folder {FolderName} - future syncs will be incremental", folder.FolderName); } else { _logger.Warning("No delta token received for folder {FolderName} - future syncs may re-download messages", folder.FolderName); } _logger.Information("Initial sync completed for folder {FolderName}. Downloaded {Count} messages", folder.FolderName, totalProcessed); } catch (ApiException apiException) { // Handle API errors var errorContext = new SynchronizerErrorContext { Account = Account, ErrorCode = (int?)apiException.ResponseStatusCode, ErrorMessage = $"API error during initial sync: {apiException.Message}", Exception = apiException }; var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); if (handled) { if (apiException.ResponseStatusCode == 410) { folder.DeltaToken = string.Empty; _logger.Information("API error handled successfully for folder {FolderName} during initial sync. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode); } } else { _logger.Error(apiException, "Unhandled API error during initial sync for folder {FolderName}. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode); } throw; } catch (Exception ex) { _logger.Error(ex, "Error occurred during initial mail download for folder {FolderName}", folder.FolderName); throw; } } /// /// Downloads metadata for a batch of messages using Graph SDK batch API (no MIME content). /// Processes up to 20 messages per batch request as per MaximumAllowedBatchRequestSize. /// private async Task> DownloadMessageMetadataBatchAsync(List messageIds, MailItemFolder folder, bool retryFailedOnce, CancellationToken cancellationToken) { if (messageIds == null || messageIds.Count == 0) return new List(); var downloadedIds = new List(); // Filter out messages that already exist in the database var messagesToDownload = new List(); foreach (var messageId in messageIds) { bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(messageId, folder.Id).ConfigureAwait(false); if (!mailExists) { messagesToDownload.Add(messageId); } else { _logger.Debug("Mail {MailId} already exists in folder {FolderName}, skipping download", messageId, folder.FolderName); } } if (messagesToDownload.Count == 0) { _logger.Debug("All messages already exist in folder {FolderName}", folder.FolderName); return downloadedIds; } // Store failed message ids to retry after. List failedMessageIds = new(); // Process in batches of MaximumAllowedBatchRequestSize (20) var batches = messagesToDownload.Batch((int)MaximumAllowedBatchRequestSize); foreach (var batch in batches) { cancellationToken.ThrowIfCancellationRequested(); try { var batchContent = new BatchRequestContentCollection(_graphClient); var requestIdToMessageIdMap = new Dictionary(); // Add all message requests to the batch foreach (var messageId in batch) { var requestInfo = _graphClient.Me.Messages[messageId].ToGetRequestInformation((config) => { config.QueryParameters.Select = outlookMessageSelectParameters; }); var batchRequestId = await batchContent.AddBatchRequestStepAsync(requestInfo).ConfigureAwait(false); requestIdToMessageIdMap[batchRequestId] = messageId; } // Execute the batch request var batchResponse = await _graphClient.Batch.PostAsync(batchContent, cancellationToken).ConfigureAwait(false); // Process all responses foreach (var batchRequestId in requestIdToMessageIdMap.Keys) { var messageId = requestIdToMessageIdMap[batchRequestId]; try { // Deserialize the Message directly from batch response var message = await batchResponse.GetResponseByIdAsync(batchRequestId).ConfigureAwait(false); if (message != null) { // Create MailCopy from metadata only var mailCopy = await CreateMailCopyFromMessageAsync(message, folder).ConfigureAwait(false); if (mailCopy != null) { // Create package without MIME var contacts = ExtractContactsFromOutlookMessage(message); var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId, contacts); bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); if (isInserted) { downloadedIds.Add(mailCopy.Id); _logger.Debug("Downloaded metadata for message {MailId} in folder {FolderName}", messageId, folder.FolderName); } else { _logger.Warning("Failed to insert mail {MailId} for folder {FolderName}", messageId, folder.FolderName); } } } else { _logger.Warning("Failed to deserialize message {MailId} for folder {FolderName}", messageId, folder.FolderName); failedMessageIds.Add(messageId); } } catch (ODataError odataError) { // Handle OData errors from the batch response if (odataError.ResponseStatusCode == 404) { _logger.Warning("Mail {MailId} not found on server (404) for folder {FolderName}", messageId, folder.FolderName); } else { failedMessageIds.Add(messageId); _logger.Error("OData error while downloading mail {MailId} for folder {FolderName}. Error: {Error}", messageId, folder.FolderName, odataError.Error?.Message); } } catch (ServiceException serviceException) { // Try to handle the error using the error handling factory var errorContext = new SynchronizerErrorContext { Account = Account, ErrorCode = (int?)serviceException.ResponseStatusCode, ErrorMessage = $"Service error during batch mail download: {serviceException.Message}", Exception = serviceException, }; var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); if (!handled) { failedMessageIds.Add(messageId); _logger.Error(serviceException, "Unhandled service error while downloading mail {MailId} for folder {FolderName}. Error: {ErrorCode}", messageId, folder.FolderName, serviceException.ResponseStatusCode); } } catch (Exception ex) { failedMessageIds.Add(messageId); _logger.Error(ex, "Error occurred while processing message {MailId} for folder {FolderName}", messageId, folder.FolderName); } } } catch (Exception ex) { failedMessageIds.AddRange(batch); _logger.Error(ex, "Error occurred during batch download for folder {FolderName}", folder.FolderName); } } if (retryFailedOnce && failedMessageIds.Any()) { // For a good cause wait a little bit. await Task.Delay(3000); // Do not retry here once again. var failedDownloadedMessagIds = await DownloadMessageMetadataBatchAsync(failedMessageIds, folder, false, cancellationToken); downloadedIds.Concat(failedDownloadedMessagIds); } return downloadedIds; } /// /// Creates a MailCopy from an Outlook Message with metadata only (centralized method). /// This replaces the scattered CreateMinimalMailCopyAsync and AsMailCopy calls. /// private async Task CreateMailCopyFromMessageAsync(Message message, MailItemFolder assignedFolder) { if (message == null) return null; var mailCopy = message.AsMailCopy(); mailCopy.FolderId = assignedFolder.Id; mailCopy.UniqueId = Guid.NewGuid(); mailCopy.FileId = Guid.NewGuid(); // Set ItemType based on calendar access permissions if (Account.IsCalendarAccessGranted && message is EventMessage) { mailCopy.ItemType = message.GetMailItemType(); } // Check for draft mapping if this is a draft with WinoLocalDraftHeader if (message.IsDraft.GetValueOrDefault() && message.InternetMessageHeaders != null) { var winoDraftHeader = message.InternetMessageHeaders .FirstOrDefault(h => string.Equals(h.Name, Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase)); if (winoDraftHeader != null && Guid.TryParse(winoDraftHeader.Value, 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) { _logger.Debug("Successfully mapped remote draft {RemoteId} to local draft {LocalId}", mailCopy.Id, localDraftCopyUniqueId); return null; // Don't create new mail copy, existing one was updated } // Local copy doesn't exist. Continue execution to insert mail copy. _logger.Debug("Local draft copy {LocalId} not found, creating new mail copy for {RemoteId}", localDraftCopyUniqueId, mailCopy.Id); } } return mailCopy; } private static IReadOnlyList ExtractContactsFromOutlookMessage(Message message) { if (message == null) return []; var contacts = new Dictionary(StringComparer.OrdinalIgnoreCase); AddRecipient(message.From?.EmailAddress); AddRecipient(message.Sender?.EmailAddress); if (message.ToRecipients != null) { foreach (var recipient in message.ToRecipients) { AddRecipient(recipient?.EmailAddress); } } if (message.CcRecipients != null) { foreach (var recipient in message.CcRecipients) { AddRecipient(recipient?.EmailAddress); } } if (message.BccRecipients != null) { foreach (var recipient in message.BccRecipients) { AddRecipient(recipient?.EmailAddress); } } if (message.ReplyTo != null) { foreach (var recipient in message.ReplyTo) { AddRecipient(recipient?.EmailAddress); } } return contacts.Values.ToList(); void AddRecipient(EmailAddress emailAddress) { var address = emailAddress?.Address?.Trim(); if (string.IsNullOrWhiteSpace(address)) return; var displayName = string.IsNullOrWhiteSpace(emailAddress.Name) ? address : emailAddress.Name.Trim(); contacts[address] = new AccountContact { Address = address, Name = displayName }; } } private string GetDeltaTokenFromDeltaLink(string deltaLink) => Regex.Split(deltaLink, "deltatoken=")[1]; /// /// Determines MailItemType based on EventMessage's MeetingMessageType. /// private static MailItemType GetMailItemType(EventMessage eventMessage) { if (eventMessage.MeetingMessageType.HasValue) { return eventMessage.MeetingMessageType.Value switch { MeetingMessageType.MeetingRequest => MailItemType.CalendarInvitation, MeetingMessageType.MeetingCancelled => MailItemType.CalendarCancellation, MeetingMessageType.MeetingAccepted or MeetingMessageType.MeetingTenativelyAccepted or MeetingMessageType.MeetingDeclined => MailItemType.CalendarResponse, _ => MailItemType.Mail }; } // Fallback to CalendarInvitation if type is unknown return MailItemType.CalendarInvitation; } protected override async Task CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) { // Use centralized method return await CreateMailCopyFromMessageAsync(message, assignedFolder).ConfigureAwait(false); } private async Task GetMessageByIdAsync(string messageId, CancellationToken cancellationToken = default) { try { var message = await _graphClient.Me.Messages[messageId].GetAsync((config) => { config.QueryParameters.Select = outlookMessageSelectParameters; }, cancellationToken).ConfigureAwait(false); // Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted) if (Account.IsCalendarAccessGranted && message is EventMessage) { message = await FetchEventMessageAsync(message.Id, cancellationToken).ConfigureAwait(false); } return message; } catch (ServiceException serviceException) { // Try to handle the error using the error handling factory first var errorContext = new SynchronizerErrorContext { Account = Account, ErrorCode = (int?)serviceException.ResponseStatusCode, ErrorMessage = $"Service error during message retrieval: {serviceException.Message}", Exception = serviceException }; var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); if (!handled) { // No handler could process this error, log and handle appropriately if (serviceException.ResponseStatusCode == 404) { // Re-throw 404 errors to be handled by the caller for queue cleanup throw; } else { _logger.Error(serviceException, "Unhandled service error while getting message {MessageId}. Error: {ErrorCode}", messageId, serviceException.ResponseStatusCode); return null; } } else { _logger.Information("Service error handled successfully during message retrieval. Message: {MessageId}, Error: {ErrorCode}", messageId, serviceException.ResponseStatusCode); return null; // Return null since the error was handled but we couldn't get the message } } catch (Exception ex) { _logger.Error(ex, "Failed to get message {MessageId}", messageId); return null; } } private async Task ProcessDeltaChangesAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken = default) { // Only process delta changes if we have a delta token (not initial sync) if (string.IsNullOrEmpty(folder.DeltaToken)) return; try { var currentDeltaToken = folder.DeltaToken; // Always use Delta endpoint with proper configuration var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) => { config.QueryParameters.Select = outlookMessageSelectParameters; config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc }); requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken"); requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken); var messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation, DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken); // Use PageIterator for iterating mails var messageIterator = PageIterator .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 delta item {Id} for folder {FolderName}", item.Id, folder.FolderName); } finally { _handleItemRetrievalSemaphore.Release(); } return true; }); await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false); // Update delta token for next sync - store delta token when there are no nextPageToken remaining if (!string.IsNullOrEmpty(messageIterator.Deltalink)) { var deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink); await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false); _logger.Debug("Updated delta token for folder {FolderName} after processing delta changes", folder.FolderName); } } catch (ApiException apiException) { // Try to handle the error using the error handling factory var errorContext = new SynchronizerErrorContext { Account = Account, ErrorCode = (int?)apiException.ResponseStatusCode, ErrorMessage = $"API error during legacy delta sync: {apiException.Message}", Exception = apiException }; var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); if (!handled) { // No handler could process this error, log and re-throw _logger.Error(apiException, "Unhandled API error during legacy delta sync for folder {FolderName}. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode); } } } private bool IsResourceDeleted(IDictionary additionalData) => additionalData != null && additionalData.ContainsKey("@removed"); /// /// Fetches an EventMessage with full details including MeetingMessageType from the Messages endpoint. /// This is necessary because MeetingMessageType is not available when fetching as Message type. /// private async Task FetchEventMessageAsync(string messageId, CancellationToken cancellationToken) { try { var requestInfo = _graphClient.Me.Messages[messageId].ToGetRequestInformation((config) => { config.QueryParameters.Select = outlookMessageSelectParameters.Concat(["MeetingMessageType"]).ToArray(); }); var eventMessage = await _graphClient.Me.Messages[messageId].GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false); var odataType = eventMessage?.AdditionalData?.ContainsKey("@odata.type") == true ? eventMessage.AdditionalData["@odata.type"]?.ToString() : "unknown"; _logger.Debug("Fetched EventMessage {MessageId} with type {ODataType}", messageId, odataType); return eventMessage as EventMessage; } catch (Exception ex) { _logger.Error(ex, "Failed to fetch EventMessage {MessageId}", messageId); return null; } } private async Task HandleFolderRetrievedAsync(MailFolder folder, OutlookSpecialFolderIdInformation outlookSpecialFolderIdInformation, CancellationToken cancellationToken = default) { if (IsResourceDeleted(folder.AdditionalData)) { await _outlookChangeProcessor.DeleteFolderAsync(Account.Id, folder.Id).ConfigureAwait(false); _isFolderStructureChanged = true; } 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); _isFolderStructureChanged = true; } return true; } /// /// Somehow, Graph API returns Message type item for items like TodoTask, EventMessage and Contact. /// Basically deleted item retention items are stored as Message object in Deleted Items folder. /// Suprisingly, odatatype will also be the same as Message. /// In order to differentiate them from regular messages, we need to check the addresses in the message. /// EventMessage types (calendar invitations/responses) are now processed as regular mail items with appropriate ItemType. /// /// Retrieved message. /// Whether the item is non-Message type or not. private bool IsNotRealMessageType(Message item) => item.From?.EmailAddress == null; private async Task HandleItemRetrievedAsync(Message item, MailItemFolder folder, IList 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 { // Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted) if (Account.IsCalendarAccessGranted && item is EventMessage) { item = await FetchEventMessageAsync(item.Id, cancellationToken).ConfigureAwait(false); if (item == null) { return true; // Skip this message if fetch failed } } // If the item exists in the local database, it means that it's already downloaded. Process as an Update. var isMailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(item.Id, folder.Id); if (isMailExists) { // Some of the properties of the item are updated. _logger.Debug("Processing delta update for existing mail {MessageId} in folder {FolderName}", item.Id, folder.FolderName); if (item.IsRead != null) { _logger.Debug("Updating read status for mail {MessageId}: IsRead={IsRead}", item.Id, item.IsRead.GetValueOrDefault()); await _outlookChangeProcessor.ChangeMailReadStatusAsync(item.Id, item.IsRead.GetValueOrDefault()).ConfigureAwait(false); } if (item.Flag?.FlagStatus != null) { var isFlagged = item.Flag.FlagStatus.GetValueOrDefault() == FollowupFlagStatus.Flagged; _logger.Debug("Updating flag status for mail {MessageId}: IsFlagged={IsFlagged}", item.Id, isFlagged); await _outlookChangeProcessor.ChangeFlagStatusAsync(item.Id, isFlagged).ConfigureAwait(false); } } else { if (IsNotRealMessageType(item)) { // EventMessages are handled above if calendar access is granted // This catches non-message types like contacts or todo items Log.Warning("Received non-message item type (contact/todo). This is not supported yet. {Id}", item.Id); return true; } // 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) { _isFolderStructureChanged = false; var specialFolderInfo = await GetSpecialFolderIdsAsync(cancellationToken).ConfigureAwait(false); var graphFolders = await GetDeltaFoldersAsync(cancellationToken).ConfigureAwait(false); var iterator = PageIterator .CreatePageIterator(_graphClient, graphFolders, (folder) => HandleFolderRetrievedAsync(folder, specialFolderInfo, cancellationToken)); await iterator.IterateAsync(); await UpdateDeltaSynchronizationIdentifierAsync(iterator.Deltalink).ConfigureAwait(false); if (_isFolderStructureChanged) { WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id)); } } private async Task GetSpecialFolderIdsAsync(CancellationToken cancellationToken) { var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false); var cachedSpecialFolders = TryGetSpecialFolderIdsFromLocalFolders(localFolders); if (cachedSpecialFolders != null) { _logger.Debug("Using cached Outlook special folder ids for {AccountName}", Account.Name); return cachedSpecialFolders; } _logger.Information("Cached Outlook special folder ids are incomplete for {AccountName}. Fetching from Microsoft Graph.", Account.Name); return new OutlookSpecialFolderIdInformation( await GetWellKnownFolderIdAsync(INBOX_NAME, cancellationToken).ConfigureAwait(false), await GetWellKnownFolderIdAsync(DELETED_NAME, cancellationToken).ConfigureAwait(false), await GetWellKnownFolderIdAsync(JUNK_NAME, cancellationToken).ConfigureAwait(false), await GetWellKnownFolderIdAsync(DRAFTS_NAME, cancellationToken).ConfigureAwait(false), await GetWellKnownFolderIdAsync(SENT_NAME, cancellationToken).ConfigureAwait(false), await GetWellKnownFolderIdAsync(ARCHIVE_NAME, cancellationToken).ConfigureAwait(false)); } private async Task GetWellKnownFolderIdAsync(string wellKnownFolderName, CancellationToken cancellationToken) { try { var folder = await _graphClient.Me.MailFolders[wellKnownFolderName] .GetAsync(requestConfiguration => { requestConfiguration.QueryParameters.Select = ["id"]; }, cancellationToken: cancellationToken) .ConfigureAwait(false); if (string.IsNullOrWhiteSpace(folder?.Id)) { throw new SynchronizerException($"Outlook special folder '{wellKnownFolderName}' returned no id."); } return folder.Id; } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.Warning(ex, "Failed to fetch Outlook special folder id for {FolderName}", wellKnownFolderName); throw; } } private static OutlookSpecialFolderIdInformation TryGetSpecialFolderIdsFromLocalFolders(IEnumerable localFolders) { if (localFolders == null) { return null; } var inboxId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Inbox); var deletedId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Deleted); var junkId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Junk); var draftId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Draft); var sentId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Sent); var archiveId = GetSpecialFolderRemoteId(localFolders, SpecialFolderType.Archive); if (new[] { inboxId, deletedId, junkId, draftId, sentId, archiveId }.Any(string.IsNullOrWhiteSpace)) { return null; } return new OutlookSpecialFolderIdInformation(inboxId, deletedId, junkId, draftId, sentId, archiveId); } private static string GetSpecialFolderRemoteId(IEnumerable localFolders, SpecialFolderType specialFolderType) => localFolders.FirstOrDefault(folder => folder.SpecialFolderType == specialFolderType && !string.IsNullOrWhiteSpace(folder.RemoteFolderId))?.RemoteFolderId; private async Task GetDeltaFoldersAsync(CancellationToken cancellationToken) { if (string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier)) { var deltaRequest = _graphClient.Me.MailFolders.Delta.ToGetRequestInformation(); deltaRequest.UrlTemplate = deltaRequest.UrlTemplate.Insert(deltaRequest.UrlTemplate.Length - 1, ",includehiddenfolders"); deltaRequest.QueryParameters.Add("includehiddenfolders", "true"); return await _graphClient.RequestAdapter.SendAsync(deltaRequest, Microsoft.Graph.Me.MailFolders.Delta.DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken).ConfigureAwait(false); } try { var deltaRequest = _graphClient.Me.MailFolders.Delta.ToGetRequestInformation(); deltaRequest.UrlTemplate = deltaRequest.UrlTemplate.Insert(deltaRequest.UrlTemplate.Length - 1, ",%24deltaToken"); deltaRequest.QueryParameters.Add("%24deltaToken", Account.SynchronizationDeltaIdentifier); return await _graphClient.RequestAdapter.SendAsync(deltaRequest, Microsoft.Graph.Me.MailFolders.Delta.DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken).ConfigureAwait(false); } catch (ApiException apiException) { // Try to handle the error using the error handling factory var errorContext = new SynchronizerErrorContext { Account = Account, ErrorCode = (int?)apiException.ResponseStatusCode, ErrorMessage = $"API error during folder synchronization: {apiException.Message}", Exception = apiException }; var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); if (handled) { // The error handler has processed the error (e.g., DeltaTokenExpiredHandler for 410) // Update in-memory account state if it was a delta token expiration if (apiException.ResponseStatusCode == 410) { Account.SynchronizationDeltaIdentifier = string.Empty; _logger.Information("API error handled successfully for account {AccountName} during folder sync. Error: {ErrorCode}", Account.Name, apiException.ResponseStatusCode); } } else { // No handler could process this error, log and re-throw _logger.Error(apiException, "Unhandled API error during folder synchronization for account {AccountName}. Error: {ErrorCode}", Account.Name, apiException.ResponseStatusCode); throw; } // If a handler processed the error and it was 410, retry with fresh token if (apiException.ResponseStatusCode == 410) { return await GetDeltaFoldersAsync(cancellationToken); } // For other handled errors, we still need to throw since we can't return a meaningful response throw; } } private async Task UpdateDeltaSynchronizationIdentifierAsync(string deltalink) { if (string.IsNullOrEmpty(deltalink)) return; var deltaToken = deltalink.Split('=')[1]; var latestAccountDeltaToken = await _outlookChangeProcessor .UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, deltaToken); if (!string.IsNullOrEmpty(latestAccountDeltaToken)) { Account.SynchronizationDeltaIdentifier = latestAccountDeltaToken; } } /// /// Get the user's profile picture /// /// Base64 encoded profile picture. private async Task GetUserProfilePictureAsync() { try { var photoStream = await _graphClient.Me.Photos["48x48"].Content.GetAsync(); using var memoryStream = new MemoryStream(); await photoStream.CopyToAsync(memoryStream); var byteArray = memoryStream.ToArray(); return Convert.ToBase64String(byteArray); } catch (ODataError odataError) when (odataError.Error.Code == "ImageNotFound") { // Accounts without profile picture will throw this error. // At this point nothing we can do. Just return empty string. return string.Empty; } catch (Exception) { // Don't throw for profile picture. // Office 365 apps require different permissions for profile picture. // This permission requires admin consent. // We avoid those permissions for now. return string.Empty; } } /// /// Get the user's display name. /// /// Display name and address of the user. private async Task> GetDisplayNameAndAddressAsync() { var userInfo = await _graphClient.Me.GetAsync(); return new Tuple(userInfo.DisplayName, userInfo.Mail); } public override async Task GetProfileInformationAsync() { var profilePictureData = await GetUserProfilePictureAsync().ConfigureAwait(false); var displayNameAndAddress = await GetDisplayNameAndAddressAsync().ConfigureAwait(false); return new ProfileInformation(displayNameAndAddress.Item1, profilePictureData, displayNameAndAddress.Item2); } /// /// POST requests are handled differently in batches in Graph SDK. /// Batch basically ignores the step's coontent-type and body. /// Manually create a POST request with empty body and send it. /// /// Post request information. /// Content object to serialize. /// Updated post request information. private RequestInformation PreparePostRequestInformation(RequestInformation requestInformation, Microsoft.Graph.Me.Messages.Item.Move.MovePostRequestBody content = null) { requestInformation.Headers.Clear(); string contentJson = content == null ? "{}" : JsonSerializer.Serialize(content, OutlookSynchronizerJsonContext.Default.MovePostRequestBody); requestInformation.Content = new MemoryStream(Encoding.UTF8.GetBytes(contentJson)); requestInformation.HttpMethod = Method.POST; requestInformation.Headers.Add("Content-Type", "application/json"); return requestInformation; } #region Mail Integration public override bool DelaySendOperationSynchronization() => true; public override List> Move(BatchMoveRequest request) { return ForEachRequest(request, (item) => { var requestBody = new Microsoft.Graph.Me.Messages.Item.Move.MovePostRequestBody() { DestinationId = item.ToFolder.RemoteFolderId }; return PreparePostRequestInformation(_graphClient.Me.Messages[item.Item.Id].Move.ToPostRequestInformation(requestBody), requestBody); }); } public override List> ChangeFlag(BatchChangeFlagRequest request) { return ForEachRequest(request, (item) => { var message = new Message() { Flag = new FollowupFlag() { FlagStatus = item.IsFlagged ? FollowupFlagStatus.Flagged : FollowupFlagStatus.NotFlagged } }; return _graphClient.Me.Messages[item.Item.Id].ToPatchRequestInformation(message); }); } public override List> MarkRead(BatchMarkReadRequest request) { return ForEachRequest(request, (item) => { var message = new Message() { IsRead = item.IsRead }; return _graphClient.Me.Messages[item.Item.Id].ToPatchRequestInformation(message); }); } public override List> Delete(BatchDeleteRequest request) { return ForEachRequest(request, (item) => { return _graphClient.Me.Messages[item.Item.Id].ToDeleteRequestInformation(); }); } public override List> MoveToFocused(BatchMoveToFocusedRequest request) { return ForEachRequest(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 List> AlwaysMoveTo(BatchAlwaysMoveToRequest request) { return ForEachRequest(request, (item) => { var inferenceClassificationOverride = new InferenceClassificationOverride { ClassifyAs = item.MoveToFocused ? InferenceClassificationType.Focused : InferenceClassificationType.Other, SenderEmailAddress = new EmailAddress { Name = item.Item.FromName, Address = item.Item.FromAddress } }; return _graphClient.Me.InferenceClassification.Overrides.ToPostRequestInformation(inferenceClassificationOverride); }); } public override List> CreateDraft(CreateDraftRequest createDraftRequest) { var reason = createDraftRequest.DraftPreperationRequest.Reason; var message = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.AsOutlookMessage(true); if (reason == DraftCreationReason.Empty) { return [new HttpRequestBundle(_graphClient.Me.Messages.ToPostRequestInformation(message), createDraftRequest)]; } else if (reason == DraftCreationReason.Reply) { return [new HttpRequestBundle(_graphClient.Me.Messages[createDraftRequest.DraftPreperationRequest.ReferenceMailCopy.Id].CreateReply.ToPostRequestInformation(new Microsoft.Graph.Me.Messages.Item.CreateReply.CreateReplyPostRequestBody() { Message = message }), createDraftRequest)]; } else if (reason == DraftCreationReason.ReplyAll) { return [new HttpRequestBundle(_graphClient.Me.Messages[createDraftRequest.DraftPreperationRequest.ReferenceMailCopy.Id].CreateReplyAll.ToPostRequestInformation(new Microsoft.Graph.Me.Messages.Item.CreateReplyAll.CreateReplyAllPostRequestBody() { Message = message }), createDraftRequest)]; } else if (reason == DraftCreationReason.Forward) { return [new HttpRequestBundle( _graphClient.Me.Messages[createDraftRequest.DraftPreperationRequest.ReferenceMailCopy.Id].CreateForward.ToPostRequestInformation(new Microsoft.Graph.Me.Messages.Item.CreateForward.CreateForwardPostRequestBody() { Message = message }), createDraftRequest)]; } else { throw new NotImplementedException("Draft creation reason is not implemented."); } } public override List> SendDraft(SendDraftRequest request) { var sendDraftPreparationRequest = request.Request; var mailCopyId = sendDraftPreparationRequest.MailItem.Id; var mimeMessage = sendDraftPreparationRequest.Mime; // Graph API ignores the From header in direct MIME uploads, so we must convert // to a JSON Message object to properly support sending from aliases. var conversationId = sendDraftPreparationRequest.MailItem.ThreadId; var outlookMessage = mimeMessage.AsOutlookMessage(false, conversationId); var patchDraftRequest = _graphClient.Me.Messages[mailCopyId].ToPatchRequestInformation(outlookMessage); var patchDraftBundle = new HttpRequestBundle(patchDraftRequest, request); var sendRequest = PreparePostRequestInformation(_graphClient.Me.Messages[mailCopyId].Send.ToPostRequestInformation()); var sendBundle = new HttpRequestBundle(sendRequest, request); // Attachment uploads are handled outside batching because large attachments // require upload sessions whose URLs are generated dynamically. return [patchDraftBundle, sendBundle]; } private async Task UploadDraftAttachmentsAsync(SendDraftRequest sendDraftRequest, CancellationToken cancellationToken) { var mailCopyId = sendDraftRequest.Request.MailItem.Id; var attachments = sendDraftRequest.Request.Mime.ExtractAttachments(); if (!attachments.Any()) { return; } foreach (var attachment in attachments) { cancellationToken.ThrowIfCancellationRequested(); var contentBytes = attachment.ContentBytes ?? []; if (contentBytes.Length <= SimpleAttachmentUploadLimitBytes) { await _graphClient.Me.Messages[mailCopyId].Attachments.PostAsync(attachment, cancellationToken: cancellationToken).ConfigureAwait(false); continue; } if (contentBytes.Length > MaximumUploadSessionAttachmentSizeBytes) { var attachmentSizeMb = contentBytes.LongLength / (1024d * 1024d); var maximumSizeMb = MaximumUploadSessionAttachmentSizeBytes / (1024d * 1024d); throw new InvalidOperationException( $"Attachment '{attachment.Name}' is {attachmentSizeMb:F1} MB, which exceeds Outlook's upload limit of {maximumSizeMb:F0} MB per attachment."); } var sessionBody = new Microsoft.Graph.Me.Messages.Item.Attachments.CreateUploadSession.CreateUploadSessionPostRequestBody { AttachmentItem = new AttachmentItem { AttachmentType = AttachmentType.File, ContentType = attachment.ContentType, Name = attachment.Name, Size = contentBytes.LongLength } }; var uploadSession = await _graphClient.Me.Messages[mailCopyId].Attachments.CreateUploadSession.PostAsync(sessionBody, cancellationToken: cancellationToken).ConfigureAwait(false); if (uploadSession?.UploadUrl == null) { throw new InvalidOperationException($"Failed to create upload session for attachment '{attachment.Name}'."); } await UploadAttachmentInChunksAsync(uploadSession.UploadUrl, contentBytes, cancellationToken).ConfigureAwait(false); } } private static async Task UploadAttachmentInChunksAsync(string uploadUrl, byte[] content, CancellationToken cancellationToken) { using var client = new HttpClient(); var totalSize = content.Length; var offset = 0; while (offset < totalSize) { cancellationToken.ThrowIfCancellationRequested(); var chunkLength = Math.Min(LargeAttachmentUploadChunkSizeBytes, totalSize - offset); var end = offset + chunkLength - 1; using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) { Content = new ByteArrayContent(content, offset, chunkLength) }; request.Content.Headers.Add("Content-Range", $"bytes {offset}-{end}/{totalSize}"); using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); // Upload session returns either 202 (continue) or 201/200 (completed). if (!response.IsSuccessStatusCode) { var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Attachment chunk upload failed with status {(int)response.StatusCode}: {responseContent}"); } offset += chunkLength; } } public override List> Archive(BatchArchiveRequest request) { var batchMoveRequest = new BatchMoveRequest(request.Select(item => new MoveRequest(item.Item, item.FromFolder, item.ToFolder))); return Move(batchMoveRequest); } public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem, MailKit.ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) { try { var mimeMessage = await DownloadMimeMessageAsync(mailItem.Id, cancellationToken).ConfigureAwait(false); await _outlookChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false); } catch (ODataError ex) when (ex.ResponseStatusCode == 404) { _logger.Warning("Outlook message {MailId} not found (404) during MIME download. Deleting locally.", mailItem.Id); await _outlookChangeProcessor.DeleteMailAsync(Account.Id, mailItem.Id).ConfigureAwait(false); throw new SynchronizerEntityNotFoundException(ex.Message); } } public override async Task DownloadCalendarAttachmentAsync( Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem, Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment, string localFilePath, CancellationToken cancellationToken = default) { try { var calendar = calendarItem.AssignedCalendar; var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId(); // First, get the attachment metadata to retrieve contentBytes for FileAttachment var attachmentItem = await _graphClient.Me .Calendars[calendar.RemoteCalendarId] .Events[remoteEventId] .Attachments[attachment.RemoteAttachmentId] .GetAsync(cancellationToken: cancellationToken) .ConfigureAwait(false); if (attachmentItem == null) { _logger.Error("Failed to retrieve attachment {AttachmentId} for event {EventId}", attachment.RemoteAttachmentId, calendarItem.RemoteEventId); throw new InvalidOperationException("Failed to retrieve attachment."); } byte[] contentBytes = null; // Handle FileAttachment (has ContentBytes property) if (attachmentItem is FileAttachment fileAttachment && fileAttachment.ContentBytes != null) { contentBytes = fileAttachment.ContentBytes; } // Handle ItemAttachment (embedded items like emails) else if (attachmentItem is ItemAttachment) { _logger.Warning("ItemAttachment type not supported for download. AttachmentId: {AttachmentId}", attachment.RemoteAttachmentId); throw new NotSupportedException("ItemAttachment downloads are not currently supported."); } else { _logger.Error("Unknown attachment type or missing content for {AttachmentId}", attachment.RemoteAttachmentId); throw new InvalidOperationException("Attachment content is not available."); } // Save to local file await System.IO.File.WriteAllBytesAsync(localFilePath, contentBytes, cancellationToken).ConfigureAwait(false); _logger.Information("Downloaded calendar attachment {FileName} to {LocalPath}", attachment.FileName, localFilePath); } catch (Exception ex) { _logger.Error(ex, "Error downloading calendar attachment {AttachmentId}", attachment.Id); throw; } } public override List> RenameFolder(RenameFolderRequest request) { var requestBody = new MailFolder { DisplayName = request.NewFolderName, }; var networkCall = _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ToPatchRequestInformation(requestBody); return [new HttpRequestBundle(networkCall, request)]; } public override List> EmptyFolder(EmptyFolderRequest request) => Delete(new BatchDeleteRequest(request.MailsToDelete.Select(a => new DeleteRequest(a)))); public override List> MarkFolderAsRead(MarkFolderAsReadRequest request) => MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true)))); public override List> DeleteFolder(DeleteFolderRequest request) { var networkCall = _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ToDeleteRequestInformation(); return [new HttpRequestBundle(networkCall, request)]; } public override List> CreateSubFolder(CreateSubFolderRequest request) { var requestBody = new MailFolder { DisplayName = request.NewFolderName }; var networkCall = _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ChildFolders.ToPostRequestInformation(requestBody); return [new HttpRequestBundle(networkCall, request)]; } #endregion public override async Task ExecuteNativeRequestsAsync(List> batchedRequests, CancellationToken cancellationToken = default) { // First apply all UI changes immediately before any batching. // This ensures UI reflects changes right away, regardless of batch processing. foreach (var bundle in batchedRequests) { bundle.UIChangeRequest?.ApplyUIChanges(); } // SendDraft requests may include large attachments, which require upload sessions. // Upload these attachments before the batched patch/send sequence. foreach (var sendDraftBundle in batchedRequests.Where(b => b.UIChangeRequest is SendDraftRequest)) { var sendDraftRequest = sendDraftBundle.UIChangeRequest as SendDraftRequest; try { await UploadDraftAttachmentsAsync(sendDraftRequest, cancellationToken).ConfigureAwait(false); } catch { sendDraftRequest?.RevertUIChanges(); throw; } } // Now batch and execute the network requests. var batchedGroups = batchedRequests.Batch((int)MaximumAllowedBatchRequestSize); foreach (var batch in batchedGroups) { await ExecuteBatchRequestsAsync(batch, cancellationToken); } } private async Task ExecuteBatchRequestsAsync(IEnumerable> batch, CancellationToken cancellationToken) { var batchContent = new BatchRequestContentCollection(_graphClient); var itemCount = batch.Count(); if (itemCount == 0) return; var bundleIdMap = await PrepareBatchContentAsync(batch, batchContent, itemCount); // Execute batch to collect responses from network call var batchRequestResponse = await _graphClient.Batch.PostAsync(batchContent, cancellationToken); await ProcessBatchResponsesAsync(batch, batchRequestResponse, bundleIdMap); } private async Task>> PrepareBatchContentAsync( IEnumerable> batch, BatchRequestContentCollection batchContent, int itemCount) { var bundleIdMap = new Dictionary>(); bool requiresSerial = false; for (int i = 0; i < itemCount; i++) { var bundle = batch.ElementAt(i); requiresSerial |= bundle.UIChangeRequest is SendDraftRequest; // UI changes are already applied in ExecuteNativeRequestsAsync before batching. var batchRequestId = await batchContent.AddBatchRequestStepAsync(bundle.NativeRequest); bundle.BundleId = batchRequestId; bundleIdMap[batchRequestId] = bundle; } if (requiresSerial) { ConfigureSerialExecution(batchContent); } return bundleIdMap; } private void ConfigureSerialExecution(BatchRequestContentCollection batchContent) { // Set each step to depend on previous one for serial execution var steps = batchContent.BatchRequestSteps.ToList(); for (int i = 1; i < steps.Count; i++) { var currentStep = steps[i].Value; var previousStepKey = steps[i - 1].Key; currentStep.DependsOn = [previousStepKey]; } } private async Task ProcessBatchResponsesAsync( IEnumerable> batch, BatchResponseContentCollection batchResponse, Dictionary> bundleIdMap) { var errors = new List(); foreach (var bundleId in bundleIdMap.Keys) { var bundle = bundleIdMap[bundleId]; var response = await batchResponse.GetResponseByIdAsync(bundleId); if (response == null) continue; using (response) { if (!response.IsSuccessStatusCode) { await HandleFailedResponseAsync(bundle, response, errors); } else { await HandleSuccessfulResponseAsync(bundle, response).ConfigureAwait(false); } } } if (errors.Any()) { ThrowBatchExecutionException(errors); } } private async Task HandleFailedResponseAsync( IRequestBundle bundle, HttpResponseMessage response, List errors) { var content = await response.Content.ReadAsStringAsync(); var errorJson = JsonNode.Parse(content); var errorCode = errorJson["error"]["code"].GetValue(); var errorMessage = errorJson["error"]["message"].GetValue(); var errorString = $"[{response.StatusCode}] {errorCode} - {errorMessage}\n"; // Create error context var errorContext = new SynchronizerErrorContext { Account = Account, ErrorCode = (int)response.StatusCode, ErrorMessage = errorMessage, RequestBundle = bundle, Request = bundle.Request, IsEntityNotFound = IsKnownOutlookEntityNotFoundError(response.StatusCode, errorCode, errorMessage, bundle), AdditionalData = new Dictionary { { "ErrorCode", errorCode }, { "HttpResponse", response }, { "Content", content } } }; // Try to handle the error with registered handlers var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext); // Transient errors still need to bubble so the request can be retried or surfaced to the caller. if (!handled || errorContext.Severity == SynchronizerErrorSeverity.Transient) { CaptureSynchronizationIssue(errorContext); bundle.UIChangeRequest?.RevertUIChanges(); Debug.WriteLine(errorString); errors.Add(errorString); } } private static bool IsKnownOutlookEntityNotFoundError( HttpStatusCode statusCode, string errorCode, string errorMessage, IRequestBundle bundle) { if (statusCode != HttpStatusCode.NotFound || bundle?.UIChangeRequest == null) return false; if (!IsExistingEntityOperation(bundle.UIChangeRequest)) return false; var normalizedErrorCode = errorCode?.Trim().ToLowerInvariant() ?? string.Empty; var normalizedMessage = errorMessage?.Trim().ToLowerInvariant() ?? string.Empty; return normalizedErrorCode.Contains("notfound") || normalizedErrorCode.Contains("itemnotfound") || normalizedErrorCode.Contains("resource") || normalizedMessage.Contains("not found") || normalizedMessage.Contains("does not exist") || normalizedMessage.Contains("cannot be found"); } private static bool IsExistingEntityOperation(IUIChangeRequest request) => request is BatchDeleteRequest || request is BatchMoveRequest || request is BatchChangeFlagRequest || request is BatchMarkReadRequest || request is BatchArchiveRequest || request is DeleteRequest || request is MoveRequest || request is ChangeFlagRequest || request is MarkReadRequest || request is ArchiveRequest || request is RenameFolderRequest || request is DeleteFolderRequest || request is AcceptEventRequest || request is DeclineEventRequest || request is OutlookDeclineEventRequest || request is TentativeEventRequest || request is UpdateCalendarEventRequest || request is DeleteCalendarEventRequest; private async Task HandleSuccessfulResponseAsync(IRequestBundle bundle, HttpResponseMessage response) { try { if (bundle?.UIChangeRequest is MarkReadRequest markReadRequest) { await _outlookChangeProcessor.ChangeMailReadStatusAsync(markReadRequest.Item.Id, markReadRequest.IsRead).ConfigureAwait(false); return; } if (bundle?.UIChangeRequest is ChangeFlagRequest changeFlagRequest) { await _outlookChangeProcessor.ChangeFlagStatusAsync(changeFlagRequest.Item.Id, changeFlagRequest.IsFlagged).ConfigureAwait(false); return; } var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(content)) return; var json = JsonNode.Parse(content); if (bundle?.UIChangeRequest is CreateDraftRequest createDraftRequest) { var createdDraftId = json?["id"]?.GetValue(); if (string.IsNullOrWhiteSpace(createdDraftId)) return; var createdConversationId = json?["conversationId"]?.GetValue(); var localDraft = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftCopy; await _outlookChangeProcessor.MapLocalDraftAsync( Account.Id, localDraft.UniqueId, createdDraftId, createdConversationId, createdConversationId).ConfigureAwait(false); return; } if (bundle?.UIChangeRequest is CreateCalendarEventRequest createCalendarEventRequest) { var createdEventId = json?["id"]?.GetValue(); if (string.IsNullOrWhiteSpace(createdEventId)) return; await UploadCalendarEventAttachmentsAsync(createCalendarEventRequest, createdEventId, CancellationToken.None).ConfigureAwait(false); } } catch (Exception ex) { _logger.Debug(ex, "Failed to process Outlook create response."); } } private async Task UploadCalendarEventAttachmentsAsync(CreateCalendarEventRequest request, string remoteEventId, CancellationToken cancellationToken) { var attachments = request.ComposeResult.Attachments ?? []; if (attachments.Count == 0) return; var remoteCalendarId = request.AssignedCalendar.RemoteCalendarId; foreach (var attachment in attachments.Where(a => !string.IsNullOrWhiteSpace(a.FilePath) && File.Exists(a.FilePath))) { cancellationToken.ThrowIfCancellationRequested(); var contentBytes = await File.ReadAllBytesAsync(attachment.FilePath, cancellationToken).ConfigureAwait(false); var contentType = MimeTypes.GetMimeType(attachment.FileName ?? attachment.FilePath); var fileAttachment = new FileAttachment { Name = attachment.FileName, ContentType = contentType, ContentBytes = contentBytes }; if (contentBytes.Length <= SimpleAttachmentUploadLimitBytes) { await _graphClient.Me.Calendars[remoteCalendarId].Events[remoteEventId].Attachments.PostAsync(fileAttachment, cancellationToken: cancellationToken).ConfigureAwait(false); continue; } if (contentBytes.Length > MaximumUploadSessionAttachmentSizeBytes) { var attachmentSizeMb = contentBytes.LongLength / (1024d * 1024d); var maximumSizeMb = MaximumUploadSessionAttachmentSizeBytes / (1024d * 1024d); throw new InvalidOperationException( $"Attachment '{attachment.FileName}' is {attachmentSizeMb:F1} MB, which exceeds Outlook's upload limit of {maximumSizeMb:F0} MB per attachment."); } var sessionBody = new Microsoft.Graph.Me.Calendars.Item.Events.Item.Attachments.CreateUploadSession.CreateUploadSessionPostRequestBody { AttachmentItem = new AttachmentItem { AttachmentType = AttachmentType.File, ContentType = contentType, Name = attachment.FileName, Size = contentBytes.LongLength } }; var uploadSession = await _graphClient.Me.Calendars[remoteCalendarId].Events[remoteEventId].Attachments.CreateUploadSession.PostAsync(sessionBody, cancellationToken: cancellationToken).ConfigureAwait(false); if (uploadSession?.UploadUrl == null) { throw new InvalidOperationException($"Failed to create upload session for attachment '{attachment.FileName}'."); } await UploadAttachmentInChunksAsync(uploadSession.UploadUrl, contentBytes, cancellationToken).ConfigureAwait(false); } } private void ThrowBatchExecutionException(List errors) { var formattedErrorString = string.Join("\n", errors.Select((item, index) => $"{index + 1}. {item}")); throw new SynchronizerException(formattedErrorString); } public override async Task> OnlineSearchAsync(string queryText, List folders, CancellationToken cancellationToken = default) { var messagesById = new Dictionary(StringComparer.Ordinal); // Perform search for each folder separately. if (folders?.Count > 0) { var folderIds = folders .Where(a => a != null && !string.IsNullOrWhiteSpace(a.RemoteFolderId)) .Select(a => a.RemoteFolderId) .Distinct(StringComparer.Ordinal) .ToList(); var tasks = folderIds.Select(async folderId => { var mailQuery = _graphClient.Me.MailFolders[folderId].Messages .GetAsync(requestConfig => { requestConfig.QueryParameters.Search = $"\"{queryText}\""; requestConfig.QueryParameters.Select = ["Id, ParentFolderId"]; requestConfig.QueryParameters.Top = 1000; }, cancellationToken); var result = await mailQuery; if (result?.Value != null) { lock (messagesById) { foreach (var message in result.Value) { if (string.IsNullOrWhiteSpace(message?.Id)) continue; messagesById[message.Id] = message; } } } }); await Task.WhenAll(tasks); } else { // Perform search for all messages without folder data. var mailQuery = _graphClient.Me.Messages .GetAsync(requestConfig => { requestConfig.QueryParameters.Search = $"\"{queryText}\""; requestConfig.QueryParameters.Select = ["Id, ParentFolderId"]; requestConfig.QueryParameters.Top = 1000; }, cancellationToken); var result = await mailQuery; if (result?.Value != null) { foreach (var message in result.Value) { if (string.IsNullOrWhiteSpace(message?.Id)) continue; messagesById[message.Id] = message; } } } if (messagesById.Count == 0) return []; var localFolders = (await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false)) .ToDictionary(x => x.RemoteFolderId); // Contains a list of message ids that potentially can be downloaded. var messageIdsWithKnownFolder = new HashSet(StringComparer.Ordinal); // Validate that all messages are in a known folder. foreach (var message in messagesById.Values) { if (!localFolders.ContainsKey(message.ParentFolderId)) { Log.Warning("Search result returned a message from a folder that is not synchronized."); continue; } messageIdsWithKnownFolder.Add(message.Id); } if (messageIdsWithKnownFolder.Count == 0) return []; var locallyExistingMails = await _outlookChangeProcessor.AreMailsExistsAsync(messageIdsWithKnownFolder).ConfigureAwait(false); // Find messages that are not downloaded yet. List messagesToDownload = []; foreach (var id in messageIdsWithKnownFolder.Except(locallyExistingMails, StringComparer.Ordinal)) { if (messagesById.TryGetValue(id, out var message)) { messagesToDownload.Add(message); } } foreach (var message in messagesToDownload) { await DownloadSearchResultMessageAsync(message.Id, localFolders[message.ParentFolderId], cancellationToken).ConfigureAwait(false); } // Get results from database and return. return await _outlookChangeProcessor.GetMailCopiesAsync(messageIdsWithKnownFolder).ConfigureAwait(false); } private async Task DownloadMimeMessageAsync(string messageId, CancellationToken cancellationToken = default) { var mimeContentStream = await _graphClient.Me.Messages[messageId].Content.GetAsync(null, cancellationToken).ConfigureAwait(false); return await MimeMessage.LoadAsync(mimeContentStream, cancellationToken).ConfigureAwait(false); } public override async Task> CreateNewMailPackagesAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) { // Download MIME message for specific scenarios (e.g., search results, draft handling) // During normal sync, this method should not be called - use CreateMailCopyFromMessageAsync instead var mimeMessage = await DownloadMimeMessageAsync(message.Id, cancellationToken).ConfigureAwait(false); var mailCopy = await CreateMailCopyFromMessageAsync(message, assignedFolder).ConfigureAwait(false); // If draft mapping was successful, mailCopy will be null if (mailCopy == null) return null; await TryMapCalendarInvitationAsync(mailCopy, mimeMessage, cancellationToken).ConfigureAwait(false); // 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 contacts = ExtractContactsFromOutlookMessage(message); var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId, contacts); return [package]; } private async Task TryMapCalendarInvitationAsync(MailCopy mailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken) { if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null) return; var invitationUid = mimeMessage.ExtractInvitationUid(); if (string.IsNullOrWhiteSpace(invitationUid)) return; var calendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false); if (calendars == null || calendars.Count == 0) return; string escapedUid = invitationUid.Replace("'", "''", StringComparison.Ordinal); foreach (var calendar in calendars) { try { var eventsResponse = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events .GetAsync(requestConfiguration => { requestConfiguration.QueryParameters.Filter = $"iCalUId eq '{escapedUid}'"; requestConfiguration.QueryParameters.Select = ["id"]; requestConfiguration.QueryParameters.Top = 1; }, cancellationToken: cancellationToken) .ConfigureAwait(false); var matchedEvent = eventsResponse?.Value?.FirstOrDefault(); if (matchedEvent == null || string.IsNullOrWhiteSpace(matchedEvent.Id)) continue; var fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[matchedEvent.Id] .GetAsync(requestConfiguration => { requestConfiguration.QueryParameters.Expand = ["attachments($select=id,name,contentType,size,isInline)"]; }, cancellationToken: cancellationToken) .ConfigureAwait(false); if (fullEvent == null) continue; await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false); var localCalendarItem = await _outlookChangeProcessor.GetCalendarItemAsync(calendar.Id, fullEvent.Id).ConfigureAwait(false); if (localCalendarItem == null) return; await _outlookChangeProcessor.UpsertMailInvitationCalendarMappingAsync(new MailInvitationCalendarMapping() { Id = Guid.NewGuid(), AccountId = Account.Id, MailCopyId = mailCopy.Id, InvitationUid = invitationUid, CalendarId = calendar.Id, CalendarItemId = localCalendarItem.Id, CalendarRemoteEventId = fullEvent.Id }).ConfigureAwait(false); return; } catch (Exception ex) { _logger.Warning(ex, "Failed to map Outlook calendar invitation mail {MailCopyId} for calendar {CalendarId}", mailCopy.Id, calendar.Id); } } } protected override async Task SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) { _logger.Information("Internal calendar synchronization started for {Name}", Account.Name); cancellationToken.ThrowIfCancellationRequested(); await SynchronizeCalendarsAsync(cancellationToken).ConfigureAwait(false); if (options?.Type == CalendarSynchronizationType.CalendarMetadata) return CalendarSynchronizationResult.Empty; var localCalendars = (await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false)) .Where(c => c.IsSynchronizationEnabled) .ToList(); Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse eventsDeltaResponse = null; // TODO: Maybe we can batch each calendar? foreach (var calendar in localCalendars) { try { bool isInitialSync = string.IsNullOrEmpty(calendar.SynchronizationDeltaToken); if (isInitialSync) { _logger.Information("No calendar sync identifier for calendar {Name}. Performing initial sync.", calendar.Name); // ISO 8601 format as expected by Microsoft Graph API (e.g., "2019-11-08T19:00:00-08:00") var startDate = DateTimeOffset.Now.AddYears(-2).ToString("yyyy-MM-ddTHH:mm:sszzz"); var endDate = DateTimeOffset.Now.AddYears(2).ToString("yyyy-MM-ddTHH:mm:sszzz"); // Get Id only. We will always download the full event. eventsDeltaResponse = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.GetAsDeltaGetResponseAsync((requestConfiguration) => { requestConfiguration.QueryParameters.Select = ["id", "type"]; requestConfiguration.QueryParameters.StartDateTime = startDate; requestConfiguration.QueryParameters.EndDateTime = endDate; }, cancellationToken: cancellationToken); } else { var currentDeltaToken = calendar.SynchronizationDeltaToken; _logger.Information("Performing delta sync for calendar {Name}.", calendar.Name); var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation(); requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken"); requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken); eventsDeltaResponse = await _graphClient.RequestAdapter.SendAsync(requestInformation, Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse.CreateFromDiscriminatorValue); } List events = new(); // We must first save the parent recurring events to not lose exceptions. // Therefore, order the existing items by their type and save the parent recurring events first. var messageIteratorAsync = PageIterator.CreatePageIterator(_graphClient, eventsDeltaResponse, (item) => { // Include all event types: SingleInstance, SeriesMaster, Occurrence, and Exception // CalendarView already expands recurring events into individual occurrences events.Add(item); return true; }); await messageIteratorAsync .IterateAsync(cancellationToken) .ConfigureAwait(false); // Desc-order will move parent recurring events to the top. events = events.OrderByDescending(a => a.Type).ToList(); _logger.Information("Found {Count} events in total.", events.Count); foreach (var item in events) { // Declined events are returned as Deleted from the API. // There is no way to distinguish unfortunately atm. if (IsResourceDeleted(item.AdditionalData)) { await _outlookChangeProcessor.DeleteCalendarItemAsync(item.Id, calendar.Id).ConfigureAwait(false); continue; } try { await _handleCalendarEventRetrievalSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); Event fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[item.Id] .GetAsync(requestConfiguration => { // Expand attachments but only get metadata, not the full content requestConfiguration.QueryParameters.Expand = new[] { "attachments($select=id,name,contentType,size,isInline)" }; }, cancellationToken: cancellationToken).ConfigureAwait(false); await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { var errorContext = new SynchronizerErrorContext { Account = Account, ErrorMessage = ex.Message, Exception = ex, CalendarId = calendar.Id, CalendarName = calendar.Name, OperationType = "CalendarEventSync", Severity = SynchronizerErrorSeverity.Recoverable }; _ = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); CaptureSynchronizationIssue(errorContext); _logger.Error(ex, "Error occurred while handling item {Id} for calendar {Name}", item.Id, calendar.Name); } finally { _handleCalendarEventRetrievalSemaphore.Release(); } } var latestDeltaLink = messageIteratorAsync.Deltalink; //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.UpdateCalendarDeltaSynchronizationToken(calendar.Id, deltaToken).ConfigureAwait(false); } } catch (OperationCanceledException) { throw; } catch (Exception ex) { var errorContext = new SynchronizerErrorContext { Account = Account, ErrorMessage = ex.Message, Exception = ex, CalendarId = calendar.Id, CalendarName = calendar.Name, OperationType = "CalendarSync" }; _ = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); CaptureSynchronizationIssue(errorContext); if (!errorContext.CanContinueSync) throw; } } // TODO: Return proper results. return CalendarSynchronizationResult.Empty; } private async Task SynchronizeCalendarsAsync(CancellationToken cancellationToken = default) { var calendars = await _graphClient.Me.Calendars.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false); var remotePrimaryCalendarId = await GetPrimaryCalendarIdAsync(calendars.Value, cancellationToken).ConfigureAwait(false); var localCalendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false); var usedCalendarColors = new HashSet(StringComparer.OrdinalIgnoreCase); List insertedCalendars = new(); List updatedCalendars = new(); List deletedCalendars = new(); // 1. Handle deleted calendars. foreach (var calendar in localCalendars) { var remoteCalendar = calendars.Value.FirstOrDefault(a => a.Id == calendar.RemoteCalendarId); if (remoteCalendar == null) { // Local calendar doesn't exists remotely. Delete local copy. await _outlookChangeProcessor.DeleteAccountCalendarAsync(calendar).ConfigureAwait(false); deletedCalendars.Add(calendar); } } // Delete the deleted folders from local list. deletedCalendars.ForEach(a => localCalendars.Remove(a)); // 2. Handle update/insert based on remote calendars. foreach (var calendar in calendars.Value) { var existingLocalCalendar = localCalendars.FirstOrDefault(a => a.RemoteCalendarId == calendar.Id); if (existingLocalCalendar == null) { // Insert new calendar. var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, calendar.HexColor); var localCalendar = calendar.AsCalendar(Account, fallbackColor); localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, localCalendar.BackgroundColorHex); localCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(localCalendar.BackgroundColorHex); usedCalendarColors.Add(localCalendar.BackgroundColorHex); insertedCalendars.Add(localCalendar); } else { // Update existing calendar. Right now we only update the name. var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocalCalendar.BackgroundColorHex); if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId) || !string.Equals(existingLocalCalendar.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase)) { existingLocalCalendar.Name = calendar.Name; existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); existingLocalCalendar.BackgroundColorHex = resolvedColor; existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex); updatedCalendars.Add(existingLocalCalendar); } else { // Remove it from the local folder list to skip additional calendar updates. localCalendars.Remove(existingLocalCalendar); } usedCalendarColors.Add(resolvedColor); } } // 3.Process changes in order-> Insert, Update. Deleted ones are already processed. foreach (var calendar in insertedCalendars) { await _outlookChangeProcessor.InsertAccountCalendarAsync(calendar).ConfigureAwait(false); } foreach (var calendar in updatedCalendars) { await _outlookChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false); } if (insertedCalendars.Any() || deletedCalendars.Any() || updatedCalendars.Any()) { // TODO: Notify calendar updates. // WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id)); } } private bool ShouldUpdateCalendar(Calendar calendar, AccountCalendar accountCalendar, string remotePrimaryCalendarId) { var remoteCalendarName = calendar.Name; var remoteBackgroundColor = string.IsNullOrEmpty(calendar.HexColor) ? accountCalendar.BackgroundColorHex : calendar.HexColor; var remoteIsPrimary = string.Equals(calendar.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase); bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase); bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary; return isNameChanged || isBackgroundColorChanged || isPrimaryChanged; } private async Task GetPrimaryCalendarIdAsync(IList remoteCalendars, CancellationToken cancellationToken) { if (remoteCalendars == null || remoteCalendars.Count == 0) return string.Empty; var explicitPrimary = remoteCalendars.FirstOrDefault(c => c.IsDefaultCalendar.GetValueOrDefault()); if (explicitPrimary != null) return explicitPrimary.Id; try { var meCalendar = await _graphClient.Me.Calendar.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false); if (!string.IsNullOrEmpty(meCalendar?.Id)) return meCalendar.Id; } catch (Exception ex) { _logger.Warning(ex, "Failed to fetch default Outlook calendar for {Name}. Falling back to first available calendar.", Account.Name); } return remoteCalendars.First().Id; } #region Calendar Operations public override List> CreateCalendarEvent(CreateCalendarEventRequest request) { var calendarItem = request.PreparedItem; var attendees = request.PreparedEvent.Attendees; var reminders = request.PreparedEvent.Reminders; var calendar = request.AssignedCalendar; var outlookEvent = new Microsoft.Graph.Models.Event { Subject = calendarItem.Title, Body = new Microsoft.Graph.Models.ItemBody { ContentType = Microsoft.Graph.Models.BodyType.Html, Content = calendarItem.Description }, Location = new Microsoft.Graph.Models.Location { DisplayName = calendarItem.Location }, ShowAs = calendarItem.ShowAs switch { CalendarItemShowAs.Free => Microsoft.Graph.Models.FreeBusyStatus.Free, CalendarItemShowAs.Tentative => Microsoft.Graph.Models.FreeBusyStatus.Tentative, CalendarItemShowAs.Busy => Microsoft.Graph.Models.FreeBusyStatus.Busy, CalendarItemShowAs.OutOfOffice => Microsoft.Graph.Models.FreeBusyStatus.Oof, CalendarItemShowAs.WorkingElsewhere => Microsoft.Graph.Models.FreeBusyStatus.WorkingElsewhere, _ => Microsoft.Graph.Models.FreeBusyStatus.Busy }, TransactionId = calendarItem.Id.ToString("N") }; if (calendarItem.IsAllDayEvent) { outlookEvent.IsAllDay = true; outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.StartDate.ToString("yyyy-MM-dd"), TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id }; outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.EndDate.ToString("yyyy-MM-dd"), TimeZone = calendarItem.EndTimeZone ?? calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id }; } else { outlookEvent.IsAllDay = false; outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.StartDate.ToString("yyyy-MM-ddTHH:mm:ss"), TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id }; outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.EndDate.ToString("yyyy-MM-ddTHH:mm:ss"), TimeZone = calendarItem.EndTimeZone ?? TimeZoneInfo.Local.Id }; } if (attendees.Count > 0) { outlookEvent.Attendees = attendees.Select(a => new Microsoft.Graph.Models.Attendee { EmailAddress = new Microsoft.Graph.Models.EmailAddress { Address = a.Email, Name = a.Name }, Type = a.IsOptionalAttendee ? Microsoft.Graph.Models.AttendeeType.Optional : Microsoft.Graph.Models.AttendeeType.Required }).ToList(); } if (reminders.Count > 0) { var reminder = reminders .OrderBy(reminder => reminder.DurationInSeconds) .FirstOrDefault(reminder => reminder.ReminderType == CalendarItemReminderType.Popup) ?? reminders.OrderBy(reminder => reminder.DurationInSeconds).First(); outlookEvent.IsReminderOn = true; outlookEvent.ReminderMinutesBeforeStart = (int)Math.Max(0, reminder.DurationInSeconds / 60); } var recurrence = CalendarRecurrenceMapper.CreateOutlookRecurrence(calendarItem); if (recurrence != null) { outlookEvent.Recurrence = recurrence; } var createRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events.ToPostRequestInformation(outlookEvent); return [new HttpRequestBundle(createRequest, request)]; } public override List> AcceptEvent(AcceptEventRequest request) { var calendarItem = request.Item; var calendar = calendarItem.AssignedCalendar; if (calendar == null) { throw new InvalidOperationException("Calendar item must have an assigned calendar"); } var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId(); if (string.IsNullOrEmpty(remoteEventId)) { throw new InvalidOperationException("Cannot accept event without remote event ID"); } var acceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[remoteEventId].Accept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Accept.AcceptPostRequestBody { Comment = request.ResponseMessage, SendResponse = !string.IsNullOrEmpty(request.ResponseMessage) }); return [new HttpRequestBundle(acceptRequestInfo, request)]; } public override List> OutlookDeclineEvent(OutlookDeclineEventRequest request) { var responseMessage = request.ResponseMessage; var calendarItem = request.Item; var calendar = calendarItem.AssignedCalendar; if (calendar == null) { throw new InvalidOperationException("Calendar item must have an assigned calendar"); } var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId(); if (string.IsNullOrEmpty(remoteEventId)) { throw new InvalidOperationException("Cannot decline event without remote event ID"); } var declineRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[remoteEventId].Decline.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Decline.DeclinePostRequestBody { Comment = responseMessage, SendResponse = !string.IsNullOrEmpty(responseMessage) }); return [new HttpRequestBundle(declineRequestInfo, request)]; } public override List> TentativeEvent(TentativeEventRequest request) { var calendarItem = request.Item; var calendar = calendarItem.AssignedCalendar; if (calendar == null) { throw new InvalidOperationException("Calendar item must have an assigned calendar"); } var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId(); if (string.IsNullOrEmpty(remoteEventId)) { throw new InvalidOperationException("Cannot tentatively accept event without remote event ID"); } var tentativelyAcceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[remoteEventId].TentativelyAccept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.TentativelyAccept.TentativelyAcceptPostRequestBody { Comment = request.ResponseMessage, SendResponse = !string.IsNullOrEmpty(request.ResponseMessage) }); return [new HttpRequestBundle(tentativelyAcceptRequestInfo, request)]; } public override List> UpdateCalendarEvent(UpdateCalendarEventRequest request) { var calendarItem = request.Item; var attendees = request.Attendees; // Get the calendar for this event var calendar = calendarItem.AssignedCalendar; if (calendar == null) { throw new InvalidOperationException("Calendar item must have an assigned calendar"); } // Convert CalendarItem to Outlook Event for update var outlookEvent = new Microsoft.Graph.Models.Event { Subject = calendarItem.Title, Body = new Microsoft.Graph.Models.ItemBody { ContentType = Microsoft.Graph.Models.BodyType.Text, Content = calendarItem.Description }, Location = new Microsoft.Graph.Models.Location { DisplayName = calendarItem.Location }, ShowAs = calendarItem.ShowAs switch { CalendarItemShowAs.Free => Microsoft.Graph.Models.FreeBusyStatus.Free, CalendarItemShowAs.Tentative => Microsoft.Graph.Models.FreeBusyStatus.Tentative, CalendarItemShowAs.Busy => Microsoft.Graph.Models.FreeBusyStatus.Busy, CalendarItemShowAs.OutOfOffice => Microsoft.Graph.Models.FreeBusyStatus.Oof, CalendarItemShowAs.WorkingElsewhere => Microsoft.Graph.Models.FreeBusyStatus.WorkingElsewhere, _ => Microsoft.Graph.Models.FreeBusyStatus.Busy } }; // Set start and end time using DateTimeTimeZone if (calendarItem.IsAllDayEvent) { // All-day events outlookEvent.IsAllDay = true; outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.StartDate.ToString("yyyy-MM-dd"), TimeZone = "UTC" }; outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.EndDate.ToString("yyyy-MM-dd"), TimeZone = "UTC" }; } else { // Regular events with time // StartDate and EndDate are stored in the event's timezone // We preserve the timezone information during update outlookEvent.IsAllDay = false; outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.StartDate.ToString("yyyy-MM-ddTHH:mm:ss"), TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id }; outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.EndDate.ToString("yyyy-MM-ddTHH:mm:ss"), TimeZone = calendarItem.EndTimeZone ?? TimeZoneInfo.Local.Id }; } // Add attendees if any if (attendees != null && attendees.Count > 0) { outlookEvent.Attendees = attendees.Select(a => new Microsoft.Graph.Models.Attendee { EmailAddress = new Microsoft.Graph.Models.EmailAddress { Address = a.Email, Name = a.Name }, Type = a.IsOptionalAttendee ? Microsoft.Graph.Models.AttendeeType.Optional : Microsoft.Graph.Models.AttendeeType.Required }).ToList(); } // Update the event using Graph API var updateRequest = _graphClient.Me.Events[calendarItem.RemoteEventId.GetProviderRemoteEventId()].ToPatchRequestInformation(outlookEvent); return [new HttpRequestBundle(updateRequest, request)]; } public override List> DeleteCalendarEvent(DeleteCalendarEventRequest request) { var calendarItem = request.Item; // Get the calendar for this event var calendar = calendarItem.AssignedCalendar; if (calendar == null) { throw new InvalidOperationException("Calendar item must have an assigned calendar"); } var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId(); if (string.IsNullOrEmpty(remoteEventId)) { throw new InvalidOperationException("Cannot delete event without remote event ID"); } var deleteRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[remoteEventId].ToDeleteRequestInformation(); return [new HttpRequestBundle(deleteRequest, request)]; } #endregion public override async Task KillSynchronizerAsync() { await base.KillSynchronizerAsync(); _graphClient.Dispose(); } }