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