From 2d22db321c956714eef49895732c97907c276b59 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:45:16 +0000 Subject: [PATCH] Reject stale draft refreshes after quick deletion --- .../MainWindowViewModel.MessageActions.cs | 2 ++ src/BetterMail.App/MainWindowViewModel.cs | 14 ++++++++++++-- tests/BetterMail.Tests/MainWindowViewModelTests.cs | 4 ++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/BetterMail.App/MainWindowViewModel.MessageActions.cs b/src/BetterMail.App/MainWindowViewModel.MessageActions.cs index a24465c..0aacee7 100644 --- a/src/BetterMail.App/MainWindowViewModel.MessageActions.cs +++ b/src/BetterMail.App/MainWindowViewModel.MessageActions.cs @@ -17,6 +17,8 @@ internal async Task DeleteDraftQuickAsync(LocalDraft draft) var current = await _store.GetLocalDraftAsync(draft.Id); if (current is null || current.IsQueued) return; await _store.QueueDraftDeletionAsync(current); + // A refresh started before this commit must not resurrect its stale snapshot. + Interlocked.Increment(ref _draftRefreshVersion); var action = (await _store.GetMailActionsAsync()).FirstOrDefault(item => item.Kind == MailActionKind.DeleteDraft && item.ItemId == current.Id); if (action is not null) ShowQueuedAction(action); await feedback; diff --git a/src/BetterMail.App/MainWindowViewModel.cs b/src/BetterMail.App/MainWindowViewModel.cs index 47e1bf0..6ce2821 100644 --- a/src/BetterMail.App/MainWindowViewModel.cs +++ b/src/BetterMail.App/MainWindowViewModel.cs @@ -1875,19 +1875,29 @@ DraftSyncStatus.UnsupportedAttachment or } } + private int _draftRefreshVersion; + private async Task RefreshDraftsAsync() { if (_store is null) { return; } + var version = Interlocked.Increment(ref _draftRefreshVersion); var drafts = await _store.GetLocalDraftSummariesAsync(); - Replace(Drafts, drafts.Where(static draft => !draft.IsQueued)); - Replace(Outbox, drafts.Where(static draft => draft.IsQueued && !draft.SendAccepted)); + if (!TryApplyDraftSnapshot(version, drafts)) return; await RefreshBusyActionsAsync(); RebuildVisibleDrafts(); } + private bool TryApplyDraftSnapshot(int version, IReadOnlyList drafts) + { + if (version != Volatile.Read(ref _draftRefreshVersion)) return false; + Replace(Drafts, drafts.Where(static draft => !draft.IsQueued)); + Replace(Outbox, drafts.Where(static draft => draft.IsQueued && !draft.SendAccepted)); + return true; + } + private async Task PrimeNewMailNotificationsAsync() { if (_store is null) diff --git a/tests/BetterMail.Tests/MainWindowViewModelTests.cs b/tests/BetterMail.Tests/MainWindowViewModelTests.cs index cdc5507..90efc6f 100644 --- a/tests/BetterMail.Tests/MainWindowViewModelTests.cs +++ b/tests/BetterMail.Tests/MainWindowViewModelTests.cs @@ -123,11 +123,15 @@ public async Task DraftQuickDeletionDoesNotWaitForOtherDraftsToSync() var second = first with { Id = "second", ProviderDraftId = "remote-second" }; await store.SaveLocalDraftAsync(first, token); await store.SaveLocalDraftAsync(second, token); vm.Drafts.Add(first); vm.Drafts.Add(second); + // Model a draft snapshot captured before deletion but delivered after both deletions. + var staleVersion = (int)typeof(MainWindowViewModel).GetField("_draftRefreshVersion", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(vm)!; var deleting = vm.DeleteDraftQuickAsync(first); Assert.True(vm.IsDraftDeletionPending(first)); await deleting.WaitAsync(TimeSpan.FromSeconds(5), token); await vm.DeleteDraftQuickAsync(second).WaitAsync(TimeSpan.FromSeconds(5), token); Assert.False(provider.DeleteRelease.Task.IsCompleted); + var applied = (bool)typeof(MainWindowViewModel).GetMethod("TryApplyDraftSnapshot", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.Invoke(vm, [staleVersion, new LocalDraft[] { first, second }])!; + Assert.False(applied); Assert.DoesNotContain(vm.Drafts, draft => draft.Id == first.Id || draft.Id == second.Id); Assert.True(await store.IsDraftPendingDeletionAsync(first.Id, token)); Assert.True(await store.IsDraftPendingDeletionAsync(second.Id, token));