2024-04-18 01:44:37 +02:00
using System ;
using System.Collections.Generic ;
using System.Diagnostics ;
using System.IO ;
using System.Linq ;
2026-04-07 13:23:07 +02:00
using System.Net ;
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 ;
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 ;
2026-03-07 17:13:48 +01:00
using Wino.Core.Domain.Extensions ;
2024-04-18 01:44:37 +02:00
using Wino.Core.Domain.Interfaces ;
2024-08-17 03:43:37 +02:00
using Wino.Core.Domain.Models.Accounts ;
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 ;
2026-03-07 17:13:48 +01:00
using Wino.Core.Helpers ;
2024-04-18 01:44:37 +02:00
using Wino.Core.Integration.Processors ;
2026-02-15 11:27:30 +01: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 ;
2026-03-01 16:23:28 +01:00
using Wino.Messaging.UI ;
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))]
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 ;
2026-02-21 16:14:55 +01:00
private const int SimpleAttachmentUploadLimitBytes = 3 * 1024 * 1024 ;
private const int MaximumUploadSessionAttachmentSizeBytes = 150 * 1024 * 1024 ;
private const int LargeAttachmentUploadChunkSizeBytes = 320 * 1024 ;
2025-02-16 11:54:23 +01:00
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",
2026-04-11 21:02:51 +02:00
"IsReadReceiptRequested",
2025-02-16 11:54:23 +01:00
"IsDraft",
"ReceivedDateTime",
"HasAttachments",
"BodyPreview",
"Id",
"ConversationId",
"From",
2026-02-09 22:39:30 +01:00
"Sender",
"ToRecipients",
"CcRecipients",
"BccRecipients",
"ReplyTo",
2025-02-16 11:54:23 +01:00
"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 ;
2026-03-01 16:23:28 +01:00
private bool _isFolderStructureChanged ;
2025-04-26 10:49:55 +02:00
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 > ( ) ;
2026-02-06 01:18:12 +01:00
var folderResults = new List < FolderSyncResult > ( ) ;
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
2026-02-06 01:18:12 +01:00
try
{
var folderDownloadedMessageIds = await SynchronizeFolderAsync ( folder , cancellationToken ) . ConfigureAwait ( false ) ;
downloadedMessageIds . AddRange ( folderDownloadedMessageIds ) ;
folderResults . Add ( FolderSyncResult . Successful ( folder . Id , folder . FolderName , folderDownloadedMessageIds . Count ( ) ) ) ;
}
catch ( OperationCanceledException )
{
// Cancellation should stop the entire sync
throw ;
}
catch ( ODataError odataError )
{
// Handle OData errors - determine if we should continue or stop
var errorContext = new SynchronizerErrorContext
{
Account = Account ,
ErrorCode = ( int? ) odataError . ResponseStatusCode ,
ErrorMessage = odataError . Error ? . Message ? ? odataError . Message ,
Exception = odataError ,
FolderId = folder . Id ,
FolderName = folder . FolderName ,
OperationType = "FolderSync"
} ;
var handled = await _errorHandlingFactory . HandleErrorAsync ( errorContext ) . ConfigureAwait ( false ) ;
if ( errorContext . CanContinueSync )
{
_logger . Warning ( "Folder {FolderName} sync failed with recoverable error, continuing with other folders. Error: {Error}" ,
folder . FolderName , odataError . Error ? . Message ) ;
folderResults . Add ( FolderSyncResult . Failed ( folder . Id , folder . FolderName , errorContext ) ) ;
}
else
{
_logger . Error ( odataError , "Folder {FolderName} sync failed with fatal error, stopping sync" , folder . FolderName ) ;
folderResults . Add ( FolderSyncResult . Failed ( folder . Id , folder . FolderName , errorContext ) ) ;
throw ;
}
}
catch ( Exception ex )
{
// For unexpected exceptions, try to classify and decide if we should continue
var errorContext = new SynchronizerErrorContext
{
Account = Account ,
ErrorMessage = ex . Message ,
Exception = ex ,
FolderId = folder . Id ,
FolderName = folder . FolderName ,
OperationType = "FolderSync" ,
Severity = SynchronizerErrorSeverity . Recoverable , // Default to recoverable for individual folders
Category = SynchronizerErrorCategory . Unknown
} ;
_logger . Warning ( ex , "Folder {FolderName} sync failed, continuing with other folders" , folder . FolderName ) ;
folderResults . Add ( FolderSyncResult . Failed ( folder . Id , folder . FolderName , errorContext ) ) ;
}
2024-04-18 01:44:37 +02:00
}
}
2025-02-16 11:43:30 +01:00
}
2026-02-06 01:18:12 +01:00
catch ( OperationCanceledException )
{
_logger . Information ( "Synchronization was canceled for {Name}" , Account . Name ) ;
return MailSynchronizationResult . Canceled ;
}
2025-02-16 11:54:23 +01:00
catch ( Exception ex )
{
_logger . Error ( ex , "Synchronizing folders for {Name}" , Account . Name ) ;
2026-02-06 01:18:12 +01:00
return MailSynchronizationResult . Failed ( ex ) ;
2025-02-16 11:54:23 +01:00
}
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
2026-02-06 01:18:12 +01:00
// Get all unread new downloaded items and return in the result.
2025-02-16 11:54:23 +01:00
// 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
2026-02-06 01:18:12 +01:00
return MailSynchronizationResult . CompletedWithFolderResults ( unreadNewItems , folderResults ) ;
2025-02-16 11:54:23 +01:00
}
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 )
{
2026-02-12 18:57:55 +01:00
if ( string . IsNullOrWhiteSpace ( messageId ) | | assignedFolder = = null ) return ;
// Online search can return the same message across repeated invocations/races.
// Guard before network+MIME download and before database insert.
var existing = await _outlookChangeProcessor . AreMailsExistsAsync ( [ messageId ] ) . ConfigureAwait ( false ) ;
if ( existing . Contains ( messageId ) )
{
return ;
}
2025-02-22 00:22:00 +01:00
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 ) ;
2026-01-05 00:21:07 +01:00
// Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted)
if ( Account . IsCalendarAccessGranted & & message is EventMessage )
{
message = await FetchEventMessageAsync ( message . Id , cancellationToken ) . ConfigureAwait ( false ) ;
if ( message = = null )
{
_logger . Warning ( "Failed to fetch EventMessage {MessageId}, skipping" , messageId ) ;
return ;
}
}
2025-02-22 00:22:00 +01:00
var mailPackages = await CreateNewMailPackagesAsync ( message , assignedFolder , cancellationToken ) . ConfigureAwait ( false ) ;
if ( mailPackages = = null ) return ;
foreach ( var package in mailPackages )
{
cancellationToken . ThrowIfCancellationRequested ( ) ;
2026-02-12 18:57:55 +01:00
// Use safe upsert path to avoid duplicate rows when message already exists.
await _outlookChangeProcessor . CreateMailAsync ( Account . Id , package ) . ConfigureAwait ( false ) ;
2025-02-22 00:22:00 +01:00
}
}
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 ) )
{
2026-01-05 00:21:07 +01:00
// Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted)
if ( Account . IsCalendarAccessGranted & & message is EventMessage )
{
message = await FetchEventMessageAsync ( message . Id , cancellationToken ) . ConfigureAwait ( false ) ;
if ( message = = null )
{
return true ; // Skip this message if fetch failed
}
}
2025-11-01 12:11:05 +01:00
// Check if message already exists
bool mailExists = await _outlookChangeProcessor . IsMailExistsInFolderAsync ( message . Id , folder . Id ) . ConfigureAwait ( false ) ;
if ( ! mailExists )
{
2026-02-10 21:35:55 +01:00
// For drafts and calendar invitations, download MIME during initial sync like delta sync.
var itemType = Account . IsCalendarAccessGranted ? message . GetMailItemType ( ) : MailItemType . Mail ;
2026-04-11 21:02:51 +02:00
if ( ShouldDownloadMimeForMessage ( message , folder , itemType ) )
2026-02-09 22:39:30 +01:00
{
var draftPackages = await CreateNewMailPackagesAsync ( message , folder , cancellationToken ) . ConfigureAwait ( false ) ;
2025-11-01 12:11:05 +01:00
2026-02-09 22:39:30 +01:00
if ( draftPackages ! = null )
{
foreach ( var package in draftPackages )
{
bool isInserted = await _outlookChangeProcessor . CreateMailAsync ( Account . Id , package ) . ConfigureAwait ( false ) ;
if ( isInserted )
{
downloadedMessageIds . Add ( package . Copy . Id ) ;
totalProcessed + + ;
}
}
}
}
else
2025-11-01 12:11:05 +01:00
{
2026-02-09 22:39:30 +01:00
// Create MailCopy from metadata
var mailCopy = await CreateMailCopyFromMessageAsync ( message , folder ) . ConfigureAwait ( false ) ;
2025-11-01 12:11:05 +01:00
2026-02-09 22:39:30 +01:00
if ( mailCopy ! = null )
2025-11-01 12:11:05 +01:00
{
2026-02-09 22:39:30 +01:00
// Create package without MIME
var contacts = ExtractContactsFromOutlookMessage ( message ) ;
var package = new NewMailItemPackage ( mailCopy , null , folder . RemoteFolderId , contacts ) ;
bool isInserted = await _outlookChangeProcessor . CreateMailAsync ( Account . Id , package ) . ConfigureAwait ( false ) ;
2025-11-01 12:11:05 +01:00
2026-02-09 22:39:30 +01:00
if ( isInserted )
2025-11-01 12:11:05 +01:00
{
2026-02-09 22:39:30 +01:00
downloadedMessageIds . Add ( mailCopy . Id ) ;
totalProcessed + + ;
2025-11-01 12:11:05 +01:00
}
}
}
2026-02-09 22:39:30 +01:00
// Update progress periodically
if ( totalProcessed > 0 & & totalProcessed % 50 = = 0 )
{
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
{
2026-04-11 21:02:51 +02:00
var itemType = Account . IsCalendarAccessGranted ? message . GetMailItemType ( ) : MailItemType . Mail ;
2025-10-31 00:51:27 +01:00
2026-04-11 21:02:51 +02:00
if ( ShouldDownloadMimeForMessage ( message , folder , itemType ) )
2025-10-20 18:27:02 +02:00
{
2026-04-11 21:02:51 +02:00
var packages = await CreateNewMailPackagesAsync ( message , folder , cancellationToken ) . ConfigureAwait ( false ) ;
2025-10-20 18:27:02 +02:00
2026-04-11 21:02:51 +02:00
if ( packages ! = null )
2025-10-20 18:27:02 +02:00
{
2026-04-11 21:02:51 +02:00
foreach ( var package in packages )
{
bool isInserted = await _outlookChangeProcessor . CreateMailAsync ( Account . Id , package ) . ConfigureAwait ( false ) ;
if ( isInserted )
{
downloadedIds . Add ( package . Copy . Id ) ;
_logger . Debug ( "Downloaded MIME-backed message {MailId} in folder {FolderName}" , messageId , folder . FolderName ) ;
}
}
2025-10-20 18:27:02 +02:00
}
2026-04-11 21:02:51 +02:00
}
else
{
// Create MailCopy from metadata only
var mailCopy = await CreateMailCopyFromMessageAsync ( message , folder ) . ConfigureAwait ( false ) ;
if ( mailCopy ! = null )
2025-10-20 18:27:02 +02:00
{
2026-04-11 21:02:51 +02:00
// Create package without MIME
var contacts = ExtractContactsFromOutlookMessage ( message ) ;
var package = new NewMailItemPackage ( mailCopy , null , folder . RemoteFolderId , contacts ) ;
bool isInserted = await _outlookChangeProcessor . CreateMailAsync ( Account . Id , package ) . ConfigureAwait ( false ) ;
if ( isInserted )
{
downloadedIds . Add ( mailCopy . Id ) ;
_logger . Debug ( "Downloaded metadata for message {MailId} in folder {FolderName}" , messageId , folder . FolderName ) ;
}
else
{
_logger . Warning ( "Failed to insert mail {MailId} for folder {FolderName}" , messageId , folder . FolderName ) ;
}
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>
2026-04-11 21:02:51 +02:00
private static bool ShouldDownloadMimeForMessage ( Message message , MailItemFolder folder , MailItemType itemType )
= > folder . SpecialFolderType = = SpecialFolderType . Draft
| | itemType = = MailItemType . CalendarInvitation
| | LooksLikeReadReceipt ( message ) ;
private static bool LooksLikeReadReceipt ( Message message )
{
var contentType = message ? . InternetMessageHeaders ?
. FirstOrDefault ( h = > string . Equals ( h . Name , "Content-Type" , StringComparison . OrdinalIgnoreCase ) )
? . Value ;
return ! string . IsNullOrWhiteSpace ( contentType )
& & contentType . Contains ( "disposition-notification" , StringComparison . OrdinalIgnoreCase ) ;
}
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 ( ) ;
2026-01-05 00:21:07 +01:00
// Set ItemType based on calendar access permissions
if ( Account . IsCalendarAccessGranted & & message is EventMessage )
{
mailCopy . ItemType = message . GetMailItemType ( ) ;
}
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
2026-02-09 22:39:30 +01:00
private static IReadOnlyList < AccountContact > ExtractContactsFromOutlookMessage ( Message message )
{
if ( message = = null ) return [ ] ;
var contacts = new Dictionary < string , AccountContact > ( StringComparer . OrdinalIgnoreCase ) ;
AddRecipient ( message . From ? . EmailAddress ) ;
AddRecipient ( message . Sender ? . EmailAddress ) ;
if ( message . ToRecipients ! = null )
{
foreach ( var recipient in message . ToRecipients )
{
AddRecipient ( recipient ? . EmailAddress ) ;
}
}
if ( message . CcRecipients ! = null )
{
foreach ( var recipient in message . CcRecipients )
{
AddRecipient ( recipient ? . EmailAddress ) ;
}
}
if ( message . BccRecipients ! = null )
{
foreach ( var recipient in message . BccRecipients )
{
AddRecipient ( recipient ? . EmailAddress ) ;
}
}
if ( message . ReplyTo ! = null )
{
foreach ( var recipient in message . ReplyTo )
{
AddRecipient ( recipient ? . EmailAddress ) ;
}
}
return contacts . Values . ToList ( ) ;
void AddRecipient ( EmailAddress emailAddress )
{
var address = emailAddress ? . Address ? . Trim ( ) ;
if ( string . IsNullOrWhiteSpace ( address ) ) return ;
var displayName = string . IsNullOrWhiteSpace ( emailAddress . Name ) ? address : emailAddress . Name . Trim ( ) ;
contacts [ address ] = new AccountContact
{
Address = address ,
Name = displayName
} ;
}
}
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
2026-01-05 00:21:07 +01:00
/// <summary>
/// Determines MailItemType based on EventMessage's MeetingMessageType.
/// </summary>
private static MailItemType GetMailItemType ( EventMessage eventMessage )
{
if ( eventMessage . MeetingMessageType . HasValue )
{
return eventMessage . MeetingMessageType . Value switch
{
MeetingMessageType . MeetingRequest = > MailItemType . CalendarInvitation ,
MeetingMessageType . MeetingCancelled = > MailItemType . CalendarCancellation ,
MeetingMessageType . MeetingAccepted or
MeetingMessageType . MeetingTenativelyAccepted or
MeetingMessageType . MeetingDeclined = > MailItemType . CalendarResponse ,
_ = > MailItemType . Mail
} ;
}
// Fallback to CalendarInvitation if type is unknown
return MailItemType . CalendarInvitation ;
}
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
{
2026-01-05 00:21:07 +01:00
var message = await _graphClient . Me . Messages [ messageId ] . GetAsync ( ( config ) = >
2025-10-12 16:23:33 +02:00
{
config . QueryParameters . Select = outlookMessageSelectParameters ;
} , cancellationToken ) . ConfigureAwait ( false ) ;
2026-01-05 00:21:07 +01:00
// Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted)
if ( Account . IsCalendarAccessGranted & & message is EventMessage )
{
message = await FetchEventMessageAsync ( message . Id , cancellationToken ) . ConfigureAwait ( false ) ;
}
return message ;
2025-10-12 16:23:33 +02:00
}
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
2026-01-05 00:21:07 +01:00
/// <summary>
/// Fetches an EventMessage with full details including MeetingMessageType from the Messages endpoint.
/// This is necessary because MeetingMessageType is not available when fetching as Message type.
/// </summary>
private async Task < EventMessage > FetchEventMessageAsync ( string messageId , CancellationToken cancellationToken )
{
try
{
var requestInfo = _graphClient . Me . Messages [ messageId ] . ToGetRequestInformation ( ( config ) = >
{
config . QueryParameters . Select = outlookMessageSelectParameters . Concat ( [ "MeetingMessageType" ] ) . ToArray ( ) ;
} ) ;
var eventMessage = await _graphClient . Me . Messages [ messageId ] . GetAsync ( cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
var odataType = eventMessage ? . AdditionalData ? . ContainsKey ( "@odata.type" ) = = true
? eventMessage . AdditionalData [ "@odata.type" ] ? . ToString ( )
: "unknown" ;
_logger . Debug ( "Fetched EventMessage {MessageId} with type {ODataType}" , messageId , odataType ) ;
return eventMessage as EventMessage ;
}
catch ( Exception ex )
{
_logger . Error ( ex , "Failed to fetch EventMessage {MessageId}" , messageId ) ;
return null ;
}
}
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 ) ;
2026-03-01 16:23:28 +01:00
_isFolderStructureChanged = true ;
2025-02-16 11:54:23 +01:00
}
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 ) ;
2026-03-01 16:23:28 +01:00
_isFolderStructureChanged = true ;
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.
2026-01-05 00:21:07 +01:00
/// EventMessage types (calendar invitations/responses) are now processed as regular mail items with appropriate ItemType.
2025-02-16 11:54:23 +01:00
/// </summary>
/// <param name="item">Retrieved message.</param>
/// <returns>Whether the item is non-Message type or not.</returns>
private bool IsNotRealMessageType ( Message item )
2026-01-05 00:21:07 +01:00
= > item . From ? . EmailAddress = = null ;
2025-02-16 11:54:23 +01:00
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
{
2026-01-05 00:21:07 +01:00
// Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted)
if ( Account . IsCalendarAccessGranted & & item is EventMessage )
{
item = await FetchEventMessageAsync ( item . Id , cancellationToken ) . ConfigureAwait ( false ) ;
if ( item = = null )
{
return true ; // Skip this message if fetch failed
}
}
2025-02-16 11:54:23 +01:00
// 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
{
2026-01-05 00:21:07 +01:00
// EventMessages are handled above if calendar access is granted
// This catches non-message types like contacts or todo items
Log . Warning ( "Received non-message item type (contact/todo). This is not supported yet. {Id}" , item . Id ) ;
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 )
{
2026-03-01 16:23:28 +01:00
_isFolderStructureChanged = false ;
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 ) ;
2026-03-01 16:23:28 +01:00
if ( _isFolderStructureChanged )
{
WeakReferenceMessenger . Default . Send ( new AccountFolderConfigurationUpdated ( Account . Id ) ) ;
}
2025-02-26 19:59:11 +01:00
}
2024-04-18 01:44:37 +02:00
2026-03-06 13:43:16 +01:00
private async Task < OutlookSpecialFolderIdInformation > GetSpecialFolderIdsAsync ( CancellationToken cancellationToken )
2025-02-26 19:59:11 +01:00
{
2026-03-06 13:43:16 +01:00
var localFolders = await _outlookChangeProcessor . GetLocalFoldersAsync ( Account . Id ) . ConfigureAwait ( false ) ;
var cachedSpecialFolders = TryGetSpecialFolderIdsFromLocalFolders ( localFolders ) ;
if ( cachedSpecialFolders ! = null )
{
_logger . Debug ( "Using cached Outlook special folder ids for {AccountName}" , Account . Name ) ;
return cachedSpecialFolders ;
}
_logger . Information ( "Cached Outlook special folder ids are incomplete for {AccountName}. Fetching from Microsoft Graph." , Account . Name ) ;
return new OutlookSpecialFolderIdInformation (
await GetWellKnownFolderIdAsync ( INBOX_NAME , cancellationToken ) . ConfigureAwait ( false ) ,
await GetWellKnownFolderIdAsync ( DELETED_NAME , cancellationToken ) . ConfigureAwait ( false ) ,
await GetWellKnownFolderIdAsync ( JUNK_NAME , cancellationToken ) . ConfigureAwait ( false ) ,
await GetWellKnownFolderIdAsync ( DRAFTS_NAME , cancellationToken ) . ConfigureAwait ( false ) ,
await GetWellKnownFolderIdAsync ( SENT_NAME , cancellationToken ) . ConfigureAwait ( false ) ,
await GetWellKnownFolderIdAsync ( ARCHIVE_NAME , cancellationToken ) . ConfigureAwait ( false ) ) ;
}
2024-04-18 01:44:37 +02:00
2026-03-06 13:43:16 +01:00
private async Task < string > GetWellKnownFolderIdAsync ( string wellKnownFolderName , CancellationToken cancellationToken )
{
2025-02-26 19:59:11 +01:00
try
{
2026-03-06 13:43:16 +01:00
var folder = await _graphClient . Me . MailFolders [ wellKnownFolderName ]
. GetAsync ( requestConfiguration = >
{
requestConfiguration . QueryParameters . Select = [ "id" ] ;
} , cancellationToken : cancellationToken )
. ConfigureAwait ( false ) ;
if ( string . IsNullOrWhiteSpace ( folder ? . Id ) )
{
throw new SynchronizerException ( $"Outlook special folder '{wellKnownFolderName}' returned no id." ) ;
}
return folder . Id ;
2025-02-26 19:59:11 +01:00
}
2026-03-06 13:43:16 +01:00
catch ( OperationCanceledException )
2025-02-26 19:59:11 +01:00
{
throw ;
}
2026-03-06 13:43:16 +01:00
catch ( Exception ex )
2025-02-26 19:59:11 +01:00
{
2026-03-06 13:43:16 +01:00
_logger . Warning ( ex , "Failed to fetch Outlook special folder id for {FolderName}" , wellKnownFolderName ) ;
throw ;
2025-02-26 19:59:11 +01:00
}
}
2024-04-18 01:44:37 +02:00
2026-03-06 13:43:16 +01:00
private static OutlookSpecialFolderIdInformation TryGetSpecialFolderIdsFromLocalFolders ( IEnumerable < MailItemFolder > localFolders )
2025-02-26 19:59:11 +01:00
{
2026-03-06 13:43:16 +01:00
if ( localFolders = = null )
2025-02-16 11:54:23 +01:00
{
2026-03-06 13:43:16 +01:00
return null ;
2025-02-26 19:59:11 +01:00
}
2024-04-18 01:44:37 +02:00
2026-03-06 13:43:16 +01:00
var inboxId = GetSpecialFolderRemoteId ( localFolders , SpecialFolderType . Inbox ) ;
var deletedId = GetSpecialFolderRemoteId ( localFolders , SpecialFolderType . Deleted ) ;
var junkId = GetSpecialFolderRemoteId ( localFolders , SpecialFolderType . Junk ) ;
var draftId = GetSpecialFolderRemoteId ( localFolders , SpecialFolderType . Draft ) ;
var sentId = GetSpecialFolderRemoteId ( localFolders , SpecialFolderType . Sent ) ;
var archiveId = GetSpecialFolderRemoteId ( localFolders , SpecialFolderType . Archive ) ;
2025-02-26 19:59:11 +01:00
2026-03-06 13:43:16 +01:00
if ( new [ ] { inboxId , deletedId , junkId , draftId , sentId , archiveId } . Any ( string . IsNullOrWhiteSpace ) )
2025-02-26 19:59:11 +01:00
{
2026-03-06 13:43:16 +01:00
return null ;
2025-02-26 19:59:11 +01:00
}
2024-04-18 01:44:37 +02:00
2026-03-06 13:43:16 +01:00
return new OutlookSpecialFolderIdInformation ( inboxId , deletedId , junkId , draftId , sentId , archiveId ) ;
2025-02-26 19:59:11 +01:00
}
2026-03-06 13:43:16 +01:00
private static string GetSpecialFolderRemoteId ( IEnumerable < MailItemFolder > localFolders , SpecialFolderType specialFolderType )
= > localFolders . FirstOrDefault ( folder = > folder . SpecialFolderType = = specialFolderType & & ! string . IsNullOrWhiteSpace ( folder . RemoteFolderId ) ) ? . RemoteFolderId ;
2025-02-26 19:59:11 +01:00
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>
2026-04-08 15:31:14 +02:00
private RequestInformation PreparePostRequestInformation ( RequestInformation requestInformation , string contentJson = "{}" )
2025-02-16 11:54:23 +01:00
{
requestInformation . Headers . Clear ( ) ;
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
2026-04-08 15:31:14 +02:00
private RequestInformation PreparePostRequestInformation ( RequestInformation requestInformation , Microsoft . Graph . Me . Messages . Item . Move . MovePostRequestBody content )
= > PreparePostRequestInformation ( requestInformation , JsonSerializer . Serialize ( content , OutlookSynchronizerJsonContext . Default . MovePostRequestBody ) ) ;
private RequestInformation PrepareReportMessageRequestInformation ( ChangeJunkStateRequest request )
{
var reportAction = request . IsJunk ? "junk" : "notJunk" ;
var body = $ $"""
{
" IsMessageMoveRequested ": true,
"ReportAction" : "{{reportAction}}"
}
"" ";
return PreparePostRequestInformation ( new RequestInformation
{
URI = new Uri ( $"https://graph.microsoft.com/beta/me/messages/{Uri.EscapeDataString(request.Item.Id)}/reportMessage" ) ,
HttpMethod = Method . POST
} , body ) ;
}
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
2026-04-08 15:31:14 +02:00
public override List < IRequestBundle < RequestInformation > > ChangeJunkState ( BatchChangeJunkStateRequest request )
{
return request
. Select ( item = > ( IRequestBundle < RequestInformation > ) new HttpRequestBundle < RequestInformation > (
PrepareReportMessageRequestInformation ( item ) ,
item ,
item ) )
. ToList ( ) ;
}
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 ;
var mailCopyId = sendDraftPreparationRequest . MailItem . Id ;
var mimeMessage = sendDraftPreparationRequest . Mime ;
2024-09-05 17:23:15 +02:00
2026-02-07 13:10:57 +01:00
// Graph API ignores the From header in direct MIME uploads, so we must convert
// to a JSON Message object to properly support sending from aliases.
2025-11-01 21:46:23 +01:00
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
var patchDraftRequest = _graphClient . Me . Messages [ mailCopyId ] . ToPatchRequestInformation ( outlookMessage ) ;
2026-02-07 13:10:57 +01:00
var patchDraftBundle = new HttpRequestBundle < RequestInformation > ( patchDraftRequest , request ) ;
2024-09-05 17:23:15 +02:00
2026-02-07 13:10:57 +01:00
var sendRequest = PreparePostRequestInformation ( _graphClient . Me . Messages [ mailCopyId ] . Send . ToPostRequestInformation ( ) ) ;
var sendBundle = new HttpRequestBundle < RequestInformation > ( sendRequest , request ) ;
2024-09-05 17:23:15 +02:00
2026-02-21 16:14:55 +01:00
// Attachment uploads are handled outside batching because large attachments
// require upload sessions whose URLs are generated dynamically.
return [ patchDraftBundle , sendBundle ] ;
2025-02-16 11:54:23 +01:00
}
2024-09-05 17:23:15 +02:00
2026-02-21 16:14:55 +01:00
private async Task UploadDraftAttachmentsAsync ( SendDraftRequest sendDraftRequest , CancellationToken cancellationToken )
2025-02-16 11:54:23 +01:00
{
2026-02-21 16:14:55 +01:00
var mailCopyId = sendDraftRequest . Request . MailItem . Id ;
var attachments = sendDraftRequest . Request . Mime . ExtractAttachments ( ) ;
if ( ! attachments . Any ( ) )
{
return ;
}
2025-02-16 11:54:23 +01:00
2026-02-07 13:10:57 +01:00
foreach ( var attachment in attachments )
2025-02-16 11:54:23 +01:00
{
2026-02-21 16:14:55 +01:00
cancellationToken . ThrowIfCancellationRequested ( ) ;
var contentBytes = attachment . ContentBytes ? ? [ ] ;
if ( contentBytes . Length < = SimpleAttachmentUploadLimitBytes )
{
await _graphClient . Me . Messages [ mailCopyId ] . Attachments . PostAsync ( attachment , cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
continue ;
}
if ( contentBytes . Length > MaximumUploadSessionAttachmentSizeBytes )
{
var attachmentSizeMb = contentBytes . LongLength / ( 1024d * 1024d ) ;
var maximumSizeMb = MaximumUploadSessionAttachmentSizeBytes / ( 1024d * 1024d ) ;
throw new InvalidOperationException (
$"Attachment '{attachment.Name}' is {attachmentSizeMb:F1} MB, which exceeds Outlook's upload limit of {maximumSizeMb:F0} MB per attachment." ) ;
}
var sessionBody = new Microsoft . Graph . Me . Messages . Item . Attachments . CreateUploadSession . CreateUploadSessionPostRequestBody
{
AttachmentItem = new AttachmentItem
{
AttachmentType = AttachmentType . File ,
ContentType = attachment . ContentType ,
Name = attachment . Name ,
Size = contentBytes . LongLength
}
} ;
var uploadSession = await _graphClient . Me . Messages [ mailCopyId ] . Attachments . CreateUploadSession . PostAsync ( sessionBody , cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
if ( uploadSession ? . UploadUrl = = null )
{
throw new InvalidOperationException ( $"Failed to create upload session for attachment '{attachment.Name}'." ) ;
}
await UploadAttachmentInChunksAsync ( uploadSession . UploadUrl , contentBytes , cancellationToken ) . ConfigureAwait ( false ) ;
2025-02-16 11:43:30 +01:00
}
2026-02-21 16:14:55 +01:00
}
2024-06-21 23:48:03 +02:00
2026-02-21 16:14:55 +01:00
private static async Task UploadAttachmentInChunksAsync ( string uploadUrl , byte [ ] content , CancellationToken cancellationToken )
{
using var client = new HttpClient ( ) ;
var totalSize = content . Length ;
var offset = 0 ;
while ( offset < totalSize )
{
cancellationToken . ThrowIfCancellationRequested ( ) ;
var chunkLength = Math . Min ( LargeAttachmentUploadChunkSizeBytes , totalSize - offset ) ;
var end = offset + chunkLength - 1 ;
using var request = new HttpRequestMessage ( HttpMethod . Put , uploadUrl )
{
Content = new ByteArrayContent ( content , offset , chunkLength )
} ;
request . Content . Headers . Add ( "Content-Range" , $"bytes {offset}-{end}/{totalSize}" ) ;
using var response = await client . SendAsync ( request , cancellationToken ) . ConfigureAwait ( false ) ;
// Upload session returns either 202 (continue) or 201/200 (completed).
if ( ! response . IsSuccessStatusCode )
{
var responseContent = await response . Content . ReadAsStringAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
throw new InvalidOperationException ( $"Attachment chunk upload failed with status {(int)response.StatusCode}: {responseContent}" ) ;
}
offset + = chunkLength ;
}
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 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 )
{
2026-02-08 22:20:38 +01:00
try
{
var mimeMessage = await DownloadMimeMessageAsync ( mailItem . Id , cancellationToken ) . ConfigureAwait ( false ) ;
await _outlookChangeProcessor . SaveMimeFileAsync ( mailItem . FileId , mimeMessage , Account . Id ) . ConfigureAwait ( false ) ;
}
catch ( ODataError ex ) when ( ex . ResponseStatusCode = = 404 )
{
_logger . Warning ( "Outlook message {MailId} not found (404) during MIME download. Deleting locally." , mailItem . Id ) ;
await _outlookChangeProcessor . DeleteMailAsync ( Account . Id , mailItem . Id ) . ConfigureAwait ( false ) ;
throw new SynchronizerEntityNotFoundException ( ex . Message ) ;
}
2025-02-16 11:54:23 +01:00
}
2026-01-03 23:59:37 +01:00
public override async Task DownloadCalendarAttachmentAsync (
Wino . Core . Domain . Entities . Calendar . CalendarItem calendarItem ,
Wino . Core . Domain . Entities . Calendar . CalendarAttachment attachment ,
string localFilePath ,
CancellationToken cancellationToken = default )
{
try
{
var calendar = calendarItem . AssignedCalendar ;
2026-03-07 17:13:48 +01:00
var remoteEventId = calendarItem . RemoteEventId . GetProviderRemoteEventId ( ) ;
2026-01-03 23:59:37 +01:00
// First, get the attachment metadata to retrieve contentBytes for FileAttachment
var attachmentItem = await _graphClient . Me
. Calendars [ calendar . RemoteCalendarId ]
2026-03-07 17:13:48 +01:00
. Events [ remoteEventId ]
2026-01-03 23:59:37 +01:00
. Attachments [ attachment . RemoteAttachmentId ]
. GetAsync ( cancellationToken : cancellationToken )
. ConfigureAwait ( false ) ;
if ( attachmentItem = = null )
{
_logger . Error ( "Failed to retrieve attachment {AttachmentId} for event {EventId}" , attachment . RemoteAttachmentId , calendarItem . RemoteEventId ) ;
throw new InvalidOperationException ( "Failed to retrieve attachment." ) ;
}
byte [ ] contentBytes = null ;
// Handle FileAttachment (has ContentBytes property)
if ( attachmentItem is FileAttachment fileAttachment & & fileAttachment . ContentBytes ! = null )
{
contentBytes = fileAttachment . ContentBytes ;
}
// Handle ItemAttachment (embedded items like emails)
else if ( attachmentItem is ItemAttachment )
{
_logger . Warning ( "ItemAttachment type not supported for download. AttachmentId: {AttachmentId}" , attachment . RemoteAttachmentId ) ;
throw new NotSupportedException ( "ItemAttachment downloads are not currently supported." ) ;
}
else
{
_logger . Error ( "Unknown attachment type or missing content for {AttachmentId}" , attachment . RemoteAttachmentId ) ;
throw new InvalidOperationException ( "Attachment content is not available." ) ;
}
// Save to local file
await System . IO . File . WriteAllBytesAsync ( localFilePath , contentBytes , cancellationToken ) . ConfigureAwait ( false ) ;
_logger . Information ( "Downloaded calendar attachment {FileName} to {LocalPath}" , attachment . FileName , localFilePath ) ;
}
catch ( Exception ex )
{
_logger . Error ( ex , "Error downloading calendar attachment {AttachmentId}" , attachment . Id ) ;
throw ;
}
}
2025-02-16 11:54:23 +01:00
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
2026-02-07 19:47:21 +01:00
public override List < IRequestBundle < RequestInformation > > DeleteFolder ( DeleteFolderRequest request )
{
var networkCall = _graphClient . Me . MailFolders [ request . Folder . RemoteFolderId ] . ToDeleteRequestInformation ( ) ;
return [ new HttpRequestBundle < RequestInformation > ( networkCall , request ) ] ;
}
public override List < IRequestBundle < RequestInformation > > CreateSubFolder ( CreateSubFolderRequest request )
{
var requestBody = new MailFolder
{
DisplayName = request . NewFolderName
} ;
var networkCall = _graphClient . Me . MailFolders [ request . Folder . RemoteFolderId ] . ChildFolders . ToPostRequestInformation ( requestBody ) ;
return [ new HttpRequestBundle < RequestInformation > ( networkCall , request ) ] ;
}
2025-02-16 11:54:23 +01:00
#endregion
public override async Task ExecuteNativeRequestsAsync ( List < IRequestBundle < RequestInformation > > batchedRequests , CancellationToken cancellationToken = default )
{
2026-01-27 20:37:18 +01:00
// First apply all UI changes immediately before any batching.
// This ensures UI reflects changes right away, regardless of batch processing.
foreach ( var bundle in batchedRequests )
{
bundle . UIChangeRequest ? . ApplyUIChanges ( ) ;
}
2026-02-21 16:14:55 +01:00
// SendDraft requests may include large attachments, which require upload sessions.
// Upload these attachments before the batched patch/send sequence.
foreach ( var sendDraftBundle in batchedRequests . Where ( b = > b . UIChangeRequest is SendDraftRequest ) )
{
var sendDraftRequest = sendDraftBundle . UIChangeRequest as SendDraftRequest ;
try
{
await UploadDraftAttachmentsAsync ( sendDraftRequest , cancellationToken ) . ConfigureAwait ( false ) ;
}
catch
{
sendDraftRequest ? . RevertUIChanges ( ) ;
throw ;
}
}
2026-04-08 15:31:14 +02:00
var directRequests = batchedRequests
. Where ( bundle = > bundle . Request is ChangeJunkStateRequest )
. ToList ( ) ;
foreach ( var directRequest in directRequests )
{
try
{
await _graphClient . RequestAdapter . SendAsync (
directRequest . NativeRequest ,
Message . CreateFromDiscriminatorValue ,
cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
}
catch
{
directRequest . UIChangeRequest ? . RevertUIChanges ( ) ;
throw ;
}
}
2026-01-27 20:37:18 +01:00
// Now batch and execute the network requests.
2026-04-08 15:31:14 +02:00
var batchEligibleRequests = batchedRequests
. Except ( directRequests )
. ToList ( ) ;
var batchedGroups = batchEligibleRequests . 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
2026-01-27 20:37:18 +01:00
// UI changes are already applied in ExecuteNativeRequestsAsync before batching.
2025-02-26 19:59:11 +01:00
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 ) ;
}
2026-02-23 01:51:44 +01:00
else
{
await HandleSuccessfulResponseAsync ( bundle , response ) . ConfigureAwait ( false ) ;
}
2025-02-26 19:59:11 +01:00
}
}
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 ,
2026-04-07 16:48:46 +02:00
Request = bundle . Request ,
2026-04-07 13:23:07 +02:00
IsEntityNotFound = IsKnownOutlookEntityNotFoundError ( response . StatusCode , errorCode , errorMessage , bundle ) ,
2025-04-26 10:49:55 +02:00
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
2026-03-06 13:43:16 +01:00
// Transient errors still need to bubble so the request can be retried or surfaced to the caller.
if ( ! handled | | errorContext . Severity = = SynchronizerErrorSeverity . Transient )
2025-04-26 10:49:55 +02:00
{
2026-04-07 16:48:46 +02:00
CaptureSynchronizationIssue ( errorContext ) ;
2025-04-26 10:49:55 +02:00
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
2026-04-07 13:23:07 +02:00
private static bool IsKnownOutlookEntityNotFoundError (
HttpStatusCode statusCode ,
string errorCode ,
string errorMessage ,
IRequestBundle < RequestInformation > bundle )
{
if ( statusCode ! = HttpStatusCode . NotFound | | bundle ? . UIChangeRequest = = null )
return false ;
if ( ! IsExistingEntityOperation ( bundle . UIChangeRequest ) )
return false ;
var normalizedErrorCode = errorCode ? . Trim ( ) . ToLowerInvariant ( ) ? ? string . Empty ;
var normalizedMessage = errorMessage ? . Trim ( ) . ToLowerInvariant ( ) ? ? string . Empty ;
return normalizedErrorCode . Contains ( "notfound" )
| | normalizedErrorCode . Contains ( "itemnotfound" )
| | normalizedErrorCode . Contains ( "resource" )
| | normalizedMessage . Contains ( "not found" )
| | normalizedMessage . Contains ( "does not exist" )
| | normalizedMessage . Contains ( "cannot be found" ) ;
}
private static bool IsExistingEntityOperation ( IUIChangeRequest request )
= > request is BatchDeleteRequest
| | request is BatchMoveRequest
2026-04-08 15:31:14 +02:00
| | request is BatchChangeJunkStateRequest
2026-04-07 13:23:07 +02:00
| | request is BatchChangeFlagRequest
| | request is BatchMarkReadRequest
| | request is BatchArchiveRequest
| | request is DeleteRequest
| | request is MoveRequest
2026-04-08 15:31:14 +02:00
| | request is ChangeJunkStateRequest
2026-04-07 13:23:07 +02:00
| | request is ChangeFlagRequest
| | request is MarkReadRequest
| | request is ArchiveRequest
| | request is RenameFolderRequest
| | request is DeleteFolderRequest
| | request is AcceptEventRequest
| | request is DeclineEventRequest
| | request is OutlookDeclineEventRequest
| | request is TentativeEventRequest
| | request is UpdateCalendarEventRequest
| | request is DeleteCalendarEventRequest ;
2026-02-23 01:51:44 +01:00
private async Task HandleSuccessfulResponseAsync ( IRequestBundle < RequestInformation > bundle , HttpResponseMessage response )
{
try
{
2026-04-01 01:41:17 +02:00
if ( bundle ? . UIChangeRequest is MarkReadRequest markReadRequest )
{
await _outlookChangeProcessor . ChangeMailReadStatusAsync ( markReadRequest . Item . Id , markReadRequest . IsRead ) . ConfigureAwait ( false ) ;
return ;
}
if ( bundle ? . UIChangeRequest is ChangeFlagRequest changeFlagRequest )
{
await _outlookChangeProcessor . ChangeFlagStatusAsync ( changeFlagRequest . Item . Id , changeFlagRequest . IsFlagged ) . ConfigureAwait ( false ) ;
return ;
}
2026-02-23 01:51:44 +01:00
var content = await response . Content . ReadAsStringAsync ( ) . ConfigureAwait ( false ) ;
if ( string . IsNullOrWhiteSpace ( content ) )
return ;
var json = JsonNode . Parse ( content ) ;
2026-03-07 17:13:48 +01:00
if ( bundle ? . UIChangeRequest is CreateDraftRequest createDraftRequest )
{
var createdDraftId = json ? [ "id" ] ? . GetValue < string > ( ) ;
if ( string . IsNullOrWhiteSpace ( createdDraftId ) )
return ;
var createdConversationId = json ? [ "conversationId" ] ? . GetValue < string > ( ) ;
var localDraft = createDraftRequest . DraftPreperationRequest . CreatedLocalDraftCopy ;
await _outlookChangeProcessor . MapLocalDraftAsync (
Account . Id ,
localDraft . UniqueId ,
createdDraftId ,
createdConversationId ,
createdConversationId ) . ConfigureAwait ( false ) ;
2026-02-23 01:51:44 +01:00
return ;
2026-03-07 17:13:48 +01:00
}
2026-02-23 01:51:44 +01:00
2026-03-07 17:13:48 +01:00
if ( bundle ? . UIChangeRequest is CreateCalendarEventRequest createCalendarEventRequest )
{
var createdEventId = json ? [ "id" ] ? . GetValue < string > ( ) ;
if ( string . IsNullOrWhiteSpace ( createdEventId ) )
return ;
2026-02-23 01:51:44 +01:00
2026-03-07 17:13:48 +01:00
await UploadCalendarEventAttachmentsAsync ( createCalendarEventRequest , createdEventId , CancellationToken . None ) . ConfigureAwait ( false ) ;
}
2026-02-23 01:51:44 +01:00
}
catch ( Exception ex )
{
2026-03-07 17:13:48 +01:00
_logger . Debug ( ex , "Failed to process Outlook create response." ) ;
}
}
private async Task UploadCalendarEventAttachmentsAsync ( CreateCalendarEventRequest request , string remoteEventId , CancellationToken cancellationToken )
{
var attachments = request . ComposeResult . Attachments ? ? [ ] ;
if ( attachments . Count = = 0 )
return ;
var remoteCalendarId = request . AssignedCalendar . RemoteCalendarId ;
foreach ( var attachment in attachments . Where ( a = > ! string . IsNullOrWhiteSpace ( a . FilePath ) & & File . Exists ( a . FilePath ) ) )
{
cancellationToken . ThrowIfCancellationRequested ( ) ;
var contentBytes = await File . ReadAllBytesAsync ( attachment . FilePath , cancellationToken ) . ConfigureAwait ( false ) ;
var contentType = MimeTypes . GetMimeType ( attachment . FileName ? ? attachment . FilePath ) ;
var fileAttachment = new FileAttachment
{
Name = attachment . FileName ,
ContentType = contentType ,
ContentBytes = contentBytes
} ;
if ( contentBytes . Length < = SimpleAttachmentUploadLimitBytes )
{
await _graphClient . Me . Calendars [ remoteCalendarId ] . Events [ remoteEventId ] . Attachments . PostAsync ( fileAttachment , cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
continue ;
}
if ( contentBytes . Length > MaximumUploadSessionAttachmentSizeBytes )
{
var attachmentSizeMb = contentBytes . LongLength / ( 1024d * 1024d ) ;
var maximumSizeMb = MaximumUploadSessionAttachmentSizeBytes / ( 1024d * 1024d ) ;
throw new InvalidOperationException (
$"Attachment '{attachment.FileName}' is {attachmentSizeMb:F1} MB, which exceeds Outlook's upload limit of {maximumSizeMb:F0} MB per attachment." ) ;
}
var sessionBody = new Microsoft . Graph . Me . Calendars . Item . Events . Item . Attachments . CreateUploadSession . CreateUploadSessionPostRequestBody
{
AttachmentItem = new AttachmentItem
{
AttachmentType = AttachmentType . File ,
ContentType = contentType ,
Name = attachment . FileName ,
Size = contentBytes . LongLength
}
} ;
var uploadSession = await _graphClient . Me . Calendars [ remoteCalendarId ] . Events [ remoteEventId ] . Attachments . CreateUploadSession . PostAsync ( sessionBody , cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
if ( uploadSession ? . UploadUrl = = null )
{
throw new InvalidOperationException ( $"Failed to create upload session for attachment '{attachment.FileName}'." ) ;
}
await UploadAttachmentInChunksAsync ( uploadSession . UploadUrl , contentBytes , cancellationToken ) . ConfigureAwait ( false ) ;
2026-02-23 01:51:44 +01: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 )
{
2026-02-12 18:57:55 +01:00
var messagesById = new Dictionary < string , Message > ( StringComparer . Ordinal ) ;
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
{
2026-02-12 18:57:55 +01:00
var folderIds = folders
. Where ( a = > a ! = null & & ! string . IsNullOrWhiteSpace ( a . RemoteFolderId ) )
. Select ( a = > a . RemoteFolderId )
. Distinct ( StringComparer . Ordinal )
. ToList ( ) ;
2025-02-22 00:22:00 +01:00
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 ;
2026-02-12 18:57:55 +01:00
} , cancellationToken ) ;
2025-02-22 00:22:00 +01:00
var result = await mailQuery ;
if ( result ? . Value ! = null )
{
2026-02-12 18:57:55 +01:00
lock ( messagesById )
2025-02-22 00:22:00 +01:00
{
2026-02-12 18:57:55 +01:00
foreach ( var message in result . Value )
{
if ( string . IsNullOrWhiteSpace ( message ? . Id ) ) continue ;
messagesById [ message . Id ] = message ;
}
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 )
{
2026-02-12 18:57:55 +01:00
foreach ( var message in result . Value )
{
if ( string . IsNullOrWhiteSpace ( message ? . Id ) ) continue ;
messagesById [ message . Id ] = message ;
}
2025-02-22 00:22:00 +01:00
}
}
2026-02-12 18:57:55 +01:00
if ( messagesById . 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
// Contains a list of message ids that potentially can be downloaded.
2026-02-12 18:57:55 +01:00
var messageIdsWithKnownFolder = new HashSet < string > ( StringComparer . Ordinal ) ;
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.
2026-02-12 18:57:55 +01:00
foreach ( var message in messagesById . Values )
2025-02-24 18:53:11 +01:00
{
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
2026-02-12 18:57:55 +01:00
if ( messageIdsWithKnownFolder . Count = = 0 ) return [ ] ;
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 = [ ] ;
2026-02-12 18:57:55 +01:00
foreach ( var id in messageIdsWithKnownFolder . Except ( locallyExistingMails , StringComparer . Ordinal ) )
2025-02-24 18:53:11 +01:00
{
2026-02-12 18:57:55 +01:00
if ( messagesById . TryGetValue ( id , out var message ) )
{
messagesToDownload . Add ( message ) ;
}
2025-02-24 18:53:11 +01:00
}
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
2026-02-10 21:35:55 +01:00
await TryMapCalendarInvitationAsync ( mailCopy , mimeMessage , cancellationToken ) . ConfigureAwait ( false ) ;
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.
2026-02-09 22:39:30 +01:00
var contacts = ExtractContactsFromOutlookMessage ( message ) ;
var package = new NewMailItemPackage ( mailCopy , mimeMessage , assignedFolder . RemoteFolderId , contacts ) ;
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
2026-02-10 21:35:55 +01:00
private async Task TryMapCalendarInvitationAsync ( MailCopy mailCopy , MimeMessage mimeMessage , CancellationToken cancellationToken )
{
if ( mailCopy . ItemType ! = MailItemType . CalendarInvitation | | mimeMessage = = null )
return ;
var invitationUid = mimeMessage . ExtractInvitationUid ( ) ;
if ( string . IsNullOrWhiteSpace ( invitationUid ) )
return ;
var calendars = await _outlookChangeProcessor . GetAccountCalendarsAsync ( Account . Id ) . ConfigureAwait ( false ) ;
if ( calendars = = null | | calendars . Count = = 0 )
return ;
string escapedUid = invitationUid . Replace ( "'" , "''" , StringComparison . Ordinal ) ;
foreach ( var calendar in calendars )
{
try
{
var eventsResponse = await _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events
. GetAsync ( requestConfiguration = >
{
requestConfiguration . QueryParameters . Filter = $"iCalUId eq '{escapedUid}'" ;
requestConfiguration . QueryParameters . Select = [ "id" ] ;
requestConfiguration . QueryParameters . Top = 1 ;
} , cancellationToken : cancellationToken )
. ConfigureAwait ( false ) ;
var matchedEvent = eventsResponse ? . Value ? . FirstOrDefault ( ) ;
if ( matchedEvent = = null | | string . IsNullOrWhiteSpace ( matchedEvent . Id ) )
continue ;
var fullEvent = await _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events [ matchedEvent . Id ]
. GetAsync ( requestConfiguration = >
{
requestConfiguration . QueryParameters . Expand = [ "attachments($select=id,name,contentType,size,isInline)" ] ;
} , cancellationToken : cancellationToken )
. ConfigureAwait ( false ) ;
if ( fullEvent = = null )
continue ;
await _outlookChangeProcessor . ManageCalendarEventAsync ( fullEvent , calendar , Account ) . ConfigureAwait ( false ) ;
var localCalendarItem = await _outlookChangeProcessor . GetCalendarItemAsync ( calendar . Id , fullEvent . Id ) . ConfigureAwait ( false ) ;
if ( localCalendarItem = = null )
return ;
await _outlookChangeProcessor . UpsertMailInvitationCalendarMappingAsync ( new MailInvitationCalendarMapping ( )
{
Id = Guid . NewGuid ( ) ,
AccountId = Account . Id ,
MailCopyId = mailCopy . Id ,
InvitationUid = invitationUid ,
CalendarId = calendar . Id ,
CalendarItemId = localCalendarItem . Id ,
CalendarRemoteEventId = fullEvent . Id
} ) . ConfigureAwait ( false ) ;
return ;
}
catch ( Exception ex )
{
_logger . Warning ( ex , "Failed to map Outlook calendar invitation mail {MailCopyId} for calendar {CalendarId}" , mailCopy . Id , calendar . Id ) ;
}
}
}
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
2026-02-15 19:57:48 +01:00
if ( options ? . Type = = CalendarSynchronizationType . CalendarMetadata )
return CalendarSynchronizationResult . Empty ;
2026-02-12 18:04:29 +01:00
var localCalendars = ( await _outlookChangeProcessor . GetAccountCalendarsAsync ( Account . Id ) . ConfigureAwait ( false ) )
. Where ( c = > c . IsSynchronizationEnabled )
. ToList ( ) ;
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 )
{
2026-04-07 16:48:46 +02:00
try
2025-02-16 11:43:30 +01:00
{
2026-04-07 16:48:46 +02:00
bool isInitialSync = string . IsNullOrEmpty ( calendar . SynchronizationDeltaToken ) ;
2025-02-16 11:54:23 +01:00
2026-04-07 16:48:46 +02:00
if ( isInitialSync )
2025-01-06 02:15:21 +01:00
{
2026-04-07 16:48:46 +02:00
_logger . Information ( "No calendar sync identifier for calendar {Name}. Performing initial sync." , calendar . Name ) ;
2025-01-06 02:15:21 +01:00
2026-04-07 16:48:46 +02:00
// ISO 8601 format as expected by Microsoft Graph API (e.g., "2019-11-08T19:00:00-08:00")
var startDate = DateTimeOffset . Now . AddYears ( - 2 ) . ToString ( "yyyy-MM-ddTHH:mm:sszzz" ) ;
var endDate = DateTimeOffset . Now . AddYears ( 2 ) . ToString ( "yyyy-MM-ddTHH:mm:sszzz" ) ;
2025-01-06 02:15:21 +01:00
2026-04-07 16:48:46 +02:00
// Get Id only. We will always download the full event.
eventsDeltaResponse = await _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . CalendarView . Delta . GetAsDeltaGetResponseAsync ( ( requestConfiguration ) = >
{
requestConfiguration . QueryParameters . Select = [ "id" , "type" ] ;
requestConfiguration . QueryParameters . StartDateTime = startDate ;
requestConfiguration . QueryParameters . EndDateTime = endDate ;
} , cancellationToken : cancellationToken ) ;
}
else
{
var currentDeltaToken = calendar . SynchronizationDeltaToken ;
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02:00
_logger . Information ( "Performing delta sync for calendar {Name}." , calendar . Name ) ;
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02:00
var requestInformation = _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . CalendarView . Delta . ToGetRequestInformation ( ) ;
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02:00
requestInformation . UrlTemplate = requestInformation . UrlTemplate . Insert ( requestInformation . UrlTemplate . Length - 1 , ",%24deltatoken" ) ;
requestInformation . QueryParameters . Add ( "%24deltatoken" , currentDeltaToken ) ;
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02:00
eventsDeltaResponse = await _graphClient . RequestAdapter . SendAsync ( requestInformation , Microsoft . Graph . Me . Calendars . Item . CalendarView . Delta . DeltaGetResponse . CreateFromDiscriminatorValue ) ;
}
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02:00
List < Event > events = new ( ) ;
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02: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
2026-04-07 16:48:46 +02:00
var messageIteratorAsync = PageIterator < Event , Microsoft . Graph . Me . Calendars . Item . CalendarView . Delta . DeltaGetResponse > . CreatePageIterator ( _graphClient , eventsDeltaResponse , ( item ) = >
{
// Include all event types: SingleInstance, SeriesMaster, Occurrence, and Exception
// CalendarView already expands recurring events into individual occurrences
events . Add ( item ) ;
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02:00
return true ;
} ) ;
2025-02-16 11:43:30 +01:00
2026-04-07 16:48:46 +02:00
await messageIteratorAsync
. IterateAsync ( cancellationToken )
. ConfigureAwait ( false ) ;
2025-02-16 11:43:30 +01:00
2026-04-07 16:48:46 +02:00
// Desc-order will move parent recurring events to the top.
events = events . OrderByDescending ( a = > a . Type ) . ToList ( ) ;
2026-01-03 19:33:36 +01:00
2026-04-07 16:48:46 +02:00
_logger . Information ( "Found {Count} events in total." , events . Count ) ;
foreach ( var item in events )
2025-02-16 11:54:23 +01:00
{
2026-04-07 16:48:46 +02:00
// Declined events are returned as Deleted from the API.
// There is no way to distinguish unfortunately atm.
if ( IsResourceDeleted ( item . AdditionalData ) )
{
await _outlookChangeProcessor . DeleteCalendarItemAsync ( item . Id , calendar . Id ) . ConfigureAwait ( false ) ;
continue ;
}
2025-12-30 23:32:00 +01:00
try
{
2026-04-07 16:48:46 +02:00
await _handleCalendarEventRetrievalSemaphore . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
2025-12-30 23:32:00 +01:00
2026-01-03 23:59:37 +01:00
Event fullEvent = await _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events [ item . Id ]
. GetAsync ( requestConfiguration = >
{
// Expand attachments but only get metadata, not the full content
requestConfiguration . QueryParameters . Expand = new [ ] { "attachments($select=id,name,contentType,size,isInline)" } ;
} , cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
2025-12-30 23:32:00 +01:00
await _outlookChangeProcessor . ManageCalendarEventAsync ( fullEvent , calendar , Account ) . ConfigureAwait ( false ) ;
}
2026-04-07 16:48:46 +02:00
catch ( OperationCanceledException )
{
throw ;
}
2025-12-30 23:32:00 +01:00
catch ( Exception ex )
{
2026-04-07 16:48:46 +02:00
var errorContext = new SynchronizerErrorContext
{
Account = Account ,
ErrorMessage = ex . Message ,
Exception = ex ,
CalendarId = calendar . Id ,
CalendarName = calendar . Name ,
OperationType = "CalendarEventSync" ,
Severity = SynchronizerErrorSeverity . Recoverable
} ;
_ = await _errorHandlingFactory . HandleErrorAsync ( errorContext ) . ConfigureAwait ( false ) ;
CaptureSynchronizationIssue ( errorContext ) ;
2025-12-30 23:32:00 +01:00
_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
2026-04-07 16:48:46 +02:00
var latestDeltaLink = messageIteratorAsync . Deltalink ;
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02: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.
var deltaToken = GetDeltaTokenFromDeltaLink ( latestDeltaLink ) ;
await _outlookChangeProcessor . UpdateCalendarDeltaSynchronizationToken ( calendar . Id , deltaToken ) . ConfigureAwait ( false ) ;
}
}
catch ( OperationCanceledException )
2025-02-16 11:54:23 +01:00
{
2026-04-07 16:48:46 +02:00
throw ;
}
catch ( Exception ex )
{
var errorContext = new SynchronizerErrorContext
{
Account = Account ,
ErrorMessage = ex . Message ,
Exception = ex ,
CalendarId = calendar . Id ,
CalendarName = calendar . Name ,
OperationType = "CalendarSync"
} ;
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02:00
_ = await _errorHandlingFactory . HandleErrorAsync ( errorContext ) . ConfigureAwait ( false ) ;
CaptureSynchronizationIssue ( errorContext ) ;
2025-01-07 13:42:10 +01:00
2026-04-07 16:48:46 +02:00
if ( ! errorContext . CanContinueSync )
throw ;
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 ) ;
2026-02-12 18:04:29 +01:00
var remotePrimaryCalendarId = await GetPrimaryCalendarIdAsync ( calendars . Value , 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 ) ;
2026-04-04 20:23:20 +02:00
var usedCalendarColors = new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) ;
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.
2026-04-04 20:23:20 +02:00
var fallbackColor = ColorHelpers . GetDistinctFlatColorHex ( usedCalendarColors , calendar . HexColor ) ;
2026-02-15 11:27:30 +01:00
var localCalendar = calendar . AsCalendar ( Account , fallbackColor ) ;
2026-02-12 18:04:29 +01:00
localCalendar . IsPrimary = string . Equals ( localCalendar . RemoteCalendarId , remotePrimaryCalendarId , StringComparison . OrdinalIgnoreCase ) ;
2026-04-04 20:23:20 +02:00
localCalendar . BackgroundColorHex = ColorHelpers . GetDistinctFlatColorHex ( usedCalendarColors , localCalendar . BackgroundColorHex ) ;
localCalendar . TextColorHex = ColorHelpers . GetReadableTextColorHex ( localCalendar . BackgroundColorHex ) ;
2026-02-15 11:27:30 +01:00
usedCalendarColors . Add ( localCalendar . BackgroundColorHex ) ;
2025-02-16 11:54:23 +01:00
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.
2026-04-04 20:23:20 +02:00
var resolvedColor = ColorHelpers . GetDistinctFlatColorHex ( usedCalendarColors , existingLocalCalendar . BackgroundColorHex ) ;
if ( ShouldUpdateCalendar ( calendar , existingLocalCalendar , remotePrimaryCalendarId ) | |
! string . Equals ( existingLocalCalendar . BackgroundColorHex , resolvedColor , StringComparison . OrdinalIgnoreCase ) )
2025-01-06 02:15:21 +01:00
{
2025-02-16 11:54:23 +01:00
existingLocalCalendar . Name = calendar . Name ;
2026-02-12 18:04:29 +01:00
existingLocalCalendar . IsPrimary = string . Equals ( existingLocalCalendar . RemoteCalendarId , remotePrimaryCalendarId , StringComparison . OrdinalIgnoreCase ) ;
2026-04-04 20:23:20 +02:00
existingLocalCalendar . BackgroundColorHex = resolvedColor ;
existingLocalCalendar . TextColorHex = ColorHelpers . GetReadableTextColorHex ( existingLocalCalendar . BackgroundColorHex ) ;
2025-02-16 11:54:23 +01:00
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
}
2026-04-04 20:23:20 +02:00
usedCalendarColors . Add ( resolvedColor ) ;
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
2026-02-12 18:04:29 +01:00
private bool ShouldUpdateCalendar ( Calendar calendar , AccountCalendar accountCalendar , string remotePrimaryCalendarId )
2025-02-16 11:54:23 +01:00
{
var remoteCalendarName = calendar . Name ;
2026-02-12 18:04:29 +01:00
var remoteBackgroundColor = string . IsNullOrEmpty ( calendar . HexColor ) ? accountCalendar . BackgroundColorHex : calendar . HexColor ;
var remoteIsPrimary = string . Equals ( calendar . Id , remotePrimaryCalendarId , StringComparison . OrdinalIgnoreCase ) ;
bool isNameChanged = ! string . Equals ( accountCalendar . Name , remoteCalendarName , StringComparison . OrdinalIgnoreCase ) ;
bool isBackgroundColorChanged = ! string . Equals ( accountCalendar . BackgroundColorHex , remoteBackgroundColor , StringComparison . OrdinalIgnoreCase ) ;
bool isPrimaryChanged = accountCalendar . IsPrimary ! = remoteIsPrimary ;
return isNameChanged | | isBackgroundColorChanged | | isPrimaryChanged ;
}
private async Task < string > GetPrimaryCalendarIdAsync ( IList < Calendar > remoteCalendars , CancellationToken cancellationToken )
{
if ( remoteCalendars = = null | | remoteCalendars . Count = = 0 )
return string . Empty ;
var explicitPrimary = remoteCalendars . FirstOrDefault ( c = > c . IsDefaultCalendar . GetValueOrDefault ( ) ) ;
if ( explicitPrimary ! = null )
return explicitPrimary . Id ;
try
{
var meCalendar = await _graphClient . Me . Calendar . GetAsync ( cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
if ( ! string . IsNullOrEmpty ( meCalendar ? . Id ) )
return meCalendar . Id ;
}
catch ( Exception ex )
{
_logger . Warning ( ex , "Failed to fetch default Outlook calendar for {Name}. Falling back to first available calendar." , Account . Name ) ;
}
2025-02-15 12:53:32 +01:00
2026-02-12 18:04:29 +01:00
return remoteCalendars . First ( ) . Id ;
2025-02-16 11:54:23 +01:00
}
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 )
{
2026-03-07 17:13:48 +01:00
var calendarItem = request . PreparedItem ;
var attendees = request . PreparedEvent . Attendees ;
var reminders = request . PreparedEvent . Reminders ;
var calendar = request . AssignedCalendar ;
2025-12-30 11:59:54 +01:00
var outlookEvent = new Microsoft . Graph . Models . Event
{
Subject = calendarItem . Title ,
Body = new Microsoft . Graph . Models . ItemBody
{
2026-03-07 17:13:48 +01:00
ContentType = Microsoft . Graph . Models . BodyType . Html ,
2025-12-30 11:59:54 +01:00
Content = calendarItem . Description
} ,
Location = new Microsoft . Graph . Models . Location
{
DisplayName = calendarItem . Location
2026-03-07 17:13:48 +01:00
} ,
ShowAs = calendarItem . ShowAs switch
{
CalendarItemShowAs . Free = > Microsoft . Graph . Models . FreeBusyStatus . Free ,
CalendarItemShowAs . Tentative = > Microsoft . Graph . Models . FreeBusyStatus . Tentative ,
CalendarItemShowAs . Busy = > Microsoft . Graph . Models . FreeBusyStatus . Busy ,
CalendarItemShowAs . OutOfOffice = > Microsoft . Graph . Models . FreeBusyStatus . Oof ,
CalendarItemShowAs . WorkingElsewhere = > Microsoft . Graph . Models . FreeBusyStatus . WorkingElsewhere ,
_ = > Microsoft . Graph . Models . FreeBusyStatus . Busy
} ,
TransactionId = calendarItem . Id . ToString ( "N" )
2025-12-30 11:59:54 +01:00
} ;
if ( calendarItem . IsAllDayEvent )
{
outlookEvent . IsAllDay = true ;
outlookEvent . Start = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . StartDate . ToString ( "yyyy-MM-dd" ) ,
2026-03-07 17:13:48 +01:00
TimeZone = calendarItem . StartTimeZone ? ? TimeZoneInfo . Local . Id
2025-12-30 11:59:54 +01:00
} ;
outlookEvent . End = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . EndDate . ToString ( "yyyy-MM-dd" ) ,
2026-03-07 17:13:48 +01:00
TimeZone = calendarItem . EndTimeZone ? ? calendarItem . StartTimeZone ? ? TimeZoneInfo . Local . Id
2025-12-30 11:59:54 +01:00
} ;
}
else
{
outlookEvent . IsAllDay = false ;
outlookEvent . Start = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . StartDate . ToString ( "yyyy-MM-ddTHH:mm:ss" ) ,
2026-01-05 00:21:07 +01:00
TimeZone = calendarItem . StartTimeZone ? ? TimeZoneInfo . Local . Id
2025-12-30 11:59:54 +01:00
} ;
outlookEvent . End = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . EndDate . ToString ( "yyyy-MM-ddTHH:mm:ss" ) ,
2026-01-05 00:21:07 +01:00
TimeZone = calendarItem . EndTimeZone ? ? TimeZoneInfo . Local . Id
2025-12-30 11:59:54 +01:00
} ;
}
2026-03-07 17:13:48 +01:00
if ( attendees . Count > 0 )
2025-12-30 11:59:54 +01:00
{
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 ( ) ;
}
2026-03-07 17:13:48 +01:00
if ( reminders . Count > 0 )
{
var reminder = reminders
. OrderBy ( reminder = > reminder . DurationInSeconds )
. FirstOrDefault ( reminder = > reminder . ReminderType = = CalendarItemReminderType . Popup )
? ? reminders . OrderBy ( reminder = > reminder . DurationInSeconds ) . First ( ) ;
outlookEvent . IsReminderOn = true ;
outlookEvent . ReminderMinutesBeforeStart = ( int ) Math . Max ( 0 , reminder . DurationInSeconds / 60 ) ;
}
var recurrence = CalendarRecurrenceMapper . CreateOutlookRecurrence ( calendarItem ) ;
if ( recurrence ! = null )
{
outlookEvent . Recurrence = recurrence ;
}
2025-12-30 11:59:54 +01:00
var createRequest = _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events . ToPostRequestInformation ( outlookEvent ) ;
return [ new HttpRequestBundle < RequestInformation > ( createRequest , request ) ] ;
}
2026-01-03 19:33:36 +01:00
public override List < IRequestBundle < RequestInformation > > AcceptEvent ( AcceptEventRequest request )
{
var calendarItem = request . Item ;
var calendar = calendarItem . AssignedCalendar ;
if ( calendar = = null )
{
throw new InvalidOperationException ( "Calendar item must have an assigned calendar" ) ;
}
2026-03-07 17:13:48 +01:00
var remoteEventId = calendarItem . RemoteEventId . GetProviderRemoteEventId ( ) ;
if ( string . IsNullOrEmpty ( remoteEventId ) )
2026-01-03 19:33:36 +01:00
{
throw new InvalidOperationException ( "Cannot accept event without remote event ID" ) ;
}
2026-03-07 17:13:48 +01:00
var acceptRequestInfo = _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events [ remoteEventId ] . Accept . ToPostRequestInformation ( new Microsoft . Graph . Me . Calendars . Item . Events . Item . Accept . AcceptPostRequestBody
2026-01-03 19:33:36 +01:00
{
Comment = request . ResponseMessage ,
SendResponse = ! string . IsNullOrEmpty ( request . ResponseMessage )
} ) ;
return [ new HttpRequestBundle < RequestInformation > ( acceptRequestInfo , request ) ] ;
}
public override List < IRequestBundle < RequestInformation > > OutlookDeclineEvent ( OutlookDeclineEventRequest request )
{
var responseMessage = request . ResponseMessage ;
var calendarItem = request . Item ;
var calendar = calendarItem . AssignedCalendar ;
if ( calendar = = null )
{
throw new InvalidOperationException ( "Calendar item must have an assigned calendar" ) ;
}
2026-03-07 17:13:48 +01:00
var remoteEventId = calendarItem . RemoteEventId . GetProviderRemoteEventId ( ) ;
if ( string . IsNullOrEmpty ( remoteEventId ) )
2026-01-03 19:33:36 +01:00
{
throw new InvalidOperationException ( "Cannot decline event without remote event ID" ) ;
}
2026-03-07 17:13:48 +01:00
var declineRequestInfo = _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events [ remoteEventId ] . Decline . ToPostRequestInformation ( new Microsoft . Graph . Me . Calendars . Item . Events . Item . Decline . DeclinePostRequestBody
2026-01-03 19:33:36 +01:00
{
Comment = responseMessage ,
SendResponse = ! string . IsNullOrEmpty ( responseMessage )
} ) ;
return [ new HttpRequestBundle < RequestInformation > ( declineRequestInfo , request ) ] ;
}
public override List < IRequestBundle < RequestInformation > > TentativeEvent ( TentativeEventRequest request )
{
var calendarItem = request . Item ;
var calendar = calendarItem . AssignedCalendar ;
if ( calendar = = null )
{
throw new InvalidOperationException ( "Calendar item must have an assigned calendar" ) ;
}
2026-03-07 17:13:48 +01:00
var remoteEventId = calendarItem . RemoteEventId . GetProviderRemoteEventId ( ) ;
if ( string . IsNullOrEmpty ( remoteEventId ) )
2026-01-03 19:33:36 +01:00
{
throw new InvalidOperationException ( "Cannot tentatively accept event without remote event ID" ) ;
}
2026-03-07 17:13:48 +01:00
var tentativelyAcceptRequestInfo = _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events [ remoteEventId ] . TentativelyAccept . ToPostRequestInformation ( new Microsoft . Graph . Me . Calendars . Item . Events . Item . TentativelyAccept . TentativelyAcceptPostRequestBody
2026-01-03 19:33:36 +01:00
{
Comment = request . ResponseMessage ,
SendResponse = ! string . IsNullOrEmpty ( request . ResponseMessage )
} ) ;
return [ new HttpRequestBundle < RequestInformation > ( tentativelyAcceptRequestInfo , request ) ] ;
}
2026-01-05 00:21:07 +01:00
public override List < IRequestBundle < RequestInformation > > UpdateCalendarEvent ( UpdateCalendarEventRequest request )
{
var calendarItem = request . Item ;
var attendees = request . Attendees ;
// Get the calendar for this event
var calendar = calendarItem . AssignedCalendar ;
if ( calendar = = null )
{
throw new InvalidOperationException ( "Calendar item must have an assigned calendar" ) ;
}
// Convert CalendarItem to Outlook Event for update
var outlookEvent = new Microsoft . Graph . Models . Event
{
Subject = calendarItem . Title ,
Body = new Microsoft . Graph . Models . ItemBody
{
2026-04-08 19:52:01 +02:00
// CalendarItem.Description stores HTML notes content, so updates must preserve it as HTML.
ContentType = Microsoft . Graph . Models . BodyType . Html ,
2026-01-05 00:21:07 +01:00
Content = calendarItem . Description
} ,
Location = new Microsoft . Graph . Models . Location
{
DisplayName = calendarItem . Location
} ,
ShowAs = calendarItem . ShowAs switch
{
CalendarItemShowAs . Free = > Microsoft . Graph . Models . FreeBusyStatus . Free ,
CalendarItemShowAs . Tentative = > Microsoft . Graph . Models . FreeBusyStatus . Tentative ,
CalendarItemShowAs . Busy = > Microsoft . Graph . Models . FreeBusyStatus . Busy ,
CalendarItemShowAs . OutOfOffice = > Microsoft . Graph . Models . FreeBusyStatus . Oof ,
CalendarItemShowAs . WorkingElsewhere = > Microsoft . Graph . Models . FreeBusyStatus . WorkingElsewhere ,
_ = > Microsoft . Graph . Models . FreeBusyStatus . Busy
}
} ;
// Set start and end time using DateTimeTimeZone
if ( calendarItem . IsAllDayEvent )
{
// All-day events
outlookEvent . IsAllDay = true ;
outlookEvent . Start = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . StartDate . ToString ( "yyyy-MM-dd" ) ,
TimeZone = "UTC"
} ;
outlookEvent . End = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . EndDate . ToString ( "yyyy-MM-dd" ) ,
TimeZone = "UTC"
} ;
}
else
{
// Regular events with time
// StartDate and EndDate are stored in the event's timezone
// We preserve the timezone information during update
outlookEvent . IsAllDay = false ;
outlookEvent . Start = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . StartDate . ToString ( "yyyy-MM-ddTHH:mm:ss" ) ,
TimeZone = calendarItem . StartTimeZone ? ? TimeZoneInfo . Local . Id
} ;
outlookEvent . End = new Microsoft . Graph . Models . DateTimeTimeZone
{
DateTime = calendarItem . EndDate . ToString ( "yyyy-MM-ddTHH:mm:ss" ) ,
TimeZone = calendarItem . EndTimeZone ? ? TimeZoneInfo . Local . Id
} ;
}
// Add attendees if any
if ( attendees ! = null & & attendees . Count > 0 )
{
outlookEvent . Attendees = attendees . Select ( a = > new Microsoft . Graph . Models . Attendee
{
EmailAddress = new Microsoft . Graph . Models . EmailAddress
{
Address = a . Email ,
Name = a . Name
} ,
Type = a . IsOptionalAttendee ? Microsoft . Graph . Models . AttendeeType . Optional : Microsoft . Graph . Models . AttendeeType . Required
} ) . ToList ( ) ;
}
// Update the event using Graph API
2026-03-07 17:13:48 +01:00
var updateRequest = _graphClient . Me . Events [ calendarItem . RemoteEventId . GetProviderRemoteEventId ( ) ] . ToPatchRequestInformation ( outlookEvent ) ;
2026-01-05 00:21:07 +01:00
return [ new HttpRequestBundle < RequestInformation > ( updateRequest , request ) ] ;
}
2026-04-08 23:46:02 +02:00
public override List < IRequestBundle < RequestInformation > > ChangeStartAndEndDate ( ChangeStartAndEndDateRequest request )
= > UpdateCalendarEvent ( request ) ;
2026-01-06 11:11:37 +01:00
public override List < IRequestBundle < RequestInformation > > DeleteCalendarEvent ( DeleteCalendarEventRequest request )
{
var calendarItem = request . Item ;
// Get the calendar for this event
var calendar = calendarItem . AssignedCalendar ;
if ( calendar = = null )
{
throw new InvalidOperationException ( "Calendar item must have an assigned calendar" ) ;
}
2026-03-07 17:13:48 +01:00
var remoteEventId = calendarItem . RemoteEventId . GetProviderRemoteEventId ( ) ;
if ( string . IsNullOrEmpty ( remoteEventId ) )
2026-01-06 11:11:37 +01:00
{
throw new InvalidOperationException ( "Cannot delete event without remote event ID" ) ;
}
2026-03-07 17:13:48 +01:00
var deleteRequest = _graphClient . Me . Calendars [ calendar . RemoteCalendarId ] . Events [ remoteEventId ] . ToDeleteRequestInformation ( ) ;
2026-01-06 11:11:37 +01:00
return [ new HttpRequestBundle < RequestInformation > ( deleteRequest , request ) ] ;
}
2025-12-30 11:59:54 +01:00
#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
}
}