From e559a7950687457c8cf4b43a8bd67d7a8040f49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sun, 8 Feb 2026 22:20:38 +0100 Subject: [PATCH] Generic 404 handler for synchronizers. --- .../MenuItems/MenuItemCollection.cs | 29 ++++++ Wino.Core/CoreContainerSetup.cs | 3 + .../Requests/Folder/DeleteFolderRequest.cs | 8 +- .../GmailSynchronizerErrorHandlingFactory.cs | 5 +- .../ImapSynchronizerErrorHandlingFactory.cs | 3 + ...OutlookSynchronizerErrorHandlingFactory.cs | 11 +-- Wino.Core/Services/SynchronizationManager.cs | 5 ++ .../Errors/EntityNotFoundHandler.cs | 89 +++++++++++++++++++ Wino.Core/Synchronizers/GmailSynchronizer.cs | 33 ++++--- Wino.Core/Synchronizers/ImapSynchronizer.cs | 55 +++++++++--- .../Synchronizers/OutlookSynchronizer.cs | 13 ++- Wino.Mail.ViewModels/MailAppShellViewModel.cs | 15 ++++ Wino.Mail.ViewModels/MailBaseViewModel.cs | 5 ++ Wino.Mail.ViewModels/MailListPageViewModel.cs | 14 +++ Wino.Messages/UI/FolderDeleted.cs | 5 ++ 15 files changed, 258 insertions(+), 35 deletions(-) create mode 100644 Wino.Core/Synchronizers/Errors/EntityNotFoundHandler.cs create mode 100644 Wino.Messages/UI/FolderDeleted.cs diff --git a/Wino.Core.Domain/MenuItems/MenuItemCollection.cs b/Wino.Core.Domain/MenuItems/MenuItemCollection.cs index bd0ac6b2..7504c0a7 100644 --- a/Wino.Core.Domain/MenuItems/MenuItemCollection.cs +++ b/Wino.Core.Domain/MenuItems/MenuItemCollection.cs @@ -188,6 +188,35 @@ public class MenuItemCollection : ObservableRangeCollection Insert(insertIndex, accountMenuItem); } + public bool RemoveFolderMenuItem(Guid folderId) + { + // Check root-level items. + var rootItem = this.OfType() + .FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId)); + + if (rootItem != null) + { + Remove(rootItem); + return true; + } + + // Check sub-items of root folders. + foreach (var rootFolder in this.OfType()) + { + var subItem = rootFolder.SubMenuItems + .OfType() + .FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId)); + + if (subItem != null) + { + rootFolder.SubMenuItems.Remove(subItem); + return true; + } + } + + return false; + } + private void ClearFolderAreaMenuItems() { var itemsToRemove = this.Where(a => !_preservingTypesForFolderArea.Contains(a.GetType())).ToList(); diff --git a/Wino.Core/CoreContainerSetup.cs b/Wino.Core/CoreContainerSetup.cs index 1f9d2ce0..631a0948 100644 --- a/Wino.Core/CoreContainerSetup.cs +++ b/Wino.Core/CoreContainerSetup.cs @@ -4,6 +4,7 @@ using Wino.Authentication; using Wino.Core.Domain.Interfaces; using Wino.Core.Integration.Processors; using Wino.Core.Services; +using Wino.Core.Synchronizers.Errors; using Wino.Core.Synchronizers.Errors.Gmail; using Wino.Core.Synchronizers.Errors.Imap; using Wino.Core.Synchronizers.Errors.Outlook; @@ -49,6 +50,8 @@ public static class CoreContainerSetup services.AddTransient(); services.AddTransient(); services.AddTransient(); + // Register shared error handlers + services.AddTransient(); // Register IMAP error handlers services.AddTransient(); diff --git a/Wino.Core/Requests/Folder/DeleteFolderRequest.cs b/Wino.Core/Requests/Folder/DeleteFolderRequest.cs index d865bee1..5615e6cf 100644 --- a/Wino.Core/Requests/Folder/DeleteFolderRequest.cs +++ b/Wino.Core/Requests/Folder/DeleteFolderRequest.cs @@ -1,11 +1,17 @@ +using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Models.Requests; +using Wino.Messaging.UI; namespace Wino.Core.Requests.Folder; public record DeleteFolderRequest(MailItemFolder Folder) : FolderRequestBase(Folder, FolderSynchronizerOperation.DeleteFolder) { - public override void ApplyUIChanges() { } + public override void ApplyUIChanges() + { + WeakReferenceMessenger.Default.Send(new FolderDeleted(Folder)); + } + public override void RevertUIChanges() { } } diff --git a/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs b/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs index 0abc7372..565cdeb8 100644 --- a/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs +++ b/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs @@ -1,4 +1,5 @@ using Wino.Core.Domain.Interfaces; +using Wino.Core.Synchronizers.Errors; using Wino.Core.Synchronizers.Errors.Gmail; namespace Wino.Core.Services; @@ -12,11 +13,13 @@ public class GmailSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFa public GmailSynchronizerErrorHandlingFactory( GmailQuotaExceededHandler quotaExceededHandler, GmailRateLimitHandler rateLimitHandler, - GmailHistoryExpiredHandler historyExpiredHandler) + GmailHistoryExpiredHandler historyExpiredHandler, + EntityNotFoundHandler entityNotFoundHandler) { // Order matters - more specific handlers should be registered first RegisterHandler(quotaExceededHandler); RegisterHandler(historyExpiredHandler); + RegisterHandler(entityNotFoundHandler); RegisterHandler(rateLimitHandler); // Most generic rate limit handler last } } diff --git a/Wino.Core/Services/ImapSynchronizerErrorHandlingFactory.cs b/Wino.Core/Services/ImapSynchronizerErrorHandlingFactory.cs index 662058c7..2a319896 100644 --- a/Wino.Core/Services/ImapSynchronizerErrorHandlingFactory.cs +++ b/Wino.Core/Services/ImapSynchronizerErrorHandlingFactory.cs @@ -1,4 +1,5 @@ using Wino.Core.Domain.Interfaces; +using Wino.Core.Synchronizers.Errors; using Wino.Core.Synchronizers.Errors.Imap; namespace Wino.Core.Services; @@ -12,11 +13,13 @@ public class ImapSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFac public ImapSynchronizerErrorHandlingFactory( ImapConnectionLostHandler connectionLostHandler, ImapAuthenticationFailedHandler authFailedHandler, + EntityNotFoundHandler entityNotFoundHandler, ImapFolderNotFoundHandler folderNotFoundHandler, ImapProtocolErrorHandler protocolErrorHandler) { // Order matters - more specific handlers should be registered first RegisterHandler(authFailedHandler); + RegisterHandler(entityNotFoundHandler); RegisterHandler(folderNotFoundHandler); RegisterHandler(connectionLostHandler); RegisterHandler(protocolErrorHandler); // Most generic, registered last diff --git a/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs b/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs index 2767ecd9..087b5102 100644 --- a/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs +++ b/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs @@ -1,6 +1,5 @@ -using System.Threading.Tasks; -using Wino.Core.Domain.Interfaces; -using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Synchronizers.Errors; using Wino.Core.Synchronizers.Errors.Outlook; namespace Wino.Core.Services; @@ -8,13 +7,11 @@ namespace Wino.Core.Services; public class OutlookSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IOutlookSynchronizerErrorHandlerFactory { public OutlookSynchronizerErrorHandlingFactory(ObjectCannotBeDeletedHandler objectCannotBeDeleted, + EntityNotFoundHandler entityNotFoundHandler, DeltaTokenExpiredHandler deltaTokenExpiredHandler) { RegisterHandler(objectCannotBeDeleted); + RegisterHandler(entityNotFoundHandler); RegisterHandler(deltaTokenExpiredHandler); } - - public bool CanHandle(SynchronizerErrorContext error) => CanHandle(error); - - public Task HandleAsync(SynchronizerErrorContext error) => HandleErrorAsync(error); } diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index 67908b0a..cadaadda 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -405,6 +405,11 @@ public class SynchronizationManager : ISynchronizationManager await synchronizer.DownloadMissingMimeMessageAsync(mailItem, null, cancellationToken); return mailItem.Id.ToString(); // Return some identifier, actual implementation might be different } + catch (SynchronizerEntityNotFoundException) + { + _logger.Warning("MIME message for mail item {MailItemId} no longer exists on server. Removed locally.", mailItem.Id); + return null; + } catch (Exception ex) { _logger.Error(ex, "Failed to download MIME message for mail item {MailItemId}", mailItem.Id); diff --git a/Wino.Core/Synchronizers/Errors/EntityNotFoundHandler.cs b/Wino.Core/Synchronizers/Errors/EntityNotFoundHandler.cs new file mode 100644 index 00000000..afa154b8 --- /dev/null +++ b/Wino.Core/Synchronizers/Errors/EntityNotFoundHandler.cs @@ -0,0 +1,89 @@ +using System; +using System.Threading.Tasks; +using Serilog; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +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. +/// +public class EntityNotFoundHandler : ISynchronizerErrorHandler +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly IMailService _mailService; + private readonly IFolderService _folderService; + + public EntityNotFoundHandler(IMailService mailService, IFolderService folderService) + { + _mailService = mailService; + _folderService = folderService; + } + + public bool CanHandle(SynchronizerErrorContext error) + { + if (error.ErrorCode != 404) return false; + if (error.RequestBundle == null) return false; + return true; + } + + public async Task HandleAsync(SynchronizerErrorContext error) + { + error.Severity = SynchronizerErrorSeverity.Recoverable; + error.Category = SynchronizerErrorCategory.ResourceNotFound; + + var uiRequest = error.RequestBundle.UIChangeRequest; + + // --- Folder actions --- + if (uiRequest is IFolderActionRequest folderAction) + { + _logger.Warning("Entity not found (404) for folder operation {Op} on {RemoteFolderId}. Deleting locally.", + folderAction.Operation, folderAction.Folder.RemoteFolderId); + + try + { + await _folderService.DeleteFolderAsync( + folderAction.Folder.MailAccountId, + folderAction.Folder.RemoteFolderId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to delete folder locally after 404."); + } + + return true; + } + + // --- Individual mail actions --- + if (uiRequest is IMailActionRequest mailAction && error.Account != null) + { + _logger.Warning("Entity not found (404) for mail operation {Op} on {MailId}. Deleting locally.", + mailAction.Operation, mailAction.Item.Id); + + // Revert optimistic UI change (e.g. mark-read/flag toggle) before deleting + error.RequestBundle.UIChangeRequest?.RevertUIChanges(); + + try + { + await _mailService.DeleteMailAsync( + error.Account.Id, mailAction.Item.Id).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to delete mail locally after 404."); + } + + 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."); + return true; + } +} diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 26eadbd4..795edbd4 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -1318,19 +1318,28 @@ public class GmailSynchronizer : WinoSynchronizer { - { "Account", Account }, { "Error", error } } }; diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index 5028c65c..c4ff0d41 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -235,18 +235,36 @@ public class ImapSynchronizer : WinoSynchronizer a.Id == folder.Id); + + await ExecuteUIThread(() => MenuItems.RemoveFolderMenuItem(folder.Id)); + + if (wasSelected && latestSelectedAccountMenuItem != null) + { + await NavigateInboxAsync(latestSelectedAccountMenuItem); + } + } + protected override void OnFolderSynchronizationEnabled(IMailItemFolder mailItemFolder) { base.OnFolderSynchronizationEnabled(mailItemFolder); diff --git a/Wino.Mail.ViewModels/MailBaseViewModel.cs b/Wino.Mail.ViewModels/MailBaseViewModel.cs index 53646754..fb30d5df 100644 --- a/Wino.Mail.ViewModels/MailBaseViewModel.cs +++ b/Wino.Mail.ViewModels/MailBaseViewModel.cs @@ -17,6 +17,7 @@ public class MailBaseViewModel : CoreBaseViewModel, IRecipient, IRecipient, IRecipient, + IRecipient, IRecipient { protected virtual void OnMailAdded(MailCopy addedMail) { } @@ -27,6 +28,7 @@ public class MailBaseViewModel : CoreBaseViewModel, protected virtual void OnDraftFailed(MailCopy draftMail, MailAccount account) { } protected virtual void OnDraftMapped(string localDraftCopyId, string remoteDraftCopyId) { } protected virtual void OnFolderRenamed(IMailItemFolder mailItemFolder) { } + protected virtual void OnFolderDeleted(MailItemFolder folder) { } protected virtual void OnFolderSynchronizationEnabled(IMailItemFolder mailItemFolder) { } void IRecipient.Receive(MailAddedMessage message) => OnMailAdded(message.AddedMail); @@ -39,6 +41,7 @@ public class MailBaseViewModel : CoreBaseViewModel, void IRecipient.Receive(DraftCreated message) => OnDraftCreated(message.DraftMail, message.Account); void IRecipient.Receive(FolderRenamed message) => OnFolderRenamed(message.MailItemFolder); + void IRecipient.Receive(FolderDeleted message) => OnFolderDeleted(message.MailItemFolder); void IRecipient.Receive(FolderSynchronizationEnabled message) => OnFolderSynchronizationEnabled(message.MailItemFolder); protected override void RegisterRecipients() @@ -55,6 +58,7 @@ public class MailBaseViewModel : CoreBaseViewModel, Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); + Messenger.Register(this); Messenger.Register(this); } @@ -70,6 +74,7 @@ public class MailBaseViewModel : CoreBaseViewModel, Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); + Messenger.Unregister(this); Messenger.Unregister(this); } } diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 2e37769e..5c3f9c41 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -780,6 +780,20 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } + protected override async void OnFolderDeleted(MailItemFolder folder) + { + base.OnFolderDeleted(folder); + + if (ActiveFolder == null) return; + + bool isActiveFolder = ActiveFolder.HandlingFolders.Any(a => a.Id == folder.Id); + + if (isActiveFolder) + { + await MailCollection.ClearAsync(); + } + } + protected override async void OnDraftCreated(MailCopy draftMail, MailAccount account) { base.OnDraftCreated(draftMail, account); diff --git a/Wino.Messages/UI/FolderDeleted.cs b/Wino.Messages/UI/FolderDeleted.cs new file mode 100644 index 00000000..94b7124e --- /dev/null +++ b/Wino.Messages/UI/FolderDeleted.cs @@ -0,0 +1,5 @@ +using Wino.Core.Domain.Entities.Mail; + +namespace Wino.Messaging.UI; + +public record FolderDeleted(MailItemFolder MailItemFolder) : UIMessageBase;