diff --git a/Wino.Core.Domain/Models/Synchronization/SynchronizerErrorContext.cs b/Wino.Core.Domain/Models/Synchronization/SynchronizerErrorContext.cs
index 9ba4c84c..aee886df 100644
--- a/Wino.Core.Domain/Models/Synchronization/SynchronizerErrorContext.cs
+++ b/Wino.Core.Domain/Models/Synchronization/SynchronizerErrorContext.cs
@@ -82,6 +82,13 @@ public class SynchronizerErrorContext
///
public string OperationType { get; set; }
+ ///
+ /// Gets or sets whether the error was explicitly classified as a missing remote entity.
+ /// This is used to distinguish true "mail/folder/event no longer exists" cases from
+ /// unrelated HTTP 404 responses that should still surface to the user.
+ ///
+ public bool IsEntityNotFound { get; set; }
+
///
/// Gets whether this error should be retried based on severity and retry count.
///
diff --git a/Wino.Core.Tests/Synchronizers/GmailSynchronizerRequestSuccessTests.cs b/Wino.Core.Tests/Synchronizers/GmailSynchronizerRequestSuccessTests.cs
index 0e82a9b0..4e2f2fc8 100644
--- a/Wino.Core.Tests/Synchronizers/GmailSynchronizerRequestSuccessTests.cs
+++ b/Wino.Core.Tests/Synchronizers/GmailSynchronizerRequestSuccessTests.cs
@@ -5,6 +5,7 @@ using FluentAssertions;
using Google.Apis.Requests;
using Moq;
using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
@@ -142,6 +143,70 @@ public sealed class GmailSynchronizerRequestSuccessTests
errorFactory.Verify(x => x.HandleErrorAsync(It.IsAny()), Times.Once);
}
+ [Fact]
+ public async Task ProcessSingleNativeRequestResponseAsync_Generic404Error_DoesNotClassifyAsEntityNotFound()
+ {
+ var changeProcessor = new Mock(MockBehavior.Strict);
+ SynchronizerErrorContext? capturedContext = null;
+
+ var errorFactory = new Mock(MockBehavior.Strict);
+ errorFactory
+ .Setup(x => x.HandleErrorAsync(It.IsAny()))
+ .Callback(context => capturedContext = context)
+ .ReturnsAsync(false);
+
+ var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object);
+ var request = new DeleteRequest(CreateMailCopy("mail-1"));
+ var bundle = new HttpRequestBundle(Mock.Of(), request, request);
+ using var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(string.Empty)
+ };
+ var error = new RequestError
+ {
+ Code = 404,
+ Message = "Not Found"
+ };
+
+ var act = () => InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error);
+
+ await act.Should().ThrowAsync();
+ capturedContext.Should().NotBeNull();
+ capturedContext!.IsEntityNotFound.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task ProcessSingleNativeRequestResponseAsync_Entity404Error_ClassifiesAsEntityNotFound()
+ {
+ var changeProcessor = new Mock(MockBehavior.Strict);
+ SynchronizerErrorContext? capturedContext = null;
+
+ var errorFactory = new Mock(MockBehavior.Strict);
+ errorFactory
+ .Setup(x => x.HandleErrorAsync(It.IsAny()))
+ .Callback(context => capturedContext = context)
+ .ReturnsAsync(false);
+
+ var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object);
+ var request = new DeleteRequest(CreateMailCopy("mail-1"));
+ var bundle = new HttpRequestBundle(Mock.Of(), request, request);
+ using var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(string.Empty)
+ };
+ var error = new RequestError
+ {
+ Code = 404,
+ Message = "Requested entity was not found."
+ };
+
+ var act = () => InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error);
+
+ await act.Should().ThrowAsync();
+ capturedContext.Should().NotBeNull();
+ capturedContext!.IsEntityNotFound.Should().BeTrue();
+ }
+
private static GmailSynchronizer CreateSynchronizer(
IGmailChangeProcessor changeProcessor,
IGmailSynchronizerErrorHandlerFactory? errorFactory = null)
diff --git a/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs b/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs
index 0f64aaf1..8b429c0f 100644
--- a/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs
+++ b/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs
@@ -21,7 +21,7 @@ public class GmailSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFa
RegisterHandler(authenticationFailedHandler);
RegisterHandler(quotaExceededHandler);
RegisterHandler(historyExpiredHandler);
+ RegisterHandler(rateLimitHandler);
RegisterHandler(entityNotFoundHandler);
- RegisterHandler(rateLimitHandler); // Most generic rate limit handler last
}
}
diff --git a/Wino.Core/Synchronizers/Errors/EntityNotFoundHandler.cs b/Wino.Core/Synchronizers/Errors/EntityNotFoundHandler.cs
index afa154b8..ca30bc2a 100644
--- a/Wino.Core/Synchronizers/Errors/EntityNotFoundHandler.cs
+++ b/Wino.Core/Synchronizers/Errors/EntityNotFoundHandler.cs
@@ -8,26 +8,27 @@ using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors;
///
-/// Generic handler for 404 (Not Found) errors across all synchronizers.
-/// When a resource is already gone on the server, this handler applies
-/// the intended change locally instead of throwing.
-/// Works for all mail actions, folder actions, and batch operations.
+/// Handles errors that were explicitly classified as missing remote entities.
+/// This avoids swallowing unrelated HTTP 404 responses that should still surface
+/// to the user as real synchronization failures.
///
public class EntityNotFoundHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext();
private readonly IMailService _mailService;
private readonly IFolderService _folderService;
+ private readonly ICalendarService _calendarService;
- public EntityNotFoundHandler(IMailService mailService, IFolderService folderService)
+ public EntityNotFoundHandler(IMailService mailService, IFolderService folderService, ICalendarService calendarService)
{
_mailService = mailService;
_folderService = folderService;
+ _calendarService = calendarService;
}
public bool CanHandle(SynchronizerErrorContext error)
{
- if (error.ErrorCode != 404) return false;
+ if (!error.IsEntityNotFound) return false;
if (error.RequestBundle == null) return false;
return true;
}
@@ -81,9 +82,30 @@ public class EntityNotFoundHandler : ISynchronizerErrorHandler
return true;
}
+ // --- Individual calendar actions ---
+ if (uiRequest is ICalendarActionRequest calendarAction)
+ {
+ _logger.Warning("Entity not found for calendar operation {Op} on {CalendarItemId}. Deleting locally.",
+ calendarAction.Operation, calendarAction.Item?.Id);
+
+ try
+ {
+ if (calendarAction.Item != null)
+ {
+ await _calendarService.DeleteCalendarItemAsync(calendarAction.Item.Id).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Failed to delete calendar item locally after entity-not-found.");
+ }
+
+ return true;
+ }
+
// --- Batch requests (can't identify specific item) ---
// Mark as recoverable. Next sync will clean up stale items.
- _logger.Warning("Entity not found (404) for batch operation. Marking as recoverable.");
+ _logger.Warning("Entity not found for batch operation. Marking as recoverable.");
return true;
}
}
diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs
index 69415630..5910e8f2 100644
--- a/Wino.Core/Synchronizers/GmailSynchronizer.cs
+++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs
@@ -1665,6 +1665,8 @@ public class GmailSynchronizer : WinoSynchronizer
{
{ "Error", error }
@@ -1702,7 +1705,7 @@ public class GmailSynchronizer : WinoSynchronizer bundle)
+ {
+ if (error?.Code != 404 || bundle?.UIChangeRequest == null)
+ return false;
+
+ if (!IsExistingEntityOperation(bundle.UIChangeRequest))
+ return false;
+
+ var message = error.Message?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(message))
+ return false;
+
+ var normalizedMessage = message.ToLowerInvariant();
+ return normalizedMessage.Contains("requested entity")
+ || normalizedMessage.Contains("message not found")
+ || normalizedMessage.Contains("thread not found")
+ || normalizedMessage.Contains("draft not found")
+ || normalizedMessage.Contains("label not found")
+ || normalizedMessage.Contains("event not found")
+ || normalizedMessage.Contains("calendar not found");
+ }
+
+ private static bool IsExistingEntityOperation(IUIChangeRequest request)
+ => request is BatchDeleteRequest
+ || request is BatchMoveRequest
+ || request is BatchChangeFlagRequest
+ || request is BatchMarkReadRequest
+ || request is BatchArchiveRequest
+ || request is DeleteRequest
+ || request is MoveRequest
+ || request is ChangeFlagRequest
+ || request is MarkReadRequest
+ || request is ArchiveRequest
+ || request is RenameFolderRequest
+ || request is DeleteFolderRequest
+ || request is AcceptEventRequest
+ || request is DeclineEventRequest
+ || request is OutlookDeclineEventRequest
+ || request is TentativeEventRequest
+ || request is UpdateCalendarEventRequest
+ || request is DeleteCalendarEventRequest;
+
private static bool ShouldRevertOptimisticMailStateChange(IUIChangeRequest request)
=> request is BatchMarkReadRequest
|| request is MarkReadRequest
diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs
index b0e925d9..5f0a030d 100644
--- a/Wino.Core/Synchronizers/ImapSynchronizer.cs
+++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs
@@ -763,7 +763,8 @@ public class ImapSynchronizer : WinoSynchronizer
{
{ "ErrorCode", errorCode },
@@ -1880,6 +1882,49 @@ public class OutlookSynchronizer : WinoSynchronizer bundle)
+ {
+ if (statusCode != HttpStatusCode.NotFound || bundle?.UIChangeRequest == null)
+ return false;
+
+ if (!IsExistingEntityOperation(bundle.UIChangeRequest))
+ return false;
+
+ var normalizedErrorCode = errorCode?.Trim().ToLowerInvariant() ?? string.Empty;
+ var normalizedMessage = errorMessage?.Trim().ToLowerInvariant() ?? string.Empty;
+
+ return normalizedErrorCode.Contains("notfound")
+ || normalizedErrorCode.Contains("itemnotfound")
+ || normalizedErrorCode.Contains("resource")
+ || normalizedMessage.Contains("not found")
+ || normalizedMessage.Contains("does not exist")
+ || normalizedMessage.Contains("cannot be found");
+ }
+
+ private static bool IsExistingEntityOperation(IUIChangeRequest request)
+ => request is BatchDeleteRequest
+ || request is BatchMoveRequest
+ || request is BatchChangeFlagRequest
+ || request is BatchMarkReadRequest
+ || request is BatchArchiveRequest
+ || request is DeleteRequest
+ || request is MoveRequest
+ || request is ChangeFlagRequest
+ || request is MarkReadRequest
+ || request is ArchiveRequest
+ || request is RenameFolderRequest
+ || request is DeleteFolderRequest
+ || request is AcceptEventRequest
+ || request is DeclineEventRequest
+ || request is OutlookDeclineEventRequest
+ || request is TentativeEventRequest
+ || request is UpdateCalendarEventRequest
+ || request is DeleteCalendarEventRequest;
+
private async Task HandleSuccessfulResponseAsync(IRequestBundle bundle, HttpResponseMessage response)
{
try