2024-04-18 01:44:37 +02:00
using System ;
using System.Collections.Generic ;
using System.Diagnostics ;
2025-05-18 14:06:25 +02:00
using System.Diagnostics.CodeAnalysis ;
2024-04-18 01:44:37 +02:00
using System.IO ;
using System.Linq ;
2025-02-26 19:59:11 +01:00
using System.Net.Http ;
2024-08-19 19:02:33 +02:00
using System.Text ;
2024-08-21 13:15:50 +02:00
using System.Text.Json ;
2024-08-19 19:16:47 +02:00
using System.Text.Json.Nodes ;
2025-02-14 01:43:52 +01:00
using System.Text.Json.Serialization ;
2024-04-18 01:44:37 +02:00
using System.Text.RegularExpressions ;
using System.Threading ;
using System.Threading.Tasks ;
2025-10-31 00:51:27 +01:00
using CommunityToolkit.Mvvm.Messaging ;
2024-04-18 01:44:37 +02:00
using Microsoft.Graph ;
2025-10-12 16:23:33 +02:00
using Microsoft.Graph.Me.MailFolders.Item.Messages.Delta ;
2024-04-18 01:44:37 +02:00
using Microsoft.Graph.Models ;
2024-08-26 22:09:00 +02:00
using Microsoft.Graph.Models.ODataErrors ;
2024-04-18 01:44:37 +02:00
using Microsoft.Kiota.Abstractions ;
using Microsoft.Kiota.Abstractions.Authentication ;
2025-02-26 19:59:11 +01:00
using Microsoft.Kiota.Abstractions.Serialization ;
2024-04-18 01:44:37 +02:00
using MimeKit ;
using MoreLinq.Extensions ;
using Serilog ;
2025-01-06 02:15:21 +01:00
using Wino.Core.Domain.Entities.Calendar ;
2024-11-10 23:28:25 +01:00
using Wino.Core.Domain.Entities.Mail ;
using Wino.Core.Domain.Entities.Shared ;
2024-04-18 01:44:37 +02:00
using Wino.Core.Domain.Enums ;
using Wino.Core.Domain.Exceptions ;
using Wino.Core.Domain.Interfaces ;
2024-08-17 03:43:37 +02:00
using Wino.Core.Domain.Models.Accounts ;
2025-04-26 10:49:55 +02:00
using Wino.Core.Domain.Models.Errors ;
2025-02-22 00:22:00 +01:00
using Wino.Core.Domain.Models.Folders ;
2024-04-18 01:44:37 +02:00
using Wino.Core.Domain.Models.MailItem ;
using Wino.Core.Domain.Models.Synchronization ;
using Wino.Core.Extensions ;
using Wino.Core.Http ;
using Wino.Core.Integration.Processors ;
2024-09-05 17:23:15 +02:00
using Wino.Core.Misc ;
2024-11-10 23:28:25 +01:00
using Wino.Core.Requests.Bundles ;
2024-11-26 20:03:10 +01:00
using Wino.Core.Requests.Folder ;
using Wino.Core.Requests.Mail ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
namespace Wino.Core.Synchronizers.Mail ;
[JsonSerializable(typeof(Microsoft.Graph.Me.Messages.Item.Move.MovePostRequestBody))]
[JsonSerializable(typeof(OutlookFileAttachment))]
2025-02-22 00:22:00 +01:00
public partial class OutlookSynchronizerJsonContext : JsonSerializerContext ;
2025-02-16 11:43:30 +01:00
2025-10-31 00:51:27 +01:00
/// <summary>
/// Outlook synchronizer implementation with queue-based metadata-only synchronization.
///
/// SYNCHRONIZATION STRATEGY:
/// - Uses per-folder queue system (unlike Gmail's per-account queue)
/// - During sync (initial/delta), only message metadata is downloaded (no MIME content)
/// - Messages are queued by folder using MailItemQueue with RemoteFolderId
/// - MailCopy objects are created from Graph API metadata fields only
/// - MIME files are downloaded on-demand when user explicitly reads a message
/// - This dramatically reduces bandwidth usage and sync time
///
/// Key implementation details:
/// - QueueMailIdsForFolderAsync: Queues all mail IDs for a folder using Delta API
/// - ProcessMailQueueForFolderAsync: Downloads metadata in batches from queue
/// - DownloadMessageMetadataBatchAsync: Concurrently downloads metadata for batches
/// - CreateMailCopyFromMessage: Centralized method to create MailCopy from Message (metadata only)
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
/// - CreateNewMailPackagesAsync: Only used for search results and special cases (downloads MIME)
/// </summary>
2025-02-16 11:54:23 +01:00
public class OutlookSynchronizer : WinoSynchronizer < RequestInformation , Message , Event >
{
public override uint BatchModificationSize = > 20 ;
2025-10-12 16:23:33 +02:00
public override uint InitialMessageDownloadCountPerFolder = > 1000 ;
2025-02-16 11:54:23 +01:00
private const uint MaximumAllowedBatchRequestSize = 20 ;
private const string INBOX_NAME = "inbox" ;
private const string SENT_NAME = "sentitems" ;
private const string DELETED_NAME = "deleteditems" ;
private const string JUNK_NAME = "junkemail" ;
private const string DRAFTS_NAME = "drafts" ;
private const string ARCHIVE_NAME = "archive" ;
private readonly string [ ] outlookMessageSelectParameters =
[
"InferenceClassification",
"Flag",
"Importance",
"IsRead",
"IsDraft",
"ReceivedDateTime",
"HasAttachments",
"BodyPreview",
"Id",
"ConversationId",
"From",
"Subject",
"ParentFolderId",
"InternetMessageId",
] ;
private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new ( 1 ) ;
private readonly ILogger _logger = Log . ForContext < OutlookSynchronizer > ( ) ;
private readonly IOutlookChangeProcessor _outlookChangeProcessor ;
private readonly GraphServiceClient _graphClient ;
2025-04-26 10:49:55 +02:00
private readonly IOutlookSynchronizerErrorHandlerFactory _errorHandlingFactory ;
2025-10-12 16:23:33 +02:00
private readonly SemaphoreSlim _concurrentDownloadSemaphore = new ( 10 ) ; // Limit to 10 concurrent downloads
2025-02-16 11:54:23 +01:00
public OutlookSynchronizer ( MailAccount account ,
IAuthenticator authenticator ,
2025-04-26 10:49:55 +02:00
IOutlookChangeProcessor outlookChangeProcessor ,
2025-10-31 00:51:27 +01:00
IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory ) : base ( account , WeakReferenceMessenger . Default )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var tokenProvider = new MicrosoftTokenProvider ( Account , authenticator ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Update request handlers for Graph client.
var handlers = GraphClientFactory . CreateDefaultHandlers ( ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
handlers . Add ( GetMicrosoftImmutableIdHandler ( ) ) ;
2024-08-05 00:36:26 +02:00
2025-02-16 11:54:23 +01:00
var httpClient = GraphClientFactory . Create ( handlers ) ;
_graphClient = new GraphServiceClient ( httpClient , new BaseBearerTokenAuthenticationProvider ( tokenProvider ) ) ;
2024-08-05 00:36:26 +02:00
2025-02-16 11:54:23 +01:00
_outlookChangeProcessor = outlookChangeProcessor ;
2025-04-26 10:49:55 +02:00
_errorHandlingFactory = errorHandlingFactory ;
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
#region MS Graph Handlers
2024-08-05 00:36:26 +02:00
2025-02-16 11:54:23 +01:00
private MicrosoftImmutableIdHandler GetMicrosoftImmutableIdHandler ( ) = > new ( ) ;
2024-08-05 00:36:26 +02:00
2025-02-16 11:54:23 +01:00
#endregion
2024-08-05 00:36:26 +02:00
2025-02-16 11:54:23 +01:00
protected override async Task < MailSynchronizationResult > SynchronizeMailsInternalAsync ( MailSynchronizationOptions options , CancellationToken cancellationToken = default )
{
var downloadedMessageIds = new List < string > ( ) ;
2024-08-05 00:36:26 +02:00
2025-02-16 11:54:23 +01:00
_logger . Information ( "Internal synchronization started for {Name}" , Account . Name ) ;
_logger . Information ( "Options: {Options}" , options ) ;
try
2024-04-18 01:44:37 +02:00
{
2025-10-31 00:51:27 +01:00
// Set indeterminate progress initially
UpdateSyncProgress ( 0 , 0 , "Synchronizing folders..." ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await SynchronizeFoldersAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if ( options . Type ! = MailSynchronizationType . FoldersOnly )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var synchronizationFolders = await _outlookChangeProcessor . GetSynchronizationFoldersAsync ( options ) . ConfigureAwait ( false ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
_logger . Information ( string . Format ( "{1} Folders: {0}" , string . Join ( "," , synchronizationFolders . Select ( a = > a . FolderName ) ) , synchronizationFolders . Count ) ) ;
2024-04-18 01:44:37 +02:00
2025-10-31 00:51:27 +01:00
var totalFolders = synchronizationFolders . Count ;
for ( int i = 0 ; i < totalFolders ; i + + )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var folder = synchronizationFolders [ i ] ;
2025-10-31 12:13:54 +01:00
2025-10-31 00:51:27 +01:00
// Update progress based on folder completion
UpdateSyncProgress ( totalFolders , totalFolders - ( i + 1 ) , $"Syncing {folder.FolderName}..." ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var folderDownloadedMessageIds = await SynchronizeFolderAsync ( folder , cancellationToken ) . ConfigureAwait ( false ) ;
downloadedMessageIds . AddRange ( folderDownloadedMessageIds ) ;
2024-04-18 01:44:37 +02:00
}
}
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:54:23 +01:00
catch ( Exception ex )
{
_logger . Error ( ex , "Synchronizing folders for {Name}" , Account . Name ) ;
Debugger . Break ( ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
throw ;
}
finally
2025-02-16 11:43:30 +01:00
{
2025-10-31 00:51:27 +01:00
// Reset progress at the end
ResetSyncProgress ( ) ;
2025-02-16 11:54:23 +01:00
}
2024-08-21 13:15:50 +02:00
2025-02-16 11:54:23 +01:00
// Get all unred new downloaded items and return in the result.
// This is primarily used in notifications.
2024-06-02 21:35:03 +02:00
2025-02-16 11:54:23 +01:00
var unreadNewItems = await _outlookChangeProcessor . GetDownloadedUnreadMailsAsync ( Account . Id , downloadedMessageIds ) . ConfigureAwait ( false ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return MailSynchronizationResult . Completed ( unreadNewItems ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-22 00:22:00 +01:00
public async Task DownloadSearchResultMessageAsync ( string messageId , MailItemFolder assignedFolder , CancellationToken cancellationToken = default )
{
Log . Information ( "Downloading search result message {messageId} for {Name} - {FolderName}" , messageId , Account . Name , assignedFolder . FolderName ) ;
// Outlook message handling was a little strange.
// Instead of changing it from the scratch, we will just download the message and process it.
// Search results will only return Id for the messages.
// This method will download the raw mime, get the required enough metadata from the service and create
// the mail locally. Message ids passed to this method is expected to be non-existent locally.
var message = await _graphClient . Me . Messages [ messageId ] . GetAsync ( ( config ) = >
{
config . QueryParameters . Select = outlookMessageSelectParameters ;
} , cancellationToken ) . ConfigureAwait ( false ) ;
var mailPackages = await CreateNewMailPackagesAsync ( message , assignedFolder , cancellationToken ) . ConfigureAwait ( false ) ;
if ( mailPackages = = null ) return ;
foreach ( var package in mailPackages )
{
cancellationToken . ThrowIfCancellationRequested ( ) ;
await _outlookChangeProcessor . CreateMailRawAsync ( Account , assignedFolder , package ) . ConfigureAwait ( false ) ;
}
}
2025-02-16 11:54:23 +01:00
private async Task < IEnumerable < string > > SynchronizeFolderAsync ( MailItemFolder folder , CancellationToken cancellationToken = default )
{
var downloadedMessageIds = new List < string > ( ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
cancellationToken . ThrowIfCancellationRequested ( ) ;
2024-04-18 01:44:37 +02:00
2025-10-12 16:23:33 +02:00
_logger . Debug ( "Synchronizing {FolderName} with direct download approach" , folder . FolderName ) ;
2024-07-09 01:05:16 +02:00
2025-10-12 16:23:33 +02:00
// Check if initial sync is completed for this folder
2025-10-31 00:51:27 +01:00
if ( folder . FolderStatus ! = InitialSynchronizationStatus . Completed )
2025-10-12 16:23:33 +02:00
{
_logger . Debug ( "Initial sync not completed for folder {FolderName}. Starting mail synchronization." , folder . FolderName ) ;
2024-04-18 01:44:37 +02:00
2025-10-12 16:23:33 +02:00
// Download mails for initial sync
await DownloadMailsForInitialSyncAsync ( folder , downloadedMessageIds , cancellationToken ) . ConfigureAwait ( false ) ;
2024-04-18 01:44:37 +02:00
2025-10-12 16:23:33 +02:00
// Mark initial sync as completed
await _outlookChangeProcessor . UpdateFolderInitialSyncCompletedAsync ( folder . Id , true ) . ConfigureAwait ( false ) ;
2025-10-31 00:51:27 +01:00
folder . FolderStatus = InitialSynchronizationStatus . Completed ;
2025-10-12 16:23:33 +02:00
}
else
2025-02-16 11:54:23 +01:00
{
2025-10-12 16:23:33 +02:00
// Initial sync is completed, process delta changes and download new mails
_logger . Debug ( "Initial sync completed for folder {FolderName}. Processing delta changes and downloading new mails." , folder . FolderName ) ;
await ProcessDeltaChangesAndDownloadMailsAsync ( folder , downloadedMessageIds , cancellationToken ) . ConfigureAwait ( false ) ;
}
2024-04-18 01:44:37 +02:00
2025-10-12 16:23:33 +02:00
await _outlookChangeProcessor . UpdateFolderLastSyncDateAsync ( folder . Id ) . ConfigureAwait ( false ) ;
2025-02-16 11:43:30 +01:00
2025-10-12 16:23:33 +02:00
if ( downloadedMessageIds . Any ( ) )
{
_logger . Information ( "Downloaded {Count} messages for folder {FolderName}" , downloadedMessageIds . Count , folder . FolderName ) ;
2025-02-16 11:54:23 +01:00
}
2025-10-12 16:23:33 +02:00
return downloadedMessageIds ;
}
/// <summary>
2025-10-31 00:51:27 +01:00
/// Downloads mails for initial synchronization using Delta API and queue-based system.
/// First, queues all mail IDs, then downloads metadata in batches.
2025-10-12 16:23:33 +02:00
/// </summary>
private async Task DownloadMailsForInitialSyncAsync ( MailItemFolder folder , List < string > downloadedMessageIds , CancellationToken cancellationToken )
{
_logger . Debug ( "Starting initial mail download for folder {FolderName}" , folder . FolderName ) ;
try
2025-02-16 11:54:23 +01:00
{
2025-10-31 00:51:27 +01:00
// Step 1: Queue all mail IDs using Delta API
await QueueMailIdsForFolderAsync ( folder , cancellationToken ) . ConfigureAwait ( false ) ;
2025-02-23 00:35:13 +01:00
2025-10-31 00:51:27 +01:00
// Step 2: Process queued mail IDs in batches
await ProcessMailQueueForFolderAsync ( folder , downloadedMessageIds , cancellationToken ) . ConfigureAwait ( false ) ;
2025-02-16 11:54:23 +01:00
}
2025-10-12 16:23:33 +02:00
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 initial sync: {apiException.Message}" ,
Exception = apiException
} ;
var handled = await _errorHandlingFactory . HandleErrorAsync ( errorContext ) . ConfigureAwait ( false ) ;
2025-10-31 12:13:54 +01:00
2025-10-12 16:23:33 +02:00
if ( handled )
{
// The error handler has processed the error (e.g., DeltaTokenExpiredHandler for 410)
// Update in-memory folder state if it was a delta token expiration
if ( apiException . ResponseStatusCode = = 410 )
{
folder . DeltaToken = string . Empty ;
2025-10-31 00:51:27 +01:00
folder . FolderStatus = InitialSynchronizationStatus . None ;
2025-10-12 16:23:33 +02:00
_logger . Information ( "API error handled successfully for folder {FolderName} during initial sync. Error: {ErrorCode}" , folder . FolderName , apiException . ResponseStatusCode ) ;
}
}
else
{
// No handler could process this error, log and re-throw
_logger . Error ( apiException , "Unhandled API error during initial sync for folder {FolderName}. Error: {ErrorCode}" , folder . FolderName , apiException . ResponseStatusCode ) ;
}
2025-10-31 12:13:54 +01:00
2025-10-12 16:23:33 +02:00
// Re-throw the exception so the synchronization can be retried
throw ;
}
catch ( Exception ex )
{
_logger . Error ( ex , "Error occurred during initial mail download for folder {FolderName}" , folder . FolderName ) ;
throw ;
}
}
2024-04-18 01:44:37 +02:00
2025-10-12 16:23:33 +02:00
/// <summary>
2025-10-31 00:51:27 +01:00
/// Queues all mail IDs for a folder using Delta API.
/// Only retrieves message IDs to minimize data transfer.
2025-10-12 16:23:33 +02:00
/// </summary>
2025-10-31 00:51:27 +01:00
private async Task QueueMailIdsForFolderAsync ( MailItemFolder folder , CancellationToken cancellationToken )
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
_logger . Debug ( "Queuing mail IDs for folder {FolderName}" , folder . FolderName ) ;
var mailIds = new List < string > ( ) ;
// Always use Delta API for initial sync - this ensures proper delta token setup for future incremental syncs
DeltaGetResponse messageCollectionPage = null ;
if ( string . IsNullOrEmpty ( folder . DeltaToken ) )
{
messageCollectionPage = await _graphClient . Me . MailFolders [ folder . RemoteFolderId ] . Messages . Delta . GetAsDeltaGetResponseAsync ( ( config ) = >
{
config . QueryParameters . Select = [ "Id" ] ; // Only get the message Ids
config . QueryParameters . Orderby = [ "receivedDateTime desc" ] ; // Sort by received date desc
config . QueryParameters . Top = ( int ) InitialMessageDownloadCountPerFolder ;
} , cancellationToken ) . ConfigureAwait ( false ) ;
}
else
{
var requestInformation = _graphClient . Me . MailFolders [ folder . RemoteFolderId ] . Messages . Delta . ToGetRequestInformation ( ( config ) = >
{
config . QueryParameters . Select = [ "Id" ] ; // Only get the message Ids
config . QueryParameters . Orderby = [ "receivedDateTime desc" ] ; // Sort by received date desc
} ) ;
requestInformation . UrlTemplate = requestInformation . UrlTemplate . Insert ( requestInformation . UrlTemplate . Length - 1 , ",%24deltatoken" ) ;
requestInformation . QueryParameters . Add ( "%24deltatoken" , folder . DeltaToken ) ;
messageCollectionPage = await _graphClient . RequestAdapter . SendAsync ( requestInformation , DeltaGetResponse . CreateFromDiscriminatorValue , cancellationToken : cancellationToken ) ;
}
// Use PageIterator to iterate through all messages and collect IDs
var messageIterator = PageIterator < Message , DeltaGetResponse > . CreatePageIterator ( _graphClient , messageCollectionPage , ( message ) = >
{
if ( ! IsResourceDeleted ( message . AdditionalData ) )
{
mailIds . Add ( message . Id ) ;
}
// Iterator must continue all the time to receive delta token at the end.
return true ;
} ) ;
await messageIterator . IterateAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
// Extract delta token from the iterator's delta link
string deltaToken = null ;
if ( ! string . IsNullOrEmpty ( messageIterator . Deltalink ) )
{
deltaToken = GetDeltaTokenFromDeltaLink ( messageIterator . Deltalink ) ;
}
// Queue all mail IDs for processing
if ( mailIds . Any ( ) )
{
var queueEntries = mailIds . Select ( id = > new MailItemQueue
{
Id = Guid . CreateVersion7 ( ) ,
AccountId = Account . Id ,
RemoteServerId = id ,
RemoteFolderId = folder . RemoteFolderId ,
IsProcessed = false ,
CreatedAt = DateTime . UtcNow
} ) ;
await _outlookChangeProcessor . AddMailItemQueueItemsAsync ( queueEntries ) . ConfigureAwait ( false ) ;
_logger . Information ( "Queued {Count} mail IDs for folder {FolderName}" , mailIds . Count , folder . FolderName ) ;
}
else
{
_logger . Information ( "No mail ids found to queue for folder {FolderName}" , folder . FolderName ) ;
}
// Store the delta token for future incremental syncs - always store when available
if ( ! string . IsNullOrEmpty ( deltaToken ) )
{
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 ) ;
}
2025-10-20 18:27:02 +02:00
}
/// <summary>
2025-10-31 00:51:27 +01:00
/// Processes queued mail IDs in batches, downloading metadata only (no MIME).
2025-10-20 18:27:02 +02:00
/// </summary>
2025-10-31 00:51:27 +01:00
private async Task ProcessMailQueueForFolderAsync ( MailItemFolder folder , List < string > downloadedMessageIds , CancellationToken cancellationToken )
2025-10-20 18:27:02 +02:00
{
2025-10-31 00:51:27 +01:00
var totalInQueue = await _outlookChangeProcessor . GetMailItemQueueCountByFolderAsync ( Account . Id , folder . RemoteFolderId ) . ConfigureAwait ( false ) ;
if ( totalInQueue = = 0 )
2025-02-16 11:54:23 +01:00
{
2025-10-31 00:51:27 +01:00
_logger . Information ( "No mails in queue for folder {FolderName}" , folder . FolderName ) ;
return ;
}
_logger . Information ( "Processing {Count} queued mails for folder {FolderName}" , totalInQueue , folder . FolderName ) ;
var totalFailed = 0 ;
var totalProcessed = 0 ;
// Set initial progress for queue processing
UpdateSyncProgress ( totalInQueue , totalInQueue , $"Downloading {folder.FolderName}..." ) ;
// Continue until all emails in queue are processed
while ( true )
{
// Get next batch of unprocessed emails from queue
var mailItemQueue = await _outlookChangeProcessor . GetMailItemQueueByFolderAsync ( Account . Id , folder . RemoteFolderId , 100 ) . ConfigureAwait ( false ) ;
if ( mailItemQueue . Count = = 0 )
break ; // No more emails to process
// Remove the items that should be deleted from queue first
mailItemQueue . RemoveAll ( a = > a . ShouldDelete ( ) ) ;
var mailChunks = mailItemQueue . Chunk ( 20 ) ; // Process 20 at a time
foreach ( var chunk in mailChunks )
2025-02-16 11:43:30 +01:00
{
2025-10-31 00:51:27 +01:00
cancellationToken . ThrowIfCancellationRequested ( ) ;
// Collect message IDs from the chunk
var messageIdsToDownload = chunk . Select ( q = > q . RemoteServerId ) . ToList ( ) ;
try
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
// Download all messages in this chunk concurrently
2025-10-31 12:13:54 +01:00
var chunkDownloadedIds = await DownloadMessageMetadataBatchAsync ( messageIdsToDownload , folder , true , cancellationToken ) . ConfigureAwait ( false ) ;
2025-10-31 00:51:27 +01:00
downloadedMessageIds . AddRange ( chunkDownloadedIds ) ;
// Mark all items in chunk as processed
foreach ( var queueItem in chunk )
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
queueItem . IsProcessed = true ;
queueItem . ProcessedAt = DateTime . UtcNow ;
totalProcessed + + ;
2025-10-12 16:23:33 +02:00
}
2025-10-31 00:51:27 +01:00
// Update progress with remaining items
var remainingItems = totalInQueue - totalProcessed ;
UpdateSyncProgress ( totalInQueue , remainingItems , $"Downloading {folder.FolderName} ({totalProcessed}/{totalInQueue})" ) ;
}
catch ( Exception ex )
{
_logger . Error ( ex , "Failed to download chunk of messages for folder {FolderName}" , folder . FolderName ) ;
// Mark all items in chunk as failed
foreach ( var queueItem in chunk )
{
queueItem . IsProcessed = false ;
queueItem . ProcessedAt = null ;
queueItem . FailedCount + + ;
totalFailed + + ;
}
}
await _outlookChangeProcessor . UpdateMailItemQueueAsync ( mailItemQueue ) . ConfigureAwait ( false ) ;
// If too many failures, pause to avoid hitting rate limits
if ( totalFailed > 50 )
{
_logger . Warning ( "Too many failures ({Count}), pausing for 10 seconds" , totalFailed ) ;
await Task . Delay ( TimeSpan . FromSeconds ( 10 ) , cancellationToken ) ;
totalFailed = 0 ; // Reset counter
2025-10-12 16:23:33 +02:00
}
2025-02-16 11:43:30 +01:00
}
2025-10-12 16:23:33 +02:00
2025-10-31 00:51:27 +01:00
_logger . Debug ( "Processed batch: {Processed}/{Total} for folder {FolderName}" , totalProcessed , totalInQueue , folder . FolderName ) ;
}
_logger . Information ( "Completed processing queue for folder {FolderName}. Processed: {Count}" , folder . FolderName , totalProcessed ) ;
2025-10-12 16:23:33 +02:00
}
/// <summary>
2025-10-31 00:51:27 +01:00
/// 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.
2025-10-12 16:23:33 +02:00
/// </summary>
2025-10-31 12:13:54 +01:00
private async Task < List < string > > DownloadMessageMetadataBatchAsync ( List < string > messageIds , MailItemFolder folder , bool retryFailedOnce , CancellationToken cancellationToken )
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
if ( messageIds = = null | | messageIds . Count = = 0 )
return new List < string > ( ) ;
var downloadedIds = new List < string > ( ) ;
// Filter out messages that already exist in the database
var messagesToDownload = new List < string > ( ) ;
foreach ( var messageId in messageIds )
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
bool mailExists = await _outlookChangeProcessor . IsMailExistsInFolderAsync ( messageId , folder . Id ) . ConfigureAwait ( false ) ;
if ( ! mailExists )
2025-02-16 11:54:23 +01:00
{
2025-10-31 00:51:27 +01:00
messagesToDownload . Add ( messageId ) ;
2025-02-16 11:54:23 +01:00
}
2025-10-31 00:51:27 +01:00
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 ;
}
2025-10-31 12:13:54 +01:00
// Store failed message ids to retry after.
List < string > failedMessageIds = new ( ) ;
2025-10-31 00:51:27 +01:00
// Process in batches of MaximumAllowedBatchRequestSize (20)
var batches = messagesToDownload . Batch ( ( int ) MaximumAllowedBatchRequestSize ) ;
2024-06-02 21:35:03 +02:00
2025-10-31 00:51:27 +01:00
foreach ( var batch in batches )
{
cancellationToken . ThrowIfCancellationRequested ( ) ;
2025-02-16 11:35:43 +01:00
2025-10-31 00:51:27 +01:00
try
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
var batchContent = new BatchRequestContentCollection ( _graphClient ) ;
var requestIdToMessageIdMap = new Dictionary < string , string > ( ) ;
// 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 )
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
var messageId = requestIdToMessageIdMap [ batchRequestId ] ;
2024-04-18 01:44:37 +02:00
2025-10-31 00:51:27 +01:00
try
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
// Deserialize the Message directly from batch response
var message = await batchResponse . GetResponseByIdAsync < Message > ( batchRequestId ) . ConfigureAwait ( false ) ;
if ( message ! = null )
2025-10-20 18:27:02 +02:00
{
2025-10-31 00:51:27 +01:00
// Create MailCopy from metadata only
var mailCopy = CreateMailCopyFromMessage ( message , folder ) ;
if ( mailCopy ! = null )
2025-10-20 18:27:02 +02:00
{
2025-10-31 00:51:27 +01:00
// Create package without MIME
var package = new NewMailItemPackage ( mailCopy , null , folder . RemoteFolderId ) ;
2025-10-20 18:27:02 +02:00
bool isInserted = await _outlookChangeProcessor . CreateMailAsync ( Account . Id , package ) . ConfigureAwait ( false ) ;
if ( isInserted )
{
2025-10-31 00:51:27 +01:00
downloadedIds . Add ( mailCopy . Id ) ;
_logger . Debug ( "Downloaded metadata for message {MailId} in folder {FolderName}" , messageId , folder . FolderName ) ;
2025-10-20 18:27:02 +02:00
}
else
{
2025-10-31 00:51:27 +01:00
_logger . Warning ( "Failed to insert mail {MailId} for folder {FolderName}" , messageId , folder . FolderName ) ;
2025-10-20 18:27:02 +02:00
}
}
}
2025-10-31 00:51:27 +01:00
else
{
_logger . Warning ( "Failed to deserialize message {MailId} for folder {FolderName}" , messageId , folder . FolderName ) ;
2025-10-31 12:13:54 +01:00
failedMessageIds . Add ( messageId ) ;
2025-10-31 00:51:27 +01:00
}
2025-10-12 16:23:33 +02:00
}
2025-10-31 00:51:27 +01:00
catch ( ODataError odataError )
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
// Handle OData errors from the batch response
if ( odataError . ResponseStatusCode = = 404 )
2025-10-20 18:27:02 +02:00
{
2025-10-31 00:51:27 +01:00
_logger . Warning ( "Mail {MailId} not found on server (404) for folder {FolderName}" , messageId , folder . FolderName ) ;
2025-10-20 18:27:02 +02:00
}
else
{
2025-10-31 12:13:54 +01:00
failedMessageIds . Add ( messageId ) ;
2025-10-31 00:51:27 +01:00
_logger . Error ( "OData error while downloading mail {MailId} for folder {FolderName}. Error: {Error}" , messageId , folder . FolderName , odataError . Error ? . Message ) ;
2025-10-20 18:27:02 +02:00
}
}
2025-10-31 00:51:27 +01:00
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}" ,
2025-10-31 12:13:54 +01:00
Exception = serviceException ,
2025-10-31 00:51:27 +01:00
} ;
var handled = await _errorHandlingFactory . HandleErrorAsync ( errorContext ) . ConfigureAwait ( false ) ;
2025-10-31 12:13:54 +01:00
2025-10-31 00:51:27 +01:00
if ( ! handled )
{
2025-10-31 12:13:54 +01:00
failedMessageIds . Add ( messageId ) ;
2025-10-31 00:51:27 +01:00
_logger . Error ( serviceException , "Unhandled service error while downloading mail {MailId} for folder {FolderName}. Error: {ErrorCode}" , messageId , folder . FolderName , serviceException . ResponseStatusCode ) ;
}
}
catch ( Exception ex )
2025-10-20 18:27:02 +02:00
{
2025-10-31 12:13:54 +01:00
failedMessageIds . Add ( messageId ) ;
2025-10-31 00:51:27 +01:00
_logger . Error ( ex , "Error occurred while processing message {MailId} for folder {FolderName}" , messageId , folder . FolderName ) ;
2025-10-20 18:27:02 +02:00
}
2025-10-12 16:23:33 +02:00
}
}
2025-10-31 00:51:27 +01:00
catch ( Exception ex )
2025-10-12 16:23:33 +02:00
{
2025-10-31 12:13:54 +01:00
failedMessageIds . AddRange ( batch ) ;
2025-10-31 00:51:27 +01:00
_logger . Error ( ex , "Error occurred during batch download for folder {FolderName}" , folder . FolderName ) ;
2025-10-12 16:23:33 +02:00
}
}
2025-10-31 12:13:54 +01:00
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 ) ;
}
2025-10-31 00:51:27 +01:00
return downloadedIds ;
}
/// <summary>
/// Creates a MailCopy from an Outlook Message with metadata only (centralized method).
/// This replaces the scattered CreateMinimalMailCopyAsync and AsMailCopy calls.
/// </summary>
private MailCopy CreateMailCopyFromMessage ( Message message , MailItemFolder assignedFolder )
{
if ( message = = null ) return null ;
2025-02-16 11:54:23 +01:00
2025-10-31 00:51:27 +01:00
var mailCopy = message . AsMailCopy ( ) ;
mailCopy . FolderId = assignedFolder . Id ;
mailCopy . UniqueId = Guid . NewGuid ( ) ;
mailCopy . FileId = Guid . NewGuid ( ) ;
return mailCopy ;
2025-10-12 16:23:33 +02:00
}
2025-02-16 11:35:43 +01:00
2025-10-12 16:23:33 +02:00
private string GetDeltaTokenFromDeltaLink ( string deltaLink )
= > Regex . Split ( deltaLink , "deltatoken=" ) [ 1 ] ;
2025-02-16 11:54:23 +01:00
2025-10-12 16:23:33 +02:00
protected override async Task QueueMailIdsForInitialSyncAsync ( MailItemFolder folder , CancellationToken cancellationToken = default )
{
2025-10-31 00:51:27 +01:00
// Queue all mail IDs for the folder
await QueueMailIdsForFolderAsync ( folder , cancellationToken ) . ConfigureAwait ( false ) ;
2025-10-12 16:23:33 +02:00
}
protected override Task < MailCopy > CreateMinimalMailCopyAsync ( Message message , MailItemFolder assignedFolder , CancellationToken cancellationToken = default )
{
2025-10-31 00:51:27 +01:00
// Use centralized method
return Task . FromResult ( CreateMailCopyFromMessage ( message , assignedFolder ) ) ;
2025-10-12 16:23:33 +02:00
}
private async Task < Message > GetMessageByIdAsync ( string messageId , CancellationToken cancellationToken = default )
{
try
{
return await _graphClient . Me . Messages [ messageId ] . GetAsync ( ( config ) = >
{
config . QueryParameters . Select = outlookMessageSelectParameters ;
} , cancellationToken ) . ConfigureAwait ( false ) ;
}
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 ) ;
2025-10-31 12:13:54 +01:00
2025-10-12 16:23:33 +02:00
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 ;
2025-02-16 11:43:30 +01:00
}
2025-10-12 16:23:33 +02:00
}
2025-02-16 11:35:43 +01:00
2025-10-12 16:23:33 +02:00
private async Task ProcessDeltaChangesAndDownloadMailsAsync ( MailItemFolder folder , List < string > downloadedMessageIds , CancellationToken cancellationToken = default )
{
2025-10-31 00:51:27 +01:00
// Process delta changes and download new mails with metadata only (no MIME)
2025-10-12 16:23:33 +02:00
if ( string . IsNullOrEmpty ( folder . DeltaToken ) )
{
_logger . Debug ( "No delta token available for folder {FolderName}. Skipping delta sync." , folder . FolderName ) ;
return ;
}
2025-02-16 11:54:23 +01:00
2025-10-12 16:23:33 +02:00
try
{
var currentDeltaToken = folder . DeltaToken ;
_logger . Debug ( "Processing delta changes for folder {FolderName} with token {DeltaToken}" , folder . FolderName , currentDeltaToken . Substring ( 0 , Math . Min ( 10 , currentDeltaToken . Length ) ) + "..." ) ;
// Always use Delta endpoint with proper configuration
var requestInformation = _graphClient . Me . MailFolders [ folder . RemoteFolderId ] . Messages . Delta . ToGetRequestInformation ( ( config ) = >
{
2025-10-31 00:51:27 +01:00
config . QueryParameters . Select = [ "Id" ] ; // Only get IDs
2025-10-12 16:23:33 +02:00
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 ) ;
var newMailIds = new List < string > ( ) ;
// Use PageIterator<DeltaGetResponse> for iterating through delta changes
var messageIterator = PageIterator < Message , DeltaGetResponse >
. CreatePageIterator ( _graphClient , messageCollectionPage , ( message ) = >
{
// Only process new messages, not deleted ones
if ( ! IsResourceDeleted ( message . AdditionalData ) )
{
newMailIds . Add ( message . Id ) ;
}
return true ;
} ) ;
await messageIterator . IterateAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
2025-10-31 00:51:27 +01:00
// Download new mails with metadata only (no MIME)
2025-10-12 16:23:33 +02:00
if ( newMailIds . Any ( ) )
{
2025-10-31 00:51:27 +01:00
_logger . Information ( "Downloading {Count} new mails from delta sync for folder {FolderName} (metadata only)" , newMailIds . Count , folder . FolderName ) ;
2025-10-31 12:13:54 +01:00
var deltaDownloadedIds = await DownloadMessageMetadataBatchAsync ( newMailIds , folder , true , cancellationToken ) . ConfigureAwait ( false ) ;
2025-10-31 00:51:27 +01:00
downloadedMessageIds . AddRange ( deltaDownloadedIds ) ;
2025-10-12 16:23:33 +02:00
}
// Update delta token for next sync - always store when there are no nextPageToken remaining
if ( ! string . IsNullOrEmpty ( messageIterator . Deltalink ) )
{
var deltaToken = GetDeltaTokenFromDeltaLink ( messageIterator . Deltalink ) ;
await _outlookChangeProcessor . UpdateFolderDeltaSynchronizationIdentifierAsync ( folder . Id , deltaToken ) . ConfigureAwait ( false ) ;
folder . DeltaToken = deltaToken ; // Update in-memory object too
_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 delta sync: {apiException.Message}" ,
Exception = apiException
} ;
var handled = await _errorHandlingFactory . HandleErrorAsync ( errorContext ) . ConfigureAwait ( false ) ;
2025-10-31 12:13:54 +01:00
2025-10-12 16:23:33 +02:00
if ( handled )
{
// The error handler has processed the error (e.g., DeltaTokenExpiredHandler for 410)
// Update in-memory folder state if it was a delta token expiration
if ( apiException . ResponseStatusCode = = 410 )
{
folder . DeltaToken = string . Empty ;
2025-10-31 00:51:27 +01:00
folder . FolderStatus = InitialSynchronizationStatus . None ;
2025-10-12 16:23:33 +02:00
_logger . Information ( "API error handled successfully for folder {FolderName} during delta sync. Error: {ErrorCode}" , folder . FolderName , apiException . ResponseStatusCode ) ;
}
}
else
{
// No handler could process this error, log and re-throw
_logger . Error ( apiException , "Unhandled API error during delta sync for folder {FolderName}. Error: {ErrorCode}" , folder . FolderName , apiException . ResponseStatusCode ) ;
}
}
catch ( Exception ex )
{
_logger . Error ( ex , "Error processing delta changes for folder {FolderName}" , folder . FolderName ) ;
}
2025-02-16 11:54:23 +01:00
}
2025-10-12 16:23:33 +02:00
private async Task ProcessDeltaChangesAsync ( MailItemFolder folder , List < string > 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<DeltaGetResponse> for iterating mails
var messageIterator = PageIterator < Message , DeltaGetResponse >
. CreatePageIterator ( _graphClient , messageCollectionPage , async ( item ) = >
{
try
{
await _handleItemRetrievalSemaphore . WaitAsync ( ) ;
return await HandleItemRetrievedAsync ( item , folder , downloadedMessageIds , cancellationToken ) ;
}
catch ( Exception ex )
{
_logger . Error ( ex , "Error occurred while handling 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 ) ;
2025-10-31 12:13:54 +01:00
2025-10-12 16:23:33 +02:00
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 ) ;
}
}
}
2024-05-25 17:00:52 +02:00
2025-02-16 11:54:23 +01:00
private bool IsResourceDeleted ( IDictionary < string , object > additionalData )
= > additionalData ! = null & & additionalData . ContainsKey ( "@removed" ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
private async Task < bool > HandleFolderRetrievedAsync ( MailFolder folder , OutlookSpecialFolderIdInformation outlookSpecialFolderIdInformation , CancellationToken cancellationToken = default )
{
if ( IsResourceDeleted ( folder . AdditionalData ) )
2025-02-16 11:35:43 +01:00
{
2025-02-16 11:54:23 +01:00
await _outlookChangeProcessor . DeleteFolderAsync ( Account . Id , folder . Id ) . ConfigureAwait ( false ) ;
}
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 ;
2024-04-18 01:44:37 +02:00
else
2025-02-16 11:54:23 +01:00
item . SpecialFolderType = SpecialFolderType . Other ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Automatically mark special folders as Sticky for better visibility.
item . IsSticky = item . SpecialFolderType ! = SpecialFolderType . Other ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// By default, all non-others are system folder.
item . IsSystemFolder = item . SpecialFolderType ! = SpecialFolderType . Other ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// By default, all special folders update unread count in the UI except Trash.
item . ShowUnreadCount = item . SpecialFolderType ! = SpecialFolderType . Deleted | | item . SpecialFolderType ! = SpecialFolderType . Other ;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
await _outlookChangeProcessor . InsertFolderAsync ( item ) . ConfigureAwait ( false ) ;
2025-02-16 11:35:43 +01:00
}
2025-02-16 11:54:23 +01:00
return true ;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="item">Retrieved message.</param>
/// <returns>Whether the item is non-Message type or not.</returns>
private bool IsNotRealMessageType ( Message item )
= > item is EventMessage | | item . From ? . EmailAddress = = null ;
private async Task < bool > HandleItemRetrievedAsync ( Message item , MailItemFolder folder , IList < string > downloadedMessageIds , CancellationToken cancellationToken = default )
{
if ( IsResourceDeleted ( item . AdditionalData ) )
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
// Deleting item with this override instead of the other one that deletes all mail copies.
// Outlook mails have 1 assignment per-folder, unlike Gmail that has one to many.
await _outlookChangeProcessor . DeleteAssignmentAsync ( Account . Id , item . Id , folder . RemoteFolderId ) . ConfigureAwait ( false ) ;
}
else
{
// If 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 )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
// Some of the properties of the item are updated.
if ( item . IsRead ! = null )
{
await _outlookChangeProcessor . ChangeMailReadStatusAsync ( item . Id , item . IsRead . GetValueOrDefault ( ) ) . ConfigureAwait ( false ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if ( item . Flag ? . FlagStatus ! = null )
{
await _outlookChangeProcessor . ChangeFlagStatusAsync ( item . Id , item . Flag . FlagStatus . GetValueOrDefault ( ) = = FollowupFlagStatus . Flagged )
. ConfigureAwait ( false ) ;
}
2024-04-18 01:44:37 +02:00
}
2024-08-05 00:36:26 +02:00
else
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
if ( IsNotRealMessageType ( item ) )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
if ( item is EventMessage eventMessage )
2024-08-05 00:36:26 +02:00
{
2025-02-16 11:54:23 +01:00
Log . Warning ( "Recieved event message. This is not supported yet. {Id}" , eventMessage . Id ) ;
2024-08-05 00:36:26 +02:00
}
2025-02-16 11:54:23 +01:00
else
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Log . Warning ( "Recieved either contact or todo item as message This is not supported yet. {Id}" , item . Id ) ;
2024-08-05 00:36:26 +02:00
}
2024-09-14 22:23:12 +02:00
2025-02-16 11:54:23 +01:00
return true ;
}
2024-08-05 00:36:26 +02:00
2025-02-16 11:54:23 +01:00
// Package may return null on some cases mapping the remote draft to existing local draft.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var newMailPackages = await CreateNewMailPackagesAsync ( item , folder , cancellationToken ) ;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
if ( newMailPackages ! = null )
{
foreach ( var package in newMailPackages )
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
// 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 )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
downloadedMessageIds . Add ( package . Copy . Id ) ;
2024-04-18 01:44:37 +02:00
}
}
}
}
}
2025-02-16 11:54:23 +01:00
return true ;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
private async Task SynchronizeFoldersAsync ( CancellationToken cancellationToken = default )
{
2025-02-26 19:59:11 +01:00
var specialFolderInfo = await GetSpecialFolderIdsAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
var graphFolders = await GetDeltaFoldersAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
var iterator = PageIterator < MailFolder , Microsoft . Graph . Me . MailFolders . Delta . DeltaGetResponse >
. CreatePageIterator ( _graphClient , graphFolders , ( folder ) = >
HandleFolderRetrievedAsync ( folder , specialFolderInfo , cancellationToken ) ) ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
await iterator . IterateAsync ( ) ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
await UpdateDeltaSynchronizationIdentifierAsync ( iterator . Deltalink ) . ConfigureAwait ( false ) ;
}
2024-04-18 01:44:37 +02:00
2025-05-18 14:06:25 +02:00
[RequiresUnreferencedCode("Calls Microsoft.Kiota.Abstractions.Serialization.KiotaJsonSerializer.DeserializeAsync<T>(String, CancellationToken)")]
2025-02-26 19:59:11 +01:00
private async Task < T > DeserializeGraphBatchResponseAsync < T > ( BatchResponseContentCollection collection , string requestId , CancellationToken cancellationToken = default ) where T : IParsable , new ( )
{
// This deserialization may throw generalException in case of failure.
// Bug: https://github.com/microsoftgraph/msgraph-sdk-dotnet/issues/2010
// This is a workaround for the bug to retrieve the actual exception.
// All generic batch response deserializations must go under this method.
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
try
{
return await collection . GetResponseByIdAsync < T > ( requestId ) ;
}
catch ( ODataError )
{
throw ;
}
catch ( ServiceException serviceException )
{
// Actual exception is hidden inside ServiceException.
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
ODataError errorResult = await KiotaJsonSerializer . DeserializeAsync < ODataError > ( serviceException . RawResponseBody , cancellationToken ) ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
throw new SynchronizerException ( "Outlook Error" , errorResult ) ;
}
}
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
private async Task < OutlookSpecialFolderIdInformation > GetSpecialFolderIdsAsync ( CancellationToken cancellationToken )
{
var wellKnownFolderIdBatch = new BatchRequestContentCollection ( _graphClient ) ;
var folderRequests = new Dictionary < string , RequestInformation >
{
{ INBOX_NAME , _graphClient . Me . MailFolders [ INBOX_NAME ] . ToGetRequestInformation ( ( t ) = > { t . QueryParameters . Select = [ "id" ] ; } ) } ,
{ SENT_NAME , _graphClient . Me . MailFolders [ SENT_NAME ] . ToGetRequestInformation ( ( t ) = > { t . QueryParameters . Select = [ "id" ] ; } ) } ,
{ DELETED_NAME , _graphClient . Me . MailFolders [ DELETED_NAME ] . ToGetRequestInformation ( ( t ) = > { t . QueryParameters . Select = [ "id" ] ; } ) } ,
{ JUNK_NAME , _graphClient . Me . MailFolders [ JUNK_NAME ] . ToGetRequestInformation ( ( t ) = > { t . QueryParameters . Select = [ "id" ] ; } ) } ,
{ DRAFTS_NAME , _graphClient . Me . MailFolders [ DRAFTS_NAME ] . ToGetRequestInformation ( ( t ) = > { t . QueryParameters . Select = [ "id" ] ; } ) } ,
{ ARCHIVE_NAME , _graphClient . Me . MailFolders [ ARCHIVE_NAME ] . ToGetRequestInformation ( ( t ) = > { t . QueryParameters . Select = [ "id" ] ; } ) }
} ;
var batchIds = new Dictionary < string , string > ( ) ;
foreach ( var request in folderRequests )
2025-02-16 11:54:23 +01:00
{
2025-02-26 19:59:11 +01:00
batchIds [ request . Key ] = await wellKnownFolderIdBatch . AddBatchRequestStepAsync ( request . Value ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
var returnedResponse = await _graphClient . Batch . PostAsync ( wellKnownFolderIdBatch , cancellationToken ) . ConfigureAwait ( false ) ;
var folderIds = new Dictionary < string , string > ( ) ;
foreach ( var batchId in batchIds )
{
folderIds [ batchId . Key ] = ( await DeserializeGraphBatchResponseAsync < MailFolder > ( returnedResponse , batchId . Value , cancellationToken ) ) . Id ;
}
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
return new OutlookSpecialFolderIdInformation (
folderIds [ INBOX_NAME ] ,
folderIds [ DELETED_NAME ] ,
folderIds [ JUNK_NAME ] ,
folderIds [ DRAFTS_NAME ] ,
folderIds [ SENT_NAME ] ,
folderIds [ ARCHIVE_NAME ] ) ;
}
private async Task < Microsoft . Graph . Me . MailFolders . Delta . DeltaGetResponse > GetDeltaFoldersAsync ( CancellationToken cancellationToken )
{
if ( string . IsNullOrEmpty ( Account . SynchronizationDeltaIdentifier ) )
{
var deltaRequest = _graphClient . Me . MailFolders . Delta . ToGetRequestInformation ( ) ;
2025-02-16 11:54:23 +01:00
deltaRequest . UrlTemplate = deltaRequest . UrlTemplate . Insert ( deltaRequest . UrlTemplate . Length - 1 , ",includehiddenfolders" ) ;
deltaRequest . QueryParameters . Add ( "includehiddenfolders" , "true" ) ;
2024-08-05 00:36:26 +02:00
2025-02-26 19:59:11 +01:00
return await _graphClient . RequestAdapter . SendAsync ( deltaRequest ,
2025-02-16 11:54:23 +01:00
Microsoft . Graph . Me . MailFolders . Delta . DeltaGetResponse . CreateFromDiscriminatorValue ,
cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
}
2024-08-05 00:36:26 +02:00
2025-02-26 19:59:11 +01:00
try
{
2025-02-16 11:54:23 +01:00
var deltaRequest = _graphClient . Me . MailFolders . Delta . ToGetRequestInformation ( ) ;
deltaRequest . UrlTemplate = deltaRequest . UrlTemplate . Insert ( deltaRequest . UrlTemplate . Length - 1 , ",%24deltaToken" ) ;
2025-02-26 19:59:11 +01:00
deltaRequest . QueryParameters . Add ( "%24deltaToken" , Account . SynchronizationDeltaIdentifier ) ;
2025-02-16 11:43:30 +01:00
2025-02-26 19:59:11 +01:00
return await _graphClient . RequestAdapter . SendAsync ( deltaRequest ,
2025-02-16 11:54:23 +01:00
Microsoft . Graph . Me . MailFolders . Delta . DeltaGetResponse . CreateFromDiscriminatorValue ,
cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
2024-04-18 01:44:37 +02:00
}
2025-10-12 16:23:33 +02:00
catch ( ApiException apiException )
2024-08-16 00:37:38 +02:00
{
2025-10-12 16:23:33 +02:00
// 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 ) ;
2025-10-31 12:13:54 +01:00
2025-10-12 16:23:33 +02:00
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 ;
}
2025-10-31 12:13:54 +01:00
2025-10-12 16:23:33 +02:00
// If a handler processed the error and it was 410, retry with fresh token
if ( apiException . ResponseStatusCode = = 410 )
{
return await GetDeltaFoldersAsync ( cancellationToken ) ;
}
2025-10-31 12:13:54 +01:00
2025-10-12 16:23:33 +02:00
// For other handled errors, we still need to throw since we can't return a meaningful response
throw ;
2025-02-26 19:59:11 +01:00
}
}
2024-08-16 00:37:38 +02:00
2025-02-26 19:59:11 +01:00
private async Task UpdateDeltaSynchronizationIdentifierAsync ( string deltalink )
{
if ( string . IsNullOrEmpty ( deltalink ) ) return ;
2024-09-12 00:50:49 +02:00
2025-02-26 19:59:11 +01:00
var deltaToken = deltalink . Split ( '=' ) [ 1 ] ;
var latestAccountDeltaToken = await _outlookChangeProcessor
. UpdateAccountDeltaSynchronizationIdentifierAsync ( Account . Id , deltaToken ) ;
2025-02-16 11:43:30 +01:00
2025-02-26 19:59:11 +01:00
if ( ! string . IsNullOrEmpty ( latestAccountDeltaToken ) )
{
Account . SynchronizationDeltaIdentifier = latestAccountDeltaToken ;
2024-08-16 00:37:38 +02:00
}
2025-02-16 11:54:23 +01:00
}
2024-08-16 00:37:38 +02:00
2025-02-16 11:54:23 +01:00
/// <summary>
/// Get the user's profile picture
/// </summary>
/// <returns>Base64 encoded profile picture.</returns>
private async Task < string > GetUserProfilePictureAsync ( )
{
try
2024-08-16 00:37:38 +02:00
{
2025-02-16 11:54:23 +01:00
var photoStream = await _graphClient . Me . Photos [ "48x48" ] . Content . GetAsync ( ) ;
2024-08-16 00:37:38 +02:00
2025-02-16 11:54:23 +01:00
using var memoryStream = new MemoryStream ( ) ;
await photoStream . CopyToAsync ( memoryStream ) ;
var byteArray = memoryStream . ToArray ( ) ;
return Convert . ToBase64String ( byteArray ) ;
2024-08-16 00:37:38 +02:00
}
2025-02-16 11:54:23 +01:00
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.
2024-08-16 00:37:38 +02:00
2025-02-16 11:54:23 +01:00
return string . Empty ;
}
catch ( Exception )
2024-08-16 00:37:38 +02:00
{
2025-02-16 11:54:23 +01:00
// 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.
2024-08-16 00:37:38 +02:00
2025-02-16 11:54:23 +01:00
return string . Empty ;
2024-08-16 00:37:38 +02:00
}
2025-02-16 11:54:23 +01:00
}
2024-08-16 00:37:38 +02:00
2025-02-16 11:54:23 +01:00
/// <summary>
/// Get the user's display name.
/// </summary>
/// <returns>Display name and address of the user.</returns>
private async Task < Tuple < string , string > > GetDisplayNameAndAddressAsync ( )
{
var userInfo = await _graphClient . Me . GetAsync ( ) ;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
return new Tuple < string , string > ( userInfo . DisplayName , userInfo . Mail ) ;
}
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
public override async Task < ProfileInformation > GetProfileInformationAsync ( )
{
var profilePictureData = await GetUserProfilePictureAsync ( ) . ConfigureAwait ( false ) ;
var displayNameAndAddress = await GetDisplayNameAndAddressAsync ( ) . ConfigureAwait ( false ) ;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
return new ProfileInformation ( displayNameAndAddress . Item1 , profilePictureData , displayNameAndAddress . Item2 ) ;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="requestInformation">Post request information.</param>
/// <param name="content">Content object to serialize.</param>
/// <returns>Updated post request information.</returns>
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 ;
}
2024-08-21 13:15:50 +02:00
2025-02-16 11:54:23 +01:00
#region Mail Integration
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override bool DelaySendOperationSynchronization ( ) = > true ;
2024-06-12 02:12:39 +02:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > Move ( BatchMoveRequest request )
{
return ForEachRequest ( request , ( item ) = >
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var requestBody = new Microsoft . Graph . Me . Messages . Item . Move . MovePostRequestBody ( )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
DestinationId = item . ToFolder . RemoteFolderId
} ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return PreparePostRequestInformation ( _graphClient . Me . Messages [ item . Item . Id ] . Move . ToPostRequestInformation ( requestBody ) ,
requestBody ) ;
} ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > ChangeFlag ( BatchChangeFlagRequest request )
{
return ForEachRequest ( request , ( item ) = >
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var message = new Message ( )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Flag = new FollowupFlag ( ) { FlagStatus = item . IsFlagged ? FollowupFlagStatus . Flagged : FollowupFlagStatus . NotFlagged }
} ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return _graphClient . Me . Messages [ item . Item . Id ] . ToPatchRequestInformation ( message ) ;
} ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > MarkRead ( BatchMarkReadRequest request )
{
return ForEachRequest ( request , ( item ) = >
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var message = new Message ( )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
IsRead = item . IsRead
} ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return _graphClient . Me . Messages [ item . Item . Id ] . ToPatchRequestInformation ( message ) ;
} ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > Delete ( BatchDeleteRequest request )
{
return ForEachRequest ( request , ( item ) = >
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
return _graphClient . Me . Messages [ item . Item . Id ] . ToDeleteRequestInformation ( ) ;
} ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > MoveToFocused ( BatchMoveToFocusedRequest request )
{
return ForEachRequest ( request , ( item ) = >
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
if ( item is MoveToFocusedRequest moveToFocusedRequest )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var message = new Message ( )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
InferenceClassification = moveToFocusedRequest . MoveToFocused ? InferenceClassificationType . Focused : InferenceClassificationType . Other
} ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return _graphClient . Me . Messages [ moveToFocusedRequest . Item . Id ] . ToPatchRequestInformation ( message ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
throw new Exception ( "Invalid request type." ) ;
} ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > AlwaysMoveTo ( BatchAlwaysMoveToRequest request )
{
return ForEachRequest ( request , ( item ) = >
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var inferenceClassificationOverride = new InferenceClassificationOverride
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
ClassifyAs = item . MoveToFocused ? InferenceClassificationType . Focused : InferenceClassificationType . Other ,
SenderEmailAddress = new EmailAddress
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Name = item . Item . FromName ,
Address = item . Item . FromAddress
}
} ;
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
return _graphClient . Me . InferenceClassification . Overrides . ToPostRequestInformation ( inferenceClassificationOverride ) ;
} ) ;
}
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > CreateDraft ( CreateDraftRequest createDraftRequest )
{
var reason = createDraftRequest . DraftPreperationRequest . Reason ;
var message = createDraftRequest . DraftPreperationRequest . CreatedLocalDraftMimeMessage . AsOutlookMessage ( true ) ;
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
if ( reason = = DraftCreationReason . Empty )
{
return [ new HttpRequestBundle < RequestInformation > ( _graphClient . Me . Messages . ToPostRequestInformation ( message ) , createDraftRequest ) ] ;
}
else if ( reason = = DraftCreationReason . Reply )
{
return [ new HttpRequestBundle < RequestInformation > ( _graphClient . Me . Messages [ createDraftRequest . DraftPreperationRequest . ReferenceMailCopy . Id ] . CreateReply . ToPostRequestInformation ( new Microsoft . Graph . Me . Messages . Item . CreateReply . CreateReplyPostRequestBody ( )
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Message = message
} ) , createDraftRequest ) ] ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
}
else if ( reason = = DraftCreationReason . ReplyAll )
{
return [ new HttpRequestBundle < RequestInformation > ( _graphClient . Me . Messages [ createDraftRequest . DraftPreperationRequest . ReferenceMailCopy . Id ] . CreateReplyAll . ToPostRequestInformation ( new Microsoft . Graph . Me . Messages . Item . CreateReplyAll . CreateReplyAllPostRequestBody ( )
2024-11-26 20:03:10 +01:00
{
2025-02-16 11:54:23 +01:00
Message = message
} ) , createDraftRequest ) ] ;
}
else if ( reason = = DraftCreationReason . Forward )
{
return [ new HttpRequestBundle < RequestInformation > ( _graphClient . Me . Messages [ createDraftRequest . DraftPreperationRequest . ReferenceMailCopy . Id ] . CreateForward . ToPostRequestInformation ( new Microsoft . Graph . Me . Messages . Item . CreateForward . CreateForwardPostRequestBody ( )
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
Message = message
} ) , createDraftRequest ) ] ;
2025-02-16 11:35:43 +01:00
}
2025-02-16 11:54:23 +01:00
else
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
throw new NotImplementedException ( "Draft creation reason is not implemented." ) ;
}
}
2024-06-11 14:16:57 +02:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > SendDraft ( SendDraftRequest request )
{
var sendDraftPreparationRequest = request . Request ;
2024-06-11 14:16:57 +02:00
2025-02-16 11:54:23 +01:00
// 1. Delete draft
// 2. Create new Message with new MIME.
// 3. Make sure that conversation id is tagged correctly for replies.
2024-06-11 14:16:57 +02:00
2025-02-16 11:54:23 +01:00
var mailCopyId = sendDraftPreparationRequest . MailItem . Id ;
var mimeMessage = sendDraftPreparationRequest . Mime ;
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
// Convert mime message to Outlook message.
// Outlook synchronizer does not send MIME messages directly anymore.
// Alias support is lacking with direct MIMEs.
// Therefore we convert the MIME message to Outlook message and use proper APIs.
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
var outlookMessage = mimeMessage . AsOutlookMessage ( false ) ;
2024-06-11 14:16:57 +02:00
2025-02-16 11:54:23 +01:00
// Create attachment requests.
// TODO: We need to support large file attachments with sessioned upload at some point.
2024-08-19 03:44:16 +02:00
2025-02-16 11:54:23 +01:00
var attachmentRequestList = CreateAttachmentUploadBundles ( mimeMessage , mailCopyId , request ) . ToList ( ) ;
2024-08-19 03:44:16 +02:00
2025-02-16 11:54:23 +01:00
// Update draft.
2024-08-19 03:44:16 +02:00
2025-02-16 11:54:23 +01:00
var patchDraftRequest = _graphClient . Me . Messages [ mailCopyId ] . ToPatchRequestInformation ( outlookMessage ) ;
var patchDraftRequestBundle = new HttpRequestBundle < RequestInformation > ( patchDraftRequest , request ) ;
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
// Send draft.
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
var sendDraftRequest = PreparePostRequestInformation ( _graphClient . Me . Messages [ mailCopyId ] . Send . ToPostRequestInformation ( ) ) ;
var sendDraftRequestBundle = new HttpRequestBundle < RequestInformation > ( sendDraftRequest , request ) ;
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
return [ . . attachmentRequestList , patchDraftRequestBundle , sendDraftRequestBundle ] ;
}
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
private List < IRequestBundle < RequestInformation > > CreateAttachmentUploadBundles ( MimeMessage mime , string mailCopyId , IRequestBase sourceRequest )
{
var allAttachments = new List < OutlookFileAttachment > ( ) ;
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
foreach ( var part in mime . BodyParts )
{
var isAttachmentOrInline = part . IsAttachment ? true : part . ContentDisposition ? . Disposition = = "inline" ;
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
if ( ! isAttachmentOrInline ) continue ;
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
using var memory = new MemoryStream ( ) ;
( ( MimePart ) part ) . Content . DecodeTo ( memory ) ;
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
var base64String = Convert . ToBase64String ( memory . ToArray ( ) ) ;
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
var attachment = new OutlookFileAttachment ( )
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
Base64EncodedContentBytes = base64String ,
FileName = part . ContentDisposition ? . FileName ? ? part . ContentType . Name ,
ContentId = part . ContentId ,
ContentType = part . ContentType . MimeType ,
IsInline = part . ContentDisposition ? . Disposition = = "inline"
} ;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
allAttachments . Add ( attachment ) ;
}
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
static RequestInformation PrepareUploadAttachmentRequest ( RequestInformation requestInformation , OutlookFileAttachment outlookFileAttachment )
{
requestInformation . Headers . Clear ( ) ;
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
string contentJson = JsonSerializer . Serialize ( outlookFileAttachment , OutlookSynchronizerJsonContext . Default . OutlookFileAttachment ) ;
requestInformation . Content = new MemoryStream ( Encoding . UTF8 . GetBytes ( contentJson ) ) ;
requestInformation . HttpMethod = Method . POST ;
requestInformation . Headers . Add ( "Content-Type" , "application/json" ) ;
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
return requestInformation ;
}
2024-09-05 17:23:15 +02:00
2025-02-16 11:54:23 +01:00
var retList = new List < IRequestBundle < RequestInformation > > ( ) ;
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
// Prepare attachment upload requests.
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
foreach ( var attachment in allAttachments )
{
var emptyPostRequest = _graphClient . Me . Messages [ mailCopyId ] . Attachments . ToPostRequestInformation ( new Attachment ( ) ) ;
var modifiedAttachmentUploadRequest = PrepareUploadAttachmentRequest ( emptyPostRequest , attachment ) ;
2024-06-11 14:16:57 +02:00
2025-02-16 11:54:23 +01:00
var bundle = new HttpRequestBundle < RequestInformation > ( modifiedAttachmentUploadRequest , null ) ;
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
retList . Add ( bundle ) ;
2025-02-16 11:43:30 +01:00
}
2024-06-21 23:48:03 +02:00
2025-02-16 11:54:23 +01:00
return retList ;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > Archive ( BatchArchiveRequest request )
{
var batchMoveRequest = new BatchMoveRequest ( request . Select ( item = > new MoveRequest ( item . Item , item . FromFolder , item . ToFolder ) ) ) ;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
return Move ( batchMoveRequest ) ;
}
2024-07-09 01:05:16 +02:00
2025-10-03 15:46:38 +02:00
public override async Task DownloadMissingMimeMessageAsync ( MailCopy mailItem ,
2025-02-16 11:54:23 +01:00
MailKit . ITransferProgress transferProgress = null ,
CancellationToken cancellationToken = default )
{
var mimeMessage = await DownloadMimeMessageAsync ( mailItem . Id , cancellationToken ) . ConfigureAwait ( false ) ;
await _outlookChangeProcessor . SaveMimeFileAsync ( mailItem . FileId , mimeMessage , Account . Id ) . ConfigureAwait ( false ) ;
}
public override List < IRequestBundle < RequestInformation > > RenameFolder ( RenameFolderRequest request )
{
var requestBody = new MailFolder
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
DisplayName = request . NewFolderName ,
} ;
2024-07-09 01:05:16 +02:00
2025-02-16 11:54:23 +01:00
var networkCall = _graphClient . Me . MailFolders [ request . Folder . RemoteFolderId ] . ToPatchRequestInformation ( requestBody ) ;
2024-07-09 01:05:16 +02:00
2025-02-16 11:54:23 +01:00
return [ new HttpRequestBundle < RequestInformation > ( networkCall , request ) ] ;
}
2024-07-09 01:05:16 +02:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > EmptyFolder ( EmptyFolderRequest request )
= > Delete ( new BatchDeleteRequest ( request . MailsToDelete . Select ( a = > new DeleteRequest ( a ) ) ) ) ;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
public override List < IRequestBundle < RequestInformation > > MarkFolderAsRead ( MarkFolderAsReadRequest request )
= > MarkRead ( new BatchMarkReadRequest ( request . MailsToMarkRead . Select ( a = > new MarkReadRequest ( a , true ) ) ) ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
#endregion
public override async Task ExecuteNativeRequestsAsync ( List < IRequestBundle < RequestInformation > > batchedRequests , CancellationToken cancellationToken = default )
{
2025-02-26 19:59:11 +01:00
var batchedGroups = batchedRequests . Batch ( ( int ) MaximumAllowedBatchRequestSize ) ;
2025-02-16 11:35:43 +01:00
2025-02-26 19:59:11 +01:00
foreach ( var batch in batchedGroups )
2024-04-18 01:44:37 +02:00
{
2025-02-26 19:59:11 +01:00
await ExecuteBatchRequestsAsync ( batch , cancellationToken ) ;
}
}
2024-08-21 13:15:50 +02:00
2025-02-26 19:59:11 +01:00
private async Task ExecuteBatchRequestsAsync ( IEnumerable < IRequestBundle < RequestInformation > > batch , CancellationToken cancellationToken )
{
var batchContent = new BatchRequestContentCollection ( _graphClient ) ;
var itemCount = batch . Count ( ) ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
if ( itemCount = = 0 ) return ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
var bundleIdMap = await PrepareBatchContentAsync ( batch , batchContent , itemCount ) ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
// Execute batch to collect responses from network call
var batchRequestResponse = await _graphClient . Batch . PostAsync ( batchContent , cancellationToken ) ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
await ProcessBatchResponsesAsync ( batch , batchRequestResponse , bundleIdMap ) ;
}
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
private async Task < Dictionary < string , IRequestBundle < RequestInformation > > > PrepareBatchContentAsync (
IEnumerable < IRequestBundle < RequestInformation > > batch ,
BatchRequestContentCollection batchContent ,
int itemCount )
{
var bundleIdMap = new Dictionary < string , IRequestBundle < RequestInformation > > ( ) ;
bool requiresSerial = false ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
for ( int i = 0 ; i < itemCount ; i + + )
{
var bundle = batch . ElementAt ( i ) ;
requiresSerial | = bundle . UIChangeRequest is SendDraftRequest ;
2024-08-19 03:44:16 +02:00
2025-02-26 19:59:11 +01:00
bundle . UIChangeRequest ? . ApplyUIChanges ( ) ;
var batchRequestId = await batchContent . AddBatchRequestStepAsync ( bundle . NativeRequest ) ;
bundle . BundleId = batchRequestId ;
bundleIdMap [ batchRequestId ] = bundle ;
}
2025-02-16 11:43:30 +01:00
2025-02-26 19:59:11 +01:00
if ( requiresSerial )
{
ConfigureSerialExecution ( batchContent ) ;
}
2024-08-19 03:44:16 +02:00
2025-02-26 19:59:11 +01:00
return bundleIdMap ;
}
2024-08-19 03:44:16 +02:00
2025-02-26 19:59:11 +01:00
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 ] ;
}
}
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
private async Task ProcessBatchResponsesAsync (
IEnumerable < IRequestBundle < RequestInformation > > batch ,
BatchResponseContentCollection batchResponse ,
Dictionary < string , IRequestBundle < RequestInformation > > bundleIdMap )
{
var errors = new List < string > ( ) ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
foreach ( var bundleId in bundleIdMap . Keys )
{
var bundle = bundleIdMap [ bundleId ] ;
var response = await batchResponse . GetResponseByIdAsync ( bundleId ) ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
if ( response = = null ) continue ;
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
using ( response )
2025-02-16 11:54:23 +01:00
{
2025-02-26 19:59:11 +01:00
if ( ! response . IsSuccessStatusCode )
2025-02-16 11:54:23 +01:00
{
2025-02-26 19:59:11 +01:00
await HandleFailedResponseAsync ( bundle , response , errors ) ;
}
}
}
2024-08-21 13:15:50 +02:00
2025-02-26 19:59:11 +01:00
if ( errors . Any ( ) )
{
ThrowBatchExecutionException ( errors ) ;
}
}
2024-12-22 00:49:55 +01:00
2025-02-26 19:59:11 +01:00
private async Task HandleFailedResponseAsync (
IRequestBundle < RequestInformation > bundle ,
HttpResponseMessage response ,
List < string > errors )
{
var content = await response . Content . ReadAsStringAsync ( ) ;
var errorJson = JsonNode . Parse ( content ) ;
2025-04-26 10:49:55 +02:00
var errorCode = errorJson [ "error" ] [ "code" ] . GetValue < string > ( ) ;
var errorMessage = errorJson [ "error" ] [ "message" ] . GetValue < string > ( ) ;
var errorString = $"[{response.StatusCode}] {errorCode} - {errorMessage}\n" ;
// Create error context
var errorContext = new SynchronizerErrorContext
{
Account = Account ,
ErrorCode = ( int ) response . StatusCode ,
ErrorMessage = errorMessage ,
RequestBundle = bundle ,
AdditionalData = new Dictionary < string , object >
{
{ "ErrorCode" , errorCode } ,
{ "HttpResponse" , response } ,
{ "Content" , content }
}
} ;
// Try to handle the error with registered handlers
var handled = await _errorHandlingFactory . HandleErrorAsync ( errorContext ) ;
2024-04-18 01:44:37 +02:00
2025-04-26 10:49:55 +02:00
// If not handled by any specific handler, revert UI changes and add to error list
if ( ! handled )
{
bundle . UIChangeRequest ? . RevertUIChanges ( ) ;
Debug . WriteLine ( errorString ) ;
errors . Add ( errorString ) ;
}
2025-02-26 19:59:11 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-26 19:59:11 +01:00
private void ThrowBatchExecutionException ( List < string > errors )
{
var formattedErrorString = string . Join ( "\n" ,
errors . Select ( ( item , index ) = > $"{index + 1}. {item}" ) ) ;
throw new SynchronizerException ( formattedErrorString ) ;
2025-02-16 11:54:23 +01:00
}
2024-05-25 17:00:52 +02:00
2025-02-22 00:22:00 +01:00
public override async Task < List < MailCopy > > OnlineSearchAsync ( string queryText , List < IMailItemFolder > folders , CancellationToken cancellationToken = default )
{
2025-02-24 18:53:11 +01:00
List < Message > messagesReturnedByApi = [ ] ;
2025-02-22 00:22:00 +01:00
// Perform search for each folder separately.
2025-02-24 18:53:11 +01:00
if ( folders ? . Count > 0 )
2025-02-22 00:22:00 +01:00
{
var folderIds = folders . Select ( a = > a . RemoteFolderId ) ;
var tasks = folderIds . Select ( async folderId = >
{
var mailQuery = _graphClient . Me . MailFolders [ folderId ] . Messages
. GetAsync ( requestConfig = >
{
requestConfig . QueryParameters . Search = $"\" { queryText } \ "" ;
requestConfig . QueryParameters . Select = [ "Id, ParentFolderId" ] ;
requestConfig . QueryParameters . Top = 1000 ;
} ) ;
var result = await mailQuery ;
if ( result ? . Value ! = null )
{
2025-02-24 18:53:11 +01:00
lock ( messagesReturnedByApi )
2025-02-22 00:22:00 +01:00
{
2025-02-24 18:53:11 +01:00
messagesReturnedByApi . AddRange ( result . Value ) ;
2025-02-22 00:22:00 +01:00
}
}
} ) ;
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 ;
2025-02-24 18:53:11 +01:00
} , cancellationToken ) ;
2025-02-22 00:22:00 +01:00
var result = await mailQuery ;
if ( result ? . Value ! = null )
{
2025-02-24 18:53:11 +01:00
messagesReturnedByApi . AddRange ( result . Value ) ;
2025-02-22 00:22:00 +01:00
}
}
2025-02-24 18:53:11 +01:00
if ( messagesReturnedByApi . Count = = 0 ) return [ ] ;
2025-02-22 00:22:00 +01:00
2025-02-24 18:53:11 +01:00
var localFolders = ( await _outlookChangeProcessor . GetLocalFoldersAsync ( Account . Id ) . ConfigureAwait ( false ) )
. ToDictionary ( x = > x . RemoteFolderId ) ;
2025-02-22 00:22:00 +01:00
2025-02-24 18:53:11 +01:00
var messagesDictionary = messagesReturnedByApi . ToDictionary ( a = > a . Id ) ;
2025-02-22 00:22:00 +01:00
2025-02-24 18:53:11 +01:00
// Contains a list of message ids that potentially can be downloaded.
List < string > messageIdsWithKnownFolder = [ ] ;
2025-02-22 00:22:00 +01:00
2025-02-24 18:53:11 +01:00
// Validate that all messages are in a known folder.
foreach ( var message in messagesReturnedByApi )
{
if ( ! localFolders . ContainsKey ( message . ParentFolderId ) )
2025-02-22 00:22:00 +01:00
{
2025-02-24 18:53:11 +01:00
Log . Warning ( "Search result returned a message from a folder that is not synchronized." ) ;
2025-02-22 00:22:00 +01:00
continue ;
}
2025-02-24 18:53:11 +01:00
messageIdsWithKnownFolder . Add ( message . Id ) ;
}
2025-02-22 00:22:00 +01:00
2025-02-24 18:53:11 +01:00
var locallyExistingMails = await _outlookChangeProcessor . AreMailsExistsAsync ( messageIdsWithKnownFolder ) . ConfigureAwait ( false ) ;
2025-02-22 00:22:00 +01:00
2025-02-24 18:53:11 +01:00
// Find messages that are not downloaded yet.
List < Message > messagesToDownload = [ ] ;
foreach ( var id in messagesDictionary . Keys . Except ( locallyExistingMails ) )
{
messagesToDownload . Add ( messagesDictionary [ id ] ) ;
}
2025-02-22 00:22:00 +01:00
2025-02-24 18:53:11 +01:00
foreach ( var message in messagesToDownload )
{
await DownloadSearchResultMessageAsync ( message . Id , localFolders [ message . ParentFolderId ] , cancellationToken ) . ConfigureAwait ( false ) ;
2025-02-22 00:22:00 +01:00
}
// Get results from database and return.
2025-02-24 18:53:11 +01:00
return await _outlookChangeProcessor . GetMailCopiesAsync ( messageIdsWithKnownFolder ) . ConfigureAwait ( false ) ;
2025-02-22 00:22:00 +01:00
}
2025-02-16 11:54:23 +01:00
private async Task < MimeMessage > DownloadMimeMessageAsync ( string messageId , CancellationToken cancellationToken = default )
{
var mimeContentStream = await _graphClient . Me . Messages [ messageId ] . Content . GetAsync ( null , cancellationToken ) . ConfigureAwait ( false ) ;
2025-02-24 18:53:11 +01:00
return await MimeMessage . LoadAsync ( mimeContentStream , cancellationToken ) . ConfigureAwait ( false ) ;
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override async Task < List < NewMailItemPackage > > CreateNewMailPackagesAsync ( Message message , MailItemFolder assignedFolder , CancellationToken cancellationToken = default )
{
2025-10-31 00:51:27 +01:00
// Download MIME message for specific scenarios (e.g., search results, draft handling)
// During normal sync, this method should not be called - use CreateMailCopyFromMessage instead
2025-02-16 11:54:23 +01:00
var mimeMessage = await DownloadMimeMessageAsync ( message . Id , cancellationToken ) . ConfigureAwait ( false ) ;
2025-10-31 00:51:27 +01:00
var mailCopy = CreateMailCopyFromMessage ( message , assignedFolder ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if ( message . IsDraft . GetValueOrDefault ( )
& & mimeMessage . Headers . Contains ( Domain . Constants . WinoLocalDraftHeader )
& & Guid . TryParse ( mimeMessage . Headers [ Domain . Constants . WinoLocalDraftHeader ] , out Guid localDraftCopyUniqueId ) )
{
// This message belongs to existing local draft copy.
// We don't need to create a new mail copy for this message, just update the existing one.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
bool isMappingSuccessful = await _outlookChangeProcessor . MapLocalDraftAsync ( Account . Id , localDraftCopyUniqueId , mailCopy . Id , mailCopy . DraftId , mailCopy . ThreadId ) ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if ( isMappingSuccessful ) return null ;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Local copy doesn't exists. Continue execution to insert mail copy.
}
2024-12-24 18:30:25 +01:00
2025-02-16 11:54:23 +01:00
// Outlook messages can only be assigned to 1 folder at a time.
// Therefore we don't need to create multiple copies of the same message for different folders.
var package = new NewMailItemPackage ( mailCopy , mimeMessage , assignedFolder . RemoteFolderId ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
return [ package ] ;
}
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
protected override async Task < CalendarSynchronizationResult > SynchronizeCalendarEventsInternalAsync ( CalendarSynchronizationOptions options , CancellationToken cancellationToken = default )
{
_logger . Information ( "Internal calendar synchronization started for {Name}" , Account . Name ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
cancellationToken . ThrowIfCancellationRequested ( ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
await SynchronizeCalendarsAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
2025-01-06 21:56:33 +01:00
2025-02-16 11:54:23 +01:00
var localCalendars = await _outlookChangeProcessor . GetAccountCalendarsAsync ( Account . Id ) . ConfigureAwait ( false ) ;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
Microsoft . Graph . Me . Calendars . Item . CalendarView . Delta . DeltaGetResponse eventsDeltaResponse = null ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
// TODO: Maybe we can batch each calendar?
foreach ( var calendar in localCalendars )
{
bool isInitialSync = string . IsNullOrEmpty ( calendar . SynchronizationDeltaToken ) ;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
if ( isInitialSync )
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
_logger . Information ( "No calendar sync identifier for calendar {Name}. Performing initial sync." , calendar . Name ) ;
var startDate = DateTime . UtcNow . AddYears ( - 2 ) . ToString ( "u" ) ;
var endDate = DateTime . UtcNow . ToString ( "u" ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
eventsDeltaResponse = await _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . CalendarView . Delta . GetAsDeltaGetResponseAsync ( ( requestConfiguration ) = >
2025-01-06 02:15:21 +01:00
{
2025-02-16 11:54:23 +01:00
requestConfiguration . QueryParameters . StartDateTime = startDate ;
requestConfiguration . QueryParameters . EndDateTime = endDate ;
} , cancellationToken : cancellationToken ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
// No delta link. Performing initial sync.
//eventsDeltaResponse = await _graphClient.Me.CalendarView.Delta.GetAsDeltaGetResponseAsync((requestConfiguration) =>
//{
// requestConfiguration.QueryParameters.StartDateTime = startDate;
// requestConfiguration.QueryParameters.EndDateTime = endDate;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
// // TODO: Expand does not work.
// // https://github.com/microsoftgraph/msgraph-sdk-dotnet/issues/2358
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
// requestConfiguration.QueryParameters.Expand = new string[] { "calendar($select=name,id)" }; // Expand the calendar and select name and id. Customize as needed.
//}, cancellationToken: cancellationToken);
}
else
{
var currentDeltaToken = calendar . SynchronizationDeltaToken ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
_logger . Information ( "Performing delta sync for calendar {Name}." , calendar . Name ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
var requestInformation = _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . CalendarView . Delta . ToGetRequestInformation ( ( requestConfiguration ) = >
2025-01-07 13:42:10 +01:00
{
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
//requestConfiguration.QueryParameters.StartDateTime = startDate;
//requestConfiguration.QueryParameters.EndDateTime = endDate;
} ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
//var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation((config) =>
//{
// config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
// config.QueryParameters.Select = outlookMessageSelectParameters;
// config.QueryParameters.Orderby = ["receivedDateTime desc"];
//});
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
requestInformation . UrlTemplate = requestInformation . UrlTemplate . Insert ( requestInformation . UrlTemplate . Length - 1 , ",%24deltatoken" ) ;
requestInformation . QueryParameters . Add ( "%24deltatoken" , currentDeltaToken ) ;
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
eventsDeltaResponse = await _graphClient . RequestAdapter . SendAsync ( requestInformation , Microsoft . Graph . Me . Calendars . Item . CalendarView . Delta . DeltaGetResponse . CreateFromDiscriminatorValue ) ;
}
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
List < Event > events = new ( ) ;
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
// 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.
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
var messageIteratorAsync = PageIterator < Event , Microsoft . Graph . Me . Calendars . Item . CalendarView . Delta . DeltaGetResponse > . CreatePageIterator ( _graphClient , eventsDeltaResponse , ( item ) = >
{
events . Add ( item ) ;
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
return true ;
} ) ;
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
await messageIteratorAsync
. IterateAsync ( cancellationToken )
. ConfigureAwait ( false ) ;
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
// Desc-order will move parent recurring events to the top.
events = events . OrderByDescending ( a = > a . Type ) . ToList ( ) ;
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
_logger . Information ( "Found {Count} events in total." , events . Count ) ;
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
foreach ( var item in events )
{
try
2025-02-16 11:35:43 +01:00
{
2025-02-16 11:54:23 +01:00
await _handleItemRetrievalSemaphore . WaitAsync ( ) ;
await _outlookChangeProcessor . ManageCalendarEventAsync ( item , calendar , Account ) . ConfigureAwait ( false ) ;
2025-02-16 11:35:43 +01:00
}
2025-02-16 11:54:23 +01:00
catch ( Exception )
{
// _logger.Error(ex, "Error occurred while handling item {Id} for calendar {Name}", item.Id, calendar.Name);
}
finally
{
_handleItemRetrievalSemaphore . Release ( ) ;
}
}
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
var latestDeltaLink = messageIteratorAsync . Deltalink ;
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
//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.
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
var deltaToken = GetDeltaTokenFromDeltaLink ( latestDeltaLink ) ;
2025-01-07 13:42:10 +01:00
2025-02-16 11:54:23 +01:00
await _outlookChangeProcessor . UpdateCalendarDeltaSynchronizationToken ( calendar . Id , deltaToken ) . ConfigureAwait ( false ) ;
2025-01-06 02:15:21 +01:00
}
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
return default ;
}
private async Task SynchronizeCalendarsAsync ( CancellationToken cancellationToken = default )
{
var calendars = await _graphClient . Me . Calendars . GetAsync ( cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
var localCalendars = await _outlookChangeProcessor . GetAccountCalendarsAsync ( Account . Id ) . ConfigureAwait ( false ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
List < AccountCalendar > insertedCalendars = new ( ) ;
List < AccountCalendar > updatedCalendars = new ( ) ;
List < AccountCalendar > deletedCalendars = new ( ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
// 1. Handle deleted calendars.
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
foreach ( var calendar in localCalendars )
{
var remoteCalendar = calendars . Value . FirstOrDefault ( a = > a . Id = = calendar . RemoteCalendarId ) ;
if ( remoteCalendar = = null )
2025-01-06 02:15:21 +01:00
{
2025-02-16 11:54:23 +01:00
// Local calendar doesn't exists remotely. Delete local copy.
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
await _outlookChangeProcessor . DeleteAccountCalendarAsync ( calendar ) . ConfigureAwait ( false ) ;
deletedCalendars . Add ( calendar ) ;
2025-01-06 02:15:21 +01:00
}
2025-02-16 11:54:23 +01:00
}
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
// Delete the deleted folders from local list.
deletedCalendars . ForEach ( a = > localCalendars . Remove ( a ) ) ;
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
// 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 localCalendar = calendar . AsCalendar ( Account ) ;
insertedCalendars . Add ( localCalendar ) ;
}
else
2025-01-06 02:15:21 +01:00
{
2025-02-16 11:54:23 +01:00
// Update existing calendar. Right now we only update the name.
if ( ShouldUpdateCalendar ( calendar , existingLocalCalendar ) )
2025-01-06 02:15:21 +01:00
{
2025-02-16 11:54:23 +01:00
existingLocalCalendar . Name = calendar . Name ;
updatedCalendars . Add ( existingLocalCalendar ) ;
2025-01-06 02:15:21 +01:00
}
else
{
2025-02-16 11:54:23 +01:00
// Remove it from the local folder list to skip additional calendar updates.
localCalendars . Remove ( existingLocalCalendar ) ;
2025-01-06 02:15:21 +01:00
}
}
2025-02-16 11:54:23 +01:00
}
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
// 3.Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach ( var calendar in insertedCalendars )
{
await _outlookChangeProcessor . InsertAccountCalendarAsync ( calendar ) . ConfigureAwait ( false ) ;
}
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
foreach ( var calendar in updatedCalendars )
{
await _outlookChangeProcessor . UpdateAccountCalendarAsync ( calendar ) . ConfigureAwait ( false ) ;
2025-02-16 11:35:43 +01:00
}
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
if ( insertedCalendars . Any ( ) | | deletedCalendars . Any ( ) | | updatedCalendars . Any ( ) )
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
// TODO: Notify calendar updates.
// WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id));
}
}
2025-01-06 02:15:21 +01:00
2025-02-16 11:54:23 +01:00
private bool ShouldUpdateCalendar ( Calendar calendar , AccountCalendar accountCalendar )
{
// TODO: Only calendar name is updated for now. We can add more checks here.
2025-02-15 12:53:32 +01:00
2025-02-16 11:54:23 +01:00
var remoteCalendarName = calendar . Name ;
var localCalendarName = accountCalendar . Name ;
2025-02-15 12:53:32 +01:00
2025-02-16 11:54:23 +01:00
return ! localCalendarName . Equals ( remoteCalendarName , StringComparison . OrdinalIgnoreCase ) ;
}
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
public override async Task KillSynchronizerAsync ( )
{
await base . KillSynchronizerAsync ( ) ;
_graphClient . Dispose ( ) ;
2024-04-18 01:44:37 +02:00
}
}