2024-04-18 01:44:37 +02:00
using System ;
using System.Collections.Generic ;
using System.Diagnostics ;
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-11-23 17:05:11 +01:00
using Wino.Core.Domain ;
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 ;
2025-12-30 11:59:54 +01:00
using Wino.Core.Requests.Calendar ;
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>
2025-11-01 12:11:05 +01:00
/// Outlook synchronizer implementation with delta token synchronization.
2025-10-31 00:51:27 +01:00
///
/// SYNCHRONIZATION STRATEGY:
2025-11-01 12:11:05 +01:00
/// - Uses delta API for both initial and incremental sync
/// - Initial sync: Downloads last 30 days of emails with metadata only
/// - Incremental sync: Uses delta token to get only changes since last sync
/// - Messages are downloaded with metadata only (no MIME content during sync)
2025-10-31 00:51:27 +01:00
/// - MIME files are downloaded on-demand when user explicitly reads a message
///
/// Key implementation details:
2025-11-01 12:11:05 +01:00
/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
/// - DownloadMailsForInitialSyncAsync: Downloads last 30 days using delta API with filter
/// - ProcessDeltaChangesAsync: Processes incremental changes using delta token
/// - DownloadMessageMetadataBatchAsync: Downloads metadata in batches using Graph batch API
/// - CreateMailCopyFromMessageAsync: Creates MailCopy from Message metadata
2025-10-31 00:51:27 +01:00
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
/// </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",
2025-11-01 01:04:04 +01:00
"InternetMessageHeaders",
2025-02-16 11:54:23 +01:00
] ;
private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new ( 1 ) ;
2025-12-30 23:32:00 +01:00
private readonly SemaphoreSlim _handleCalendarEventRetrievalSemaphore = new ( 1 ) ;
2025-02-16 11:54:23 +01:00
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 ( ) ) ;
2025-10-31 19:53:48 +01:00
handlers . Add ( GetGraphRateLimitHandler ( ) ) ;
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-10-31 19:53:48 +01:00
private GraphRateLimitHandler GetGraphRateLimitHandler ( ) = > new ( ) ;
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
2025-11-23 17:04:38 +01:00
var progressPercentage = ( int ) Math . Round ( ( double ) ( i + 1 ) / totalFolders * 100 ) ;
var statusMessage = string . Format ( Translator . Sync_SynchronizingFolder , folder . FolderName , progressPercentage ) ;
UpdateSyncProgress ( totalFolders , totalFolders - ( i + 1 ) , statusMessage ) ;
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-11-01 12:11:05 +01:00
_logger . Debug ( "Synchronizing {FolderName} using delta API" , folder . FolderName ) ;
2024-07-09 01:05:16 +02:00
2025-11-01 12:11:05 +01:00
// Check if we have a delta token
if ( string . IsNullOrEmpty ( folder . DeltaToken ) )
2025-10-12 16:23:33 +02:00
{
2025-11-01 12:11:05 +01:00
_logger . Debug ( "No delta token for folder {FolderName}. Starting initial sync (last 30 days)." , folder . FolderName ) ;
2024-04-18 01:44:37 +02:00
2025-11-01 12:11:05 +01:00
// Download mails for initial sync (last 30 days)
2025-10-12 16:23:33 +02:00
await DownloadMailsForInitialSyncAsync ( folder , downloadedMessageIds , cancellationToken ) . ConfigureAwait ( false ) ;
}
else
2025-02-16 11:54:23 +01:00
{
2025-11-01 12:11:05 +01:00
// Initial sync is completed, process delta changes
_logger . Debug ( "Delta token exists for folder {FolderName}. Processing incremental changes." , folder . FolderName ) ;
2025-10-12 16:23:33 +02:00
2025-11-01 12:11:05 +01:00
await ProcessDeltaChangesAsync ( folder , downloadedMessageIds , cancellationToken ) . ConfigureAwait ( false ) ;
2025-10-12 16:23:33 +02:00
}
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-11-01 12:11:05 +01:00
/// Downloads mails for initial synchronization using Delta API with 30-day filter.
/// Downloads metadata only (no MIME content) for messages received in the last 30 days.
2025-10-12 16:23:33 +02:00
/// </summary>
private async Task DownloadMailsForInitialSyncAsync ( MailItemFolder folder , List < string > downloadedMessageIds , CancellationToken cancellationToken )
{
2025-11-01 12:11:05 +01:00
_logger . Debug ( "Starting initial mail download for folder {FolderName} (last 6 months)" , folder . FolderName ) ;
2025-10-12 16:23:33 +02:00
try
2025-02-16 11:54:23 +01:00
{
2025-11-01 12:11:05 +01:00
// Calculate date 6 months ago
var sixMonthsAgo = DateTime . UtcNow . AddMonths ( - 6 ) ;
var filterDate = sixMonthsAgo . ToString ( "yyyy-MM-ddTHH:mm:ssZ" ) ;
_logger . Information ( "Downloading messages received after {FilterDate} for folder {FolderName}" , filterDate , folder . FolderName ) ;
// Use Delta API with receivedDateTime filter for last 6 months
var messageCollectionPage = await _graphClient . Me . MailFolders [ folder . RemoteFolderId ] . Messages . Delta . GetAsDeltaGetResponseAsync ( ( config ) = >
{
config . QueryParameters . Select = outlookMessageSelectParameters ;
config . QueryParameters . Orderby = [ "receivedDateTime desc" ] ;
config . QueryParameters . Filter = $"receivedDateTime ge {filterDate}" ;
} , cancellationToken ) . ConfigureAwait ( false ) ;
var totalProcessed = 0 ;
// Use PageIterator to process all messages
var messageIterator = PageIterator < Message , DeltaGetResponse > . CreatePageIterator ( _graphClient , messageCollectionPage , async ( message ) = >
{
try
{
await _handleItemRetrievalSemaphore . WaitAsync ( ) ;
if ( ! IsResourceDeleted ( message . AdditionalData ) & & ! IsNotRealMessageType ( message ) )
{
// Check if message already exists
bool mailExists = await _outlookChangeProcessor . IsMailExistsInFolderAsync ( message . Id , folder . Id ) . ConfigureAwait ( false ) ;
if ( ! mailExists )
{
// Create MailCopy from metadata
var mailCopy = await CreateMailCopyFromMessageAsync ( message , folder ) . ConfigureAwait ( false ) ;
if ( mailCopy ! = null )
{
// Create package without MIME
var package = new NewMailItemPackage ( mailCopy , null , folder . RemoteFolderId ) ;
bool isInserted = await _outlookChangeProcessor . CreateMailAsync ( Account . Id , package ) . ConfigureAwait ( false ) ;
if ( isInserted )
{
downloadedMessageIds . Add ( mailCopy . Id ) ;
totalProcessed + + ;
// Update progress periodically
if ( totalProcessed % 50 = = 0 )
{
2025-11-23 17:04:38 +01:00
var statusMessage = string . Format ( Translator . Sync_DownloadedMessages , totalProcessed , folder . FolderName ) ;
UpdateSyncProgress ( 0 , 0 , statusMessage ) ;
2025-11-01 12:11:05 +01:00
}
}
}
}
else
{
_logger . Debug ( "Mail {MailId} already exists in folder {FolderName}, skipping" , message . Id , folder . FolderName ) ;
}
}
return true ; // Continue processing
}
catch ( Exception ex )
{
_logger . Error ( ex , "Failed to process message {MessageId} during initial sync for folder {FolderName}" , message . Id , folder . FolderName ) ;
return true ; // Continue despite error
}
finally
{
_handleItemRetrievalSemaphore . Release ( ) ;
}
} ) ;
await messageIterator . IterateAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
// Extract and store delta token for future incremental syncs
if ( ! string . IsNullOrEmpty ( messageIterator . Deltalink ) )
{
var deltaToken = GetDeltaTokenFromDeltaLink ( messageIterator . Deltalink ) ;
await _outlookChangeProcessor . UpdateFolderDeltaSynchronizationIdentifierAsync ( folder . Id , deltaToken ) . ConfigureAwait ( false ) ;
await _outlookChangeProcessor . UpdateFolderLastSyncDateAsync ( folder . Id ) . ConfigureAwait ( false ) ;
folder . DeltaToken = deltaToken ;
_logger . Information ( "Stored delta token for folder {FolderName} - future syncs will be incremental" , folder . FolderName ) ;
}
else
{
_logger . Warning ( "No delta token received for folder {FolderName} - future syncs may re-download messages" , folder . FolderName ) ;
}
2025-02-23 00:35:13 +01:00
2025-11-01 12:11:05 +01:00
_logger . Information ( "Initial sync completed for folder {FolderName}. Downloaded {Count} messages" , folder . FolderName , totalProcessed ) ;
2025-02-16 11:54:23 +01:00
}
2025-10-12 16:23:33 +02:00
catch ( ApiException apiException )
{
2025-11-01 12:11:05 +01:00
// Handle API errors
2025-10-12 16:23:33 +02:00
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 )
{
if ( apiException . ResponseStatusCode = = 410 )
{
folder . DeltaToken = string . Empty ;
_logger . Information ( "API error handled successfully for folder {FolderName} during initial sync. Error: {ErrorCode}" , folder . FolderName , apiException . ResponseStatusCode ) ;
}
}
else
{
_logger . Error ( apiException , "Unhandled API error during initial sync for folder {FolderName}. Error: {ErrorCode}" , folder . FolderName , apiException . ResponseStatusCode ) ;
}
2025-10-31 12:13:54 +01:00
2025-10-12 16:23:33 +02:00
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
/// 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
2025-11-01 01:04:04 +01:00
var mailCopy = await CreateMailCopyFromMessageAsync ( message , folder ) . ConfigureAwait ( false ) ;
2025-10-31 00:51:27 +01:00
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>
2025-11-01 01:04:04 +01:00
private async Task < MailCopy > CreateMailCopyFromMessageAsync ( Message message , MailItemFolder assignedFolder )
2025-10-31 00:51:27 +01:00
{
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 ( ) ;
2025-11-01 01:04:04 +01:00
// Check for draft mapping if this is a draft with WinoLocalDraftHeader
if ( message . IsDraft . GetValueOrDefault ( ) & & message . InternetMessageHeaders ! = null )
{
var winoDraftHeader = message . InternetMessageHeaders
. FirstOrDefault ( h = > string . Equals ( h . Name , Domain . Constants . WinoLocalDraftHeader , StringComparison . OrdinalIgnoreCase ) ) ;
if ( winoDraftHeader ! = null & & Guid . TryParse ( winoDraftHeader . Value , out Guid localDraftCopyUniqueId ) )
{
// This message belongs to existing local draft copy.
// We don't need to create a new mail copy for this message, just update the existing one.
2025-11-14 14:42:05 +01:00
2025-11-01 01:04:04 +01:00
bool isMappingSuccessful = await _outlookChangeProcessor . MapLocalDraftAsync (
2025-11-14 14:42:05 +01:00
Account . Id ,
localDraftCopyUniqueId ,
mailCopy . Id ,
mailCopy . DraftId ,
2025-11-01 01:04:04 +01:00
mailCopy . ThreadId ) ;
if ( isMappingSuccessful )
{
2025-11-14 14:42:05 +01:00
_logger . Debug ( "Successfully mapped remote draft {RemoteId} to local draft {LocalId}" ,
2025-11-01 01:04:04 +01:00
mailCopy . Id , localDraftCopyUniqueId ) ;
return null ; // Don't create new mail copy, existing one was updated
}
// Local copy doesn't exist. Continue execution to insert mail copy.
2025-11-14 14:42:05 +01:00
_logger . Debug ( "Local draft copy {LocalId} not found, creating new mail copy for {RemoteId}" ,
2025-11-01 01:04:04 +01:00
localDraftCopyUniqueId , mailCopy . Id ) ;
}
}
2025-10-31 00:51:27 +01:00
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-11-01 01:04:04 +01:00
protected override async Task < MailCopy > CreateMinimalMailCopyAsync ( Message message , MailItemFolder assignedFolder , CancellationToken cancellationToken = default )
2025-10-12 16:23:33 +02:00
{
2025-10-31 00:51:27 +01:00
// Use centralized method
2025-11-01 01:04:04 +01:00
return await CreateMailCopyFromMessageAsync ( message , assignedFolder ) . ConfigureAwait ( false ) ;
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 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.
2025-10-31 19:53:48 +01:00
_logger . Debug ( "Processing delta update for existing mail {MessageId} in folder {FolderName}" , item . Id , folder . FolderName ) ;
2025-02-16 11:54:23 +01:00
if ( item . IsRead ! = null )
{
2025-10-31 19:53:48 +01:00
_logger . Debug ( "Updating read status for mail {MessageId}: IsRead={IsRead}" , item . Id , item . IsRead . GetValueOrDefault ( ) ) ;
2025-02-16 11:54:23 +01:00
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 )
{
2025-10-31 19:53:48 +01:00
var isFlagged = item . Flag . FlagStatus . GetValueOrDefault ( ) = = FollowupFlagStatus . Flagged ;
_logger . Debug ( "Updating flag status for mail {MessageId}: IsFlagged={IsFlagged}" , item . Id , isFlagged ) ;
await _outlookChangeProcessor . ChangeFlagStatusAsync ( item . Id , isFlagged ) . ConfigureAwait ( false ) ;
2025-02-16 11:54:23 +01:00
}
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-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 ;
}
2025-11-30 17:51:10 +01:00
//catch (ServiceException retryAfterException) when (retryAfterException.ResponseStatusCode == 429 && retryAfterException.ResponseHeaders.Contains("Retry-After"))
//{
// // This request must be retried after some time.
// var retryAfterValue = retryAfterException.ResponseHeaders.GetValues("Retry-After").FirstOrDefault();
// if (int.TryParse(retryAfterValue, out int seconds))
// {
// await Task.Delay(seconds);
// }
//}
2025-02-26 19:59:11 +01:00
catch ( ServiceException serviceException )
{
2025-11-14 14:42:05 +01:00
// TODO: AOT Comaptible inner exception deserialization.
2024-04-18 01:44:37 +02:00
2025-11-14 14:42:05 +01:00
// Actual exception is hidden inside ServiceException.
// ODataError errorResult = await KiotaJsonSerializer.DeserializeAsync<ODataError>(serviceException.RawResponseBody, cancellationToken);
2024-04-18 01:44:37 +02:00
2025-11-14 14:42:05 +01:00
throw new SynchronizerException ( "Outlook Error" , serviceException ) ;
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 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-11-01 21:46:23 +01:00
// Pass the ConversationId (ThreadId) to maintain threading for replies/forwards
var conversationId = sendDraftPreparationRequest . MailItem . ThreadId ;
var outlookMessage = mimeMessage . AsOutlookMessage ( false , conversationId ) ;
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)
2025-11-01 01:04:04 +01:00
// During normal sync, this method should not be called - use CreateMailCopyFromMessageAsync instead
2025-02-16 11:54:23 +01:00
var mimeMessage = await DownloadMimeMessageAsync ( message . Id , cancellationToken ) . ConfigureAwait ( false ) ;
2025-11-01 01:04:04 +01:00
var mailCopy = await CreateMailCopyFromMessageAsync ( message , assignedFolder ) . ConfigureAwait ( false ) ;
2024-04-18 01:44:37 +02:00
2025-11-01 01:04:04 +01:00
// If draft mapping was successful, mailCopy will be null
if ( mailCopy = = null ) return null ;
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 ) ;
2025-12-29 14:46:31 +01:00
// ISO 8601 format as expected by Microsoft Graph API (e.g., "2019-11-08T19:00:00-08:00")
2025-12-30 23:32:00 +01:00
var startDate = DateTimeOffset . Now . AddYears ( - 2 ) . ToString ( "yyyy-MM-ddTHH:mm:sszzz" ) ;
2025-12-30 23:41:53 +01:00
var endDate = DateTimeOffset . Now . AddYears ( 2 ) . ToString ( "yyyy-MM-ddTHH:mm:sszzz" ) ;
2025-01-06 02:15:21 +01:00
2025-12-30 23:49:25 +01:00
// Get Id only. We will always download the full event.
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-12-30 23:49:25 +01:00
requestConfiguration . QueryParameters . Select = [ "id" , "type" ] ;
2025-02-16 11:54:23 +01:00
requestConfiguration . QueryParameters . StartDateTime = startDate ;
requestConfiguration . QueryParameters . EndDateTime = endDate ;
} , 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-12-26 20:46:48 +01:00
var requestInformation = _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . CalendarView . Delta . ToGetRequestInformation ( ) ;
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 )
{
2025-12-30 23:32:00 +01:00
if ( IsResourceDeleted ( item . AdditionalData ) )
2025-02-16 11:35:43 +01:00
{
2025-12-30 23:32:00 +01:00
await _outlookChangeProcessor . DeleteCalendarItemAsync ( item . Id , calendar . Id ) . ConfigureAwait ( false ) ;
2025-02-16 11:35:43 +01:00
}
2025-12-30 23:32:00 +01:00
else
2025-02-16 11:54:23 +01:00
{
2025-12-30 23:32:00 +01:00
try
{
await _handleCalendarEventRetrievalSemaphore . WaitAsync ( ) ;
// Check if the event has complete information
// Sometimes delta sync returns events with only Id available
2025-12-30 23:49:25 +01:00
Event fullEvent = await _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events [ item . Id ] . GetAsync ( cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ; ;
2025-12-30 23:32:00 +01:00
await _outlookChangeProcessor . ManageCalendarEventAsync ( fullEvent , calendar , Account ) . ConfigureAwait ( false ) ;
}
catch ( Exception ex )
{
_logger . Error ( ex , "Error occurred while handling item {Id} for calendar {Name}" , item . Id , calendar . Name ) ;
}
finally
{
_handleCalendarEventRetrievalSemaphore . Release ( ) ;
}
2025-02-16 11:54:23 +01:00
}
}
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-12-30 23:32:00 +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-12-26 20:46:48 +01:00
// TODO: Return proper results.
return CalendarSynchronizationResult . Empty ;
2025-02-16 11:54:23 +01:00
}
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-12-30 11:59:54 +01:00
#region Calendar Operations
public override List < IRequestBundle < RequestInformation > > CreateCalendarEvent ( CreateCalendarEventRequest request )
{
var calendarItem = request . Item ;
var attendees = request . Attendees ;
// Get the calendar for this event
var calendar = calendarItem . AssignedCalendar ;
if ( calendar = = null )
{
throw new InvalidOperationException ( "Calendar item must have an assigned calendar" ) ;
}
// Convert CalendarItem to Outlook Event
var outlookEvent = new Microsoft . Graph . Models . Event
{
Subject = calendarItem . Title ,
Body = new Microsoft . Graph . Models . ItemBody
{
ContentType = Microsoft . Graph . Models . BodyType . Text ,
Content = calendarItem . Description
} ,
Location = new Microsoft . Graph . Models . Location
{
DisplayName = calendarItem . Location
}
} ;
// Set start and end time using DateTimeTimeZone
if ( calendarItem . IsAllDayEvent )
{
// All-day events
outlookEvent . IsAllDay = true ;
outlookEvent . Start = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . StartDate . ToString ( "yyyy-MM-dd" ) ,
TimeZone = "UTC"
} ;
outlookEvent . End = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . EndDate . ToString ( "yyyy-MM-dd" ) ,
TimeZone = "UTC"
} ;
}
else
{
// Regular events with time
outlookEvent . IsAllDay = false ;
outlookEvent . Start = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . StartDate . ToString ( "yyyy-MM-ddTHH:mm:ss" ) ,
TimeZone = calendarItem . StartTimeZone ? ? "UTC"
} ;
outlookEvent . End = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . EndDate . ToString ( "yyyy-MM-ddTHH:mm:ss" ) ,
TimeZone = calendarItem . EndTimeZone ? ? "UTC"
} ;
}
// Add attendees if any
if ( attendees ! = null & & attendees . Count > 0 )
{
outlookEvent . Attendees = attendees . Select ( a = > new Microsoft . Graph . Models . Attendee
{
EmailAddress = new Microsoft . Graph . Models . EmailAddress
{
Address = a . Email ,
Name = a . Name
} ,
Type = a . IsOptionalAttendee ? Microsoft . Graph . Models . AttendeeType . Optional : Microsoft . Graph . Models . AttendeeType . Required
} ) . ToList ( ) ;
}
// Create the event using Graph API
var createRequest = _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events . ToPostRequestInformation ( outlookEvent ) ;
return [ new HttpRequestBundle < RequestInformation > ( createRequest , request ) ] ;
}
#endregion
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
}
}