From 243c06879d9b26c4edbe1249615b08ede6bc6ae9 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sat, 31 Oct 2026 21:30:11 +0100 Subject: [PATCH] (bugfix): keep unread and flags stable across cancel and sync races --- CHANGELOG.md | 13 + src/Directory.Build.props | 2 +- .../Mail/ImapMailSession.cs | 63 ++- .../Mail/MailSyncEngine.cs | 32 +- .../Mail/Pop3MailSession.cs | 16 +- .../Mail/PstMailSession.cs | 21 + .../Archive/MailArchiveCatalog.cs | 30 +- .../Archive/MailArchiveStore.cs | 175 +++++++- .../Archive/ArchiveTests.cs | 90 ++++ .../ViewModels/MainViewModel.Pipeline.cs | 21 +- .../ViewModels/MainViewModel.cs | 400 ++++++++++++------ 11 files changed, 696 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a084fd..2bff747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic ## [Unreleased] +## [0.4.5] - 2026-10-31 + +Unread and flag updates stay local when the server store is cancelled or races with sync. Pending Seen/Flagged wait for STORE ack before background sync can overwrite them. + +### Fixed + +- Opening an unread message no longer brings the unread dot back when you select another row: mark-as-read is not cancelled with the reading pane, and IMAP cancel is not treated as a store failure. +- Manual Mark Read/Unread/Flag and folder mark-all-read keep optimistic state off the busy `_work` token; IMAP flag stores use a non-folder CTS and pending archive flags until STORE succeeds (or soft “not connected”, which stays pending and flushes on reconnect). +- Background sync and header upserts no longer clobber newer local Seen/Flagged; `UpsertHeaders` skips flag overwrite by default and `UpdateFlags` respects pending UIDs. +- IMAP MOVE no longer falls back to copy-then-delete on cancel; remaining UIDs are checked before copy/expunge. Cross-mailbox moves delete each source UID after a successful copy. +- Rules and headless sync write archive flags only after a successful STORE. SyncFolder no longer advances past a folder on list failure. +- POP3 Mark Read works locally; spam junk/inbox destinations resolve per message mailbox; Priority is restored on failed flag updates. + ## [0.4.4] - 2026-10-29 HTML mail blocks remote images by default. Windows setup upgrades keep Identity Hub sibling DLLs. Store MSIX is built with each release. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 281b50d..1b66c0b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.4.4 + 0.4.5 Postclient Postclient Maksym Sadovnychyy diff --git a/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs b/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs index 2a298e4..3594afb 100644 --- a/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs +++ b/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs @@ -69,6 +69,10 @@ public sealed class ImapMailSession : IMailSession { cancellationToken).ConfigureAwait(false); await MailKitAuth.AuthenticateAsync(client, account, resolved.Value, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { + client.Dispose(); + throw; + } catch (Exception ex) { client.Dispose(); return Result.UnprocessableEntity(MailAuthHint.Incoming(account, ex.Message)); @@ -117,6 +121,9 @@ public sealed class ImapMailSession : IMailSession { return Result>.Ok(MailFolderCatalog.Normalize(folders, layout)); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result>.UnprocessableEntity(null, ex.Message); } @@ -240,7 +247,12 @@ public sealed class ImapMailSession : IMailSession { Incomplete = range.Incomplete }); } + catch (OperationCanceledException) { + ClearListCursor(folder); + throw; + } catch (Exception ex) { + ClearListCursor(folder); return Result.UnprocessableEntity(null, ex.Message); } } @@ -358,6 +370,9 @@ public sealed class ImapMailSession : IMailSession { var uid = await imapFolder.AppendAsync(new AppendRequest(mime), cancellationToken).ConfigureAwait(false); return Result.Ok(uid?.Id); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } @@ -414,17 +429,42 @@ public sealed class ImapMailSession : IMailSession { else await CopyThenDeleteAsync(source, dest, uids, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { + throw; + } catch (Exception) when (client.Capabilities.HasFlag(ImapCapabilities.Move)) { - await CopyThenDeleteAsync(source, dest, uids, cancellationToken).ConfigureAwait(false); + var remaining = await RemainingUidsAsync(source, uids, cancellationToken).ConfigureAwait(false); + if (remaining.Count > 0) + await CopyThenDeleteAsync(source, dest, remaining, cancellationToken).ConfigureAwait(false); } return Result.Ok(); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(ex.Message); } } + private static async Task> RemainingUidsAsync( + IMailFolder source, + IList uids, + CancellationToken cancellationToken) { + if (uids.Count == 0) + return uids; + var set = new UniqueIdSet(); + foreach (var uid in uids) + set.Add(uid); + var found = await source.SearchAsync(SearchQuery.Uids(set), cancellationToken).ConfigureAwait(false); + if (found.Count == 0) + return Array.Empty(); + if (found.Count == uids.Count) + return uids; + return found.ToList(); + } + private static async Task CopyThenDeleteAsync( IMailFolder source, IMailFolder dest, @@ -481,6 +521,9 @@ public sealed class ImapMailSession : IMailSession { catch (Exception ex) when (attempt == 0 && IsBrokenConnection(ex)) { DropSocket(); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(ex.Message); } @@ -518,6 +561,9 @@ public sealed class ImapMailSession : IMailSession { await parent.CreateAsync(leaf, true, cancellationToken).ConfigureAwait(false); return Result.Ok(); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(ex.Message); } @@ -561,6 +607,9 @@ public sealed class ImapMailSession : IMailSession { await imapFolder.RenameAsync(parent, leaf, cancellationToken).ConfigureAwait(false); return Result.Ok(); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(ex.Message); } @@ -614,6 +663,9 @@ public sealed class ImapMailSession : IMailSession { return Result.Ok(); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(ex.Message); } @@ -672,6 +724,9 @@ public sealed class ImapMailSession : IMailSession { return Result.Ok(); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(ex.Message); } @@ -707,6 +762,9 @@ public sealed class ImapMailSession : IMailSession { cancellationToken).ConfigureAwait(false); return Result.Ok(); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(ex.Message); } @@ -1020,6 +1078,9 @@ public sealed class ImapMailSession : IMailSession { if (rows.Count > 0 || folder.Count == 0) return rows; } + catch (OperationCanceledException) { + throw; + } catch { } diff --git a/src/MaksIT.PostClient.Client/Mail/MailSyncEngine.cs b/src/MaksIT.PostClient.Client/Mail/MailSyncEngine.cs index bdde010..cfd06a1 100644 --- a/src/MaksIT.PostClient.Client/Mail/MailSyncEngine.cs +++ b/src/MaksIT.PostClient.Client/Mail/MailSyncEngine.cs @@ -200,12 +200,12 @@ public sealed class MailSyncEngine { var page = await session.ListMessagesAsync(folder, known, cancellationToken, MailPipeline.HeaderPage) .ConfigureAwait(false); if (!page.IsSuccess) - return true; + return false; var sync = page.Value ?? new MailFolderSync(); _archive.SetKeywordIndexEnabled(box.Id, box.InitialSyncCompleted); if (sync.Flags.Count > 0) _archive.UpdateFlags(box.Id, folder, sync.Flags.Select(f => (f.Id, f.IsSeen, f.IsFlagged))); - _archive.UpsertHeaders(box.Id, sync.Headers.Select(MailArchiveMap.FromHeader)); + _archive.UpsertHeaders(box.Id, sync.Headers.Select(MailArchiveMap.FromHeader), updateFlags: false); if (sync.Present is not null) { var keep = sync.Present.ToHashSet(); var gone = _archive.Uids(box.Id, folder).Where(uid => !keep.Contains(uid)).ToList(); @@ -343,18 +343,30 @@ public sealed class MailSyncEngine { } if (read.Count > 0) { - await session.SetMessageFlagsAsync(folder, read, new MailFlagUpdate { Seen = true }, cancellationToken) + var stored = await session + .SetMessageFlagsAsync(folder, read, new MailFlagUpdate { Seen = true }, cancellationToken) .ConfigureAwait(false); - _archive.UpdateFlags( - box.Id, - folder, - headers.Where(header => read.Contains(header.Id)) - .Select(header => (header.Id, true, header.IsFlagged || flagged.Contains(header.Id)))); + if (stored.IsSuccess) + _archive.UpdateFlags( + box.Id, + folder, + headers.Where(header => read.Contains(header.Id)) + .Select(header => (header.Id, true, header.IsFlagged || flagged.Contains(header.Id))), + respectPending: true); } - if (flagged.Count > 0) - await session.SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, cancellationToken) + if (flagged.Count > 0) { + var stored = await session + .SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, cancellationToken) .ConfigureAwait(false); + if (stored.IsSuccess) + _archive.UpdateFlags( + box.Id, + folder, + headers.Where(header => flagged.Contains(header.Id)) + .Select(header => (header.Id, header.IsSeen || read.Contains(header.Id), true)), + respectPending: true); + } } private async Task RetentionAsync( diff --git a/src/MaksIT.PostClient.Client/Mail/Pop3MailSession.cs b/src/MaksIT.PostClient.Client/Mail/Pop3MailSession.cs index 814183e..ce396df 100644 --- a/src/MaksIT.PostClient.Client/Mail/Pop3MailSession.cs +++ b/src/MaksIT.PostClient.Client/Mail/Pop3MailSession.cs @@ -55,6 +55,10 @@ public sealed class Pop3MailSession : IMailSession { cancellationToken).ConfigureAwait(false); await MailKitAuth.AuthenticateAsync(client, account, resolved.Value, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { + client.Dispose(); + throw; + } catch (Exception ex) { client.Dispose(); return Result.UnprocessableEntity(MailAuthHint.Incoming(account, ex.Message)); @@ -159,6 +163,9 @@ public sealed class Pop3MailSession : IMailSession { Incomplete = true }); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } @@ -180,10 +187,11 @@ public sealed class Pop3MailSession : IMailSession { if (client is null) return Result.UnprocessableEntity(null, "Not connected."); - using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using var abort = linked.Token.Register(DropSocket); - if (!interactive) - linked.CancelAfter(MailFetch.BackfillTimeout); + using var timeout = interactive ? null : new CancellationTokenSource(MailFetch.BackfillTimeout); + using var linked = timeout is null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + using var abort = timeout is null ? default : timeout.Token.Register(DropSocket); MimeMessage mime; try { var index = (int)id; diff --git a/src/MaksIT.PostClient.Client/Mail/PstMailSession.cs b/src/MaksIT.PostClient.Client/Mail/PstMailSession.cs index a6a8da8..accb31c 100644 --- a/src/MaksIT.PostClient.Client/Mail/PstMailSession.cs +++ b/src/MaksIT.PostClient.Client/Mail/PstMailSession.cs @@ -73,6 +73,9 @@ public sealed class PstMailSession : IMailSession { OpenStore(path); return Task.FromResult(Result.Ok()); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Task.FromResult(Result.UnprocessableEntity(WriteError(ex))); } @@ -104,6 +107,9 @@ public sealed class PstMailSession : IMailSession { return Task.FromResult(Result>.Ok( MailFolderCatalog.Normalize(folders, MailFolderLayout.Store))); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Task.FromResult(Result>.UnprocessableEntity(null, ex.Message)); } @@ -279,6 +285,9 @@ public sealed class PstMailSession : IMailSession { Incomplete = incomplete }); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } @@ -329,6 +338,9 @@ public sealed class PstMailSession : IMailSession { using var stream = new MemoryStream(eml, writable: false); mime = await MimeMessage.LoadAsync(stream, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { + throw; + } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } @@ -603,6 +615,15 @@ public sealed class PstMailSession : IMailSession { if (!copy.IsSuccess) return copy; } + catch (OperationCanceledException) { + try { + OpenStore(path); + } + catch { + } + + throw; + } catch (Exception ex) { try { OpenStore(path); diff --git a/src/MaksIT.PostClient.Shared/Archive/MailArchiveCatalog.cs b/src/MaksIT.PostClient.Shared/Archive/MailArchiveCatalog.cs index 99da0b9..5f7a84d 100644 --- a/src/MaksIT.PostClient.Shared/Archive/MailArchiveCatalog.cs +++ b/src/MaksIT.PostClient.Shared/Archive/MailArchiveCatalog.cs @@ -118,8 +118,11 @@ public sealed class MailArchiveCatalog : IDisposable { return store; } - public void UpsertHeaders(string mailboxId, IEnumerable headers) => - OffUi(() => Use(mailboxId, store => store.UpsertHeaders(mailboxId, headers))); + public void UpsertHeaders( + string mailboxId, + IEnumerable headers, + bool updateFlags = false) => + OffUi(() => Use(mailboxId, store => store.UpsertHeaders(mailboxId, headers, updateFlags))); public void SetKeywordIndexEnabled(string mailboxId, bool enabled) => OffUi(() => { @@ -146,8 +149,27 @@ public sealed class MailArchiveCatalog : IDisposable { public void UpdateFlags( string mailboxId, string folder, - IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags) => - OffUi(() => Use(mailboxId, store => store.UpdateFlags(mailboxId, folder, flags))); + IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags, + bool respectPending = true) => + OffUi(() => Use(mailboxId, store => store.UpdateFlags(mailboxId, folder, flags, respectPending))); + + public void SetLocalFlags( + string mailboxId, + string folder, + IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags, + bool pending = true) => + OffUi(() => Use(mailboxId, store => store.SetLocalFlags(mailboxId, folder, flags, pending))); + + public void ClearPendingFlags( + string mailboxId, + string folder, + IEnumerable uids) => + OffUi(() => Use(mailboxId, store => store.ClearPendingFlags(mailboxId, folder, uids))); + + public IReadOnlyList<(string Folder, uint Uid, bool IsSeen, bool IsFlagged)> ListAllPendingFlags( + string mailboxId) => + OffUi(() => TryGet(mailboxId)?.ListAllPendingFlags(mailboxId) + ?? (IReadOnlyList<(string Folder, uint Uid, bool IsSeen, bool IsFlagged)>)[]); public void UpsertBody( string mailboxId, diff --git a/src/MaksIT.PostClient.Shared/Archive/MailArchiveStore.cs b/src/MaksIT.PostClient.Shared/Archive/MailArchiveStore.cs index ce12c22..d1452b5 100644 --- a/src/MaksIT.PostClient.Shared/Archive/MailArchiveStore.cs +++ b/src/MaksIT.PostClient.Shared/Archive/MailArchiveStore.cs @@ -23,12 +23,25 @@ public sealed partial class MailArchiveStore : IDisposable { EnsureSchema(); } - public void UpsertHeaders(string mailboxId, IEnumerable headers) { + public void UpsertHeaders( + string mailboxId, + IEnumerable headers, + bool updateFlags = false) { ArgumentNullException.ThrowIfNull(headers); lock (_gate) { using var tx = _db.BeginTransaction(); - foreach (var header in headers) - UpsertHeader(tx, mailboxId, header, updateFlags: true); + foreach (var header in headers) { + var applyFlags = updateFlags; + if (applyFlags && TryReadPending(tx, mailboxId, header.Folder, header.Uid, out var pending)) { + if (pending.IsSeen == header.IsSeen && pending.IsFlagged == header.IsFlagged) + ClearPending(tx, mailboxId, header.Folder, header.Uid); + else + applyFlags = false; + } + + UpsertHeader(tx, mailboxId, header, updateFlags: applyFlags); + } + tx.Commit(); } } @@ -36,11 +49,19 @@ public sealed partial class MailArchiveStore : IDisposable { public void UpdateFlags( string mailboxId, string folder, - IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags) { + IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags, + bool respectPending = true) { ArgumentNullException.ThrowIfNull(flags); lock (_gate) { using var tx = _db.BeginTransaction(); foreach (var row in flags) { + if (respectPending && TryReadPending(tx, mailboxId, folder, row.Uid, out var pending)) { + if (pending.IsSeen == row.IsSeen && pending.IsFlagged == row.IsFlagged) + ClearPending(tx, mailboxId, folder, row.Uid); + else + continue; + } + using var cmd = _db.CreateCommand(); cmd.Transaction = tx; cmd.CommandText = """ @@ -54,12 +75,83 @@ public sealed partial class MailArchiveStore : IDisposable { cmd.Parameters.AddWithValue("$f", folder); cmd.Parameters.AddWithValue("$u", row.Uid); cmd.ExecuteNonQuery(); + if (!respectPending) + ClearPending(tx, mailboxId, folder, row.Uid); } tx.Commit(); } } + public void SetLocalFlags( + string mailboxId, + string folder, + IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags, + bool pending = true) { + ArgumentNullException.ThrowIfNull(flags); + lock (_gate) { + using var tx = _db.BeginTransaction(); + foreach (var row in flags) { + using (var cmd = _db.CreateCommand()) { + cmd.Transaction = tx; + cmd.CommandText = """ + UPDATE messages + SET is_seen = $seen, is_flagged = $flag + WHERE mailbox_id = $m AND folder = $f AND uid = $u; + """; + cmd.Parameters.AddWithValue("$seen", row.IsSeen ? 1 : 0); + cmd.Parameters.AddWithValue("$flag", row.IsFlagged ? 1 : 0); + cmd.Parameters.AddWithValue("$m", mailboxId); + cmd.Parameters.AddWithValue("$f", folder); + cmd.Parameters.AddWithValue("$u", row.Uid); + cmd.ExecuteNonQuery(); + } + + if (pending) + UpsertPending(tx, mailboxId, folder, row.Uid, row.IsSeen, row.IsFlagged); + else + ClearPending(tx, mailboxId, folder, row.Uid); + } + + tx.Commit(); + } + } + + public void ClearPendingFlags( + string mailboxId, + string folder, + IEnumerable uids) { + ArgumentNullException.ThrowIfNull(uids); + lock (_gate) { + using var tx = _db.BeginTransaction(); + foreach (var uid in uids) + ClearPending(tx, mailboxId, folder, uid); + tx.Commit(); + } + } + + public IReadOnlyList<(string Folder, uint Uid, bool IsSeen, bool IsFlagged)> ListAllPendingFlags( + string mailboxId) { + lock (_gate) { + using var cmd = _db.CreateCommand(); + cmd.CommandText = """ + SELECT folder, uid, is_seen, is_flagged + FROM pending_flags + WHERE mailbox_id = $m; + """; + cmd.Parameters.AddWithValue("$m", mailboxId); + var rows = new List<(string Folder, uint Uid, bool IsSeen, bool IsFlagged)>(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + rows.Add(( + reader.GetString(0), + (uint)reader.GetInt64(1), + reader.GetInt64(2) != 0, + reader.GetInt64(3) != 0)); + return rows; + } + } + public void UpsertBody( string mailboxId, MailArchiveHeader header, @@ -1297,6 +1389,14 @@ public sealed partial class MailArchiveStore : IDisposable { fingerprint TEXT PRIMARY KEY, dismissed_utc TEXT NOT NULL DEFAULT '' ); + CREATE TABLE IF NOT EXISTS pending_flags ( + mailbox_id TEXT NOT NULL, + folder TEXT NOT NULL, + uid INTEGER NOT NULL, + is_seen INTEGER NOT NULL, + is_flagged INTEGER NOT NULL, + PRIMARY KEY (mailbox_id, folder, uid) + ); """; cmd.ExecuteNonQuery(); EnsureColumn("messages", "trashed_utc", "TEXT NOT NULL DEFAULT ''"); @@ -1304,6 +1404,73 @@ public sealed partial class MailArchiveStore : IDisposable { EnsureColumn("messages", "spam_score", "REAL"); } + private static bool TryReadPending( + SqliteTransaction tx, + string mailboxId, + string folder, + uint uid, + out (bool IsSeen, bool IsFlagged) pending) { + using var cmd = tx.Connection!.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = """ + SELECT is_seen, is_flagged + FROM pending_flags + WHERE mailbox_id = $m AND folder = $f AND uid = $u; + """; + cmd.Parameters.AddWithValue("$m", mailboxId); + cmd.Parameters.AddWithValue("$f", folder); + cmd.Parameters.AddWithValue("$u", uid); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) { + pending = default; + return false; + } + + pending = (reader.GetInt64(0) != 0, reader.GetInt64(1) != 0); + return true; + } + + private static void UpsertPending( + SqliteTransaction tx, + string mailboxId, + string folder, + uint uid, + bool isSeen, + bool isFlagged) { + using var cmd = tx.Connection!.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = """ + INSERT INTO pending_flags (mailbox_id, folder, uid, is_seen, is_flagged) + VALUES ($m, $f, $u, $seen, $flag) + ON CONFLICT(mailbox_id, folder, uid) DO UPDATE SET + is_seen = excluded.is_seen, + is_flagged = excluded.is_flagged; + """; + cmd.Parameters.AddWithValue("$m", mailboxId); + cmd.Parameters.AddWithValue("$f", folder); + cmd.Parameters.AddWithValue("$u", uid); + cmd.Parameters.AddWithValue("$seen", isSeen ? 1 : 0); + cmd.Parameters.AddWithValue("$flag", isFlagged ? 1 : 0); + cmd.ExecuteNonQuery(); + } + + private static void ClearPending( + SqliteTransaction tx, + string mailboxId, + string folder, + uint uid) { + using var cmd = tx.Connection!.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = """ + DELETE FROM pending_flags + WHERE mailbox_id = $m AND folder = $f AND uid = $u; + """; + cmd.Parameters.AddWithValue("$m", mailboxId); + cmd.Parameters.AddWithValue("$f", folder); + cmd.Parameters.AddWithValue("$u", uid); + cmd.ExecuteNonQuery(); + } + private void EnsureColumn(string table, string column, string definition) { using var info = _db.CreateCommand(); info.CommandText = "PRAGMA table_info(" + table + ");"; diff --git a/src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs b/src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs index 8b2cf1b..640f1c5 100644 --- a/src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs +++ b/src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs @@ -191,6 +191,96 @@ public class MailArchiveStoreTests { } } + [Fact] + public void PendingFlags_BlockStaleServerSeenUntilAck() { + var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); + try { + using var store = new MailArchiveStore(path); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 9, + Folder = "INBOX", + Subject = "Unread", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + IsSeen = false + } + ]); + store.SetLocalFlags("box", "INBOX", [(9u, true, false)], pending: true); + Assert.True(Assert.Single(store.ListFolder("box", "INBOX")).IsSeen); + store.UpdateFlags("box", "INBOX", [(9u, false, false)]); + Assert.True(Assert.Single(store.ListFolder("box", "INBOX")).IsSeen); + store.UpdateFlags("box", "INBOX", [(9u, true, false)]); + Assert.True(Assert.Single(store.ListFolder("box", "INBOX")).IsSeen); + Assert.Empty(store.ListAllPendingFlags("box")); + } + finally { + DeleteArchive(path); + } + } + + [Fact] + public void UpsertHeaders_DoesNotClobberPendingSeen() { + var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); + try { + using var store = new MailArchiveStore(path); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 11, + Folder = "INBOX", + Subject = "A", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + IsSeen = false + } + ]); + store.SetLocalFlags("box", "INBOX", [(11u, true, false)], pending: true); + store.UpsertHeaders( + "box", + [ + new MailArchiveHeader { + Uid = 11, + Folder = "INBOX", + Subject = "A renamed", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + IsSeen = false + } + ], + updateFlags: true); + var row = Assert.Single(store.ListFolder("box", "INBOX")); + Assert.Equal("A renamed", row.Subject); + Assert.True(row.IsSeen); + } + finally { + DeleteArchive(path); + } + } + + [Fact] + public void SetLocalFlags_WithoutPending_AllowsServerOverwrite() { + var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); + try { + using var store = new MailArchiveStore(path); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 12, + Folder = "INBOX", + Subject = "Local", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + IsSeen = false + } + ]); + store.SetLocalFlags("box", "INBOX", [(12u, true, false)], pending: false); + store.UpdateFlags("box", "INBOX", [(12u, false, false)]); + Assert.False(Assert.Single(store.ListFolder("box", "INBOX")).IsSeen); + } + finally { + DeleteArchive(path); + } + } + [Fact] public void UpsertHeaders_KeepsPreviousSizeWhenIncomingIsZero() { var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); diff --git a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.Pipeline.cs b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.Pipeline.cs index e8df2d1..d0b7a67 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.Pipeline.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.Pipeline.cs @@ -100,22 +100,25 @@ public partial class MainViewModel { return true; } - if (token.IsCancellationRequested || Volatile.Read(ref _mailEpoch) != epoch) + if (token.IsCancellationRequested) return true; + var applyFlags = Volatile.Read(ref _mailEpoch) == epoch; _folderFailUtc.Remove(key); var sync = listed.Value ?? new MailFolderSync(); - ApplyFolderSync(slot.MailboxId, slot.Folder, sync); + ApplyFolderSync(slot.MailboxId, slot.Folder, sync, applyFlags); var fresh = sync.Headers.Where(header => !known.Contains(header.Id)).ToList(); - if (fresh.Count > 0) { + var openFolder = false; + await UiAsync(() => { + openFolder = SelectedMailbox?.Id.Equals(slot.MailboxId, StringComparison.OrdinalIgnoreCase) == true + && SelectedFolder?.FullName.Equals(slot.Folder, StringComparison.OrdinalIgnoreCase) == true; + if (fresh.Count > 0 && openFolder) + NoticeNewMail(fresh); + }).ConfigureAwait(false); + if (fresh.Count > 0) EnqueueFreshRules(slot.MailboxId, slot.Folder, fresh); - await UiAsync(() => { - if (SelectedMailbox?.Id.Equals(slot.MailboxId, StringComparison.OrdinalIgnoreCase) == true - && SelectedFolder?.FullName.Equals(slot.Folder, StringComparison.OrdinalIgnoreCase) == true) - NoticeNewMail(fresh); - }).ConfigureAwait(false); + if (openFolder && (fresh.Count > 0 || (applyFlags && sync.Flags.Count > 0))) await ShowArchiveAsync(slot.MailboxId, slot.Folder, token).ConfigureAwait(false); - } await SetIndexLineAsync(GlobalIndexLine(), token).ConfigureAwait(false); var counts = _archive.FolderCounts(slot.MailboxId, slot.Folder); diff --git a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs index ae8fd5f..acd0610 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs @@ -1829,7 +1829,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { return; if (IsLoadedBody(row.Header) && _reading is not null) { if (!row.Header.IsSeen) - _ = MarkOpenedReadAsync(row.Header, CancellationToken.None); + _ = MarkOpenedReadAsync(row.Header); ShowMessageWindow(_reading); return; } @@ -1932,34 +1932,47 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task MarkSpamAsync() { var rows = TargetRows(); - if (rows.Count == 0 || SelectedMailbox is null) + if (rows.Count == 0) return; foreach (var row in rows) _archive.MarkSpam(row.MailboxId, row.Header.Folder, row.Header.Id, EmbeddingModelSpec.Id); - var junk = JunkFolder(SelectedMailbox); - if (string.IsNullOrWhiteSpace(junk)) { - Status = Copy.SpamNoJunk; - PaintSpam(rows); - return; + var moved = false; + foreach (var group in rows.GroupBy(row => row.MailboxId, StringComparer.OrdinalIgnoreCase)) { + var box = MailboxById(group.Key) ?? SelectedMailbox; + if (box is null) + continue; + var junk = JunkFolder(box); + if (string.IsNullOrWhiteSpace(junk)) + continue; + moved = true; + await MoveRowsAsync(group.ToList(), junk); } - await MoveRowsAsync(rows, junk); + if (!moved) { + Status = Copy.SpamNoJunk; + PaintSpam(rows); + } } [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task MarkNotSpamAsync() { var rows = TargetRows(); - if (rows.Count == 0 || SelectedMailbox is null) + if (rows.Count == 0) return; foreach (var row in rows) _archive.DismissMessage(row.MailboxId, row.Header.Folder, row.Header.Id); - var inbox = InboxOf(SelectedMailbox)?.FullName ?? ""; - if (string.IsNullOrWhiteSpace(inbox)) { - PaintSpam(rows); - return; - } + foreach (var group in rows.GroupBy(row => row.MailboxId, StringComparer.OrdinalIgnoreCase)) { + var box = MailboxById(group.Key) ?? SelectedMailbox; + if (box is null) + continue; + var inbox = InboxOf(box)?.FullName ?? ""; + if (string.IsNullOrWhiteSpace(inbox)) { + PaintSpam(group); + continue; + } - await MoveRowsAsync(rows, inbox); + await MoveRowsAsync(group.ToList(), inbox); + } } public IReadOnlyList SpamExamples() => @@ -2197,7 +2210,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { row => row.Header.Priority = priority); } - [RelayCommand(CanExecute = nameof(CanOrganize), AllowConcurrentExecutions = true)] + [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task DeleteMessagesAsync() { var rows = TargetRows(); if (rows.Count == 0) @@ -2375,27 +2388,59 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { var session = ActiveSession; var box = SelectedMailbox; var folder = SelectedFolder; - if (session is not { IsConnected: true, SupportsFolders: true } || box is null || folder is null) + if (box is null || folder is null) return; - await RunAsync(Copy.UpdatingFlags, async token => { - NoteUiMailChange(); - var result = await session.SetFolderSeenAsync(folder.FullName, seen, token); - if (!result.IsSuccess) { - Status = string.Join(" ", result.Messages); - return; - } + NoteUiMailChange(); + var previous = Messages.Select(row => (row, row.Header.IsSeen, row.Header.IsFlagged)).ToList(); + foreach (var row in Messages) { + row.Header.IsSeen = seen; + row.RefreshMarks(); + } + folder.Unread = seen ? 0 : folder.Total; + var pending = UsesImap(box); + if (Messages.Count > 0) + _archive.SetLocalFlags( + box.Id, + folder.FullName, + Messages.Select(row => (row.Header.Id, seen, row.Header.IsFlagged)), + pending); + else _archive.SetSeen(box.Id, folder.FullName, seen); - foreach (var row in Messages) { - row.Header.IsSeen = seen; - row.RefreshMarks(); - } + NotifyFolderCounts(); + RefreshFolderStats(); + Status = seen ? Copy.FolderMarkedRead : Copy.FolderMarkedUnread; + if (session is not { IsConnected: true, SupportsFolders: true }) + return; + Result result; + try { + result = await session.SetFolderSeenAsync(folder.FullName, seen, CancellationToken.None); + } + catch (OperationCanceledException) { + return; + } - folder.Unread = seen ? 0 : folder.Total; - NotifyFolderCounts(); - RefreshFolderStats(); - Status = seen ? Copy.FolderMarkedRead : Copy.FolderMarkedUnread; - }); + if (result.IsSuccess) { + _archive.ClearPendingFlags(box.Id, folder.FullName, Messages.Select(row => row.Header.Id)); + return; + } + + if (IsSoftFlagFailure(result)) + return; + foreach (var item in previous) { + item.row.Header.IsSeen = item.IsSeen; + item.row.RefreshMarks(); + } + + _archive.SetLocalFlags( + box.Id, + folder.FullName, + previous.Select(item => (item.row.Header.Id, item.IsSeen, item.IsFlagged)), + pending: false); + ApplyOpenedFolderCounts(); + NotifyFolderCounts(); + RefreshFolderStats(); + Status = string.Join(" ", result.Messages); } private async Task ConfirmAsync(string title, string message) { @@ -3561,21 +3606,25 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } if (read.Count > 0) { - await session.SetMessageFlagsAsync(folder, read, new MailFlagUpdate { Seen = true }, token); - _archive.UpdateFlags( - box.Id, - folder, - headers.Where(h => read.Contains(h.Id)) - .Select(h => (h.Id, true, h.IsFlagged || flagged.Contains(h.Id)))); + var stored = await session.SetMessageFlagsAsync( + folder, read, new MailFlagUpdate { Seen = true }, token); + if (stored.IsSuccess) + _archive.UpdateFlags( + box.Id, + folder, + headers.Where(h => read.Contains(h.Id)) + .Select(h => (h.Id, true, h.IsFlagged || flagged.Contains(h.Id)))); } if (flagged.Count > 0) { - await session.SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, token); - _archive.UpdateFlags( - box.Id, - folder, - headers.Where(h => flagged.Contains(h.Id)) - .Select(h => (h.Id, h.IsSeen || read.Contains(h.Id), true))); + var stored = await session.SetMessageFlagsAsync( + folder, flagged, new MailFlagUpdate { Flagged = true }, token); + if (stored.IsSuccess) + _archive.UpdateFlags( + box.Id, + folder, + headers.Where(h => flagged.Contains(h.Id)) + .Select(h => (h.Id, h.IsSeen || read.Contains(h.Id), true))); } } @@ -3624,23 +3673,16 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { continue; await EnsureFoldersForRulesAsync(destBox.Id); foreach (var id in pair.Value) { - if (await CopyUidToMailboxAsync(source, sourceSession, destBox, sourceFolder, pair.Key.Folder, id, token)) - copied.Add(id); + if (!await CopyUidToMailboxAsync(source, sourceSession, destBox, sourceFolder, pair.Key.Folder, id, token)) + continue; + await RemoveCopiedUidsAsync(source, sourceSession, sourceFolder, [id], token); + copied.Add(id); } } if (copied.Count == 0) return; - var unique = copied.Distinct().ToList(); - if (sourceSession is { IsConnected: true, SupportsFolders: true }) { - var trash = MailRetention.ResolveTrash(sourceFolders); - if (!string.IsNullOrWhiteSpace(trash) && !trash.Equals(sourceFolder, StringComparison.OrdinalIgnoreCase)) - await sourceSession.MoveMessagesAsync(sourceFolder, unique, trash, token); - else - await sourceSession.SetMessageFlagsAsync(sourceFolder, unique, new MailFlagUpdate { Deleted = true }, token); - } - - DropArchived(source.Id, sourceFolder, unique); + RemoveDroppedRows(source.Id, sourceFolder, copied.Distinct().ToList()); } private async Task CopyUidToMailboxAsync( @@ -3874,6 +3916,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { if (missingFolders) await LoadFoldersAsync(box, live, token).ConfigureAwait(false); KickSyncSession(box, password); + await FlushPendingFlagsAsync(box).ConfigureAwait(false); return true; } @@ -3904,6 +3947,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { pst.StorePathChanged += path => PersistPstStorePath(box, path); await LoadFoldersAsync(box, session, token).ConfigureAwait(false); KickSyncSession(box, password); + await FlushPendingFlagsAsync(box).ConfigureAwait(false); await UiAsync(() => { EnsureIndexing(box.Id); NotifyFolderCommands(); @@ -3916,6 +3960,33 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } } + private async Task FlushPendingFlagsAsync(MailboxAccount box) { + if (!UsesImap(box)) + return; + var session = SessionFor(box); + if (session is not { IsConnected: true, SupportsFolders: true }) + return; + var pending = _archive.ListAllPendingFlags(box.Id); + foreach (var group in pending.GroupBy(item => item.Folder, StringComparer.OrdinalIgnoreCase)) { + foreach (var item in group) { + Result result; + try { + result = await session.SetMessageFlagsAsync( + group.Key, + [item.Uid], + new MailFlagUpdate { Seen = item.IsSeen, Flagged = item.IsFlagged }, + CancellationToken.None).ConfigureAwait(false); + } + catch (OperationCanceledException) { + return; + } + + if (result.IsSuccess) + _archive.ClearPendingFlags(box.Id, group.Key, [item.Uid]); + } + } + } + private async Task ConnectIdleMailboxesAsync() { foreach (var box in Mailboxes.ToList()) { if (SessionFor(box) is { IsConnected: true }) @@ -3936,21 +4007,24 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } } - private void ApplyFolderSync(string mailboxId, string folder, MailFolderSync sync) { + private void ApplyFolderSync( + string mailboxId, + string folder, + MailFolderSync sync, + bool applyFlags = true) { if (!EnsureArchive(mailboxId)) return; var ready = IsInitialSyncComplete(mailboxId); _archive.SetKeywordIndexEnabled(mailboxId, ready); - if (sync.Flags.Count > 0) + if (applyFlags && sync.Flags.Count > 0) _archive.UpdateFlags(mailboxId, folder, sync.Flags.Select(f => (f.Id, f.IsSeen, f.IsFlagged))); - _archive.UpsertHeaders(mailboxId, sync.Headers.Select(MailArchiveMap.FromHeader)); + _archive.UpsertHeaders(mailboxId, sync.Headers.Select(MailArchiveMap.FromHeader), updateFlags: false); if (sync.Present is not null) { var keep = sync.Present.ToHashSet(); var gone = _archive.Uids(mailboxId, folder).Where(uid => !keep.Contains(uid)).ToList(); if (gone.Count > 0) DropArchived(mailboxId, folder, gone); } - } private bool EnsureArchive(string mailboxId) { @@ -4000,10 +4074,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } token.ThrowIfCancellationRequested(); - if (Volatile.Read(ref _mailEpoch) != epoch) - return; + var applyFlags = Volatile.Read(ref _mailEpoch) == epoch; var sync = result.Value ?? new MailFolderSync(); - ApplyFolderSync(box.Id, folder, sync); + ApplyFolderSync(box.Id, folder, sync, applyFlags); var fresh = sync.Headers.Where(h => !known.Contains(h.Id)).ToList(); if (known.Count > 0) { await UiAsync(() => { @@ -4325,11 +4398,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { StoreArchivedBody(box.Id, header, body); await Dispatcher.UIThread.InvokeAsync(async () => { MarkRowAttachments(header); - if (SelectedMessage is null - || SelectedMessage.Header.Id != header.Id - || !SelectedMessage.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) { + var stillSelected = SelectedMessage is not null + && SelectedMessage.Header.Id == header.Id + && SelectedMessage.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase); + if (!stillSelected) { if (Status == Copy.OpeningMessage) Status = ""; + // Body finished after the user moved on — still keep the Seen mark. + await MarkOpenedReadAsync(header).ConfigureAwait(true); return; } @@ -4353,7 +4429,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { Status = explain; } - await MarkOpenedReadAsync(header, token).ConfigureAwait(true); + await MarkOpenedReadAsync(header).ConfigureAwait(true); if (_pendingWindow is { } pending && pending.Header.Id == header.Id && pending.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) { @@ -4387,7 +4463,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { MessageWindowRequested?.Invoke(vm); } - private async Task MarkOpenedReadAsync(MailMessageHeader header, CancellationToken token) { + private async Task MarkOpenedReadAsync(MailMessageHeader header) { var row = Messages.FirstOrDefault(m => m.Header.Id == header.Id && m.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)); @@ -4405,30 +4481,54 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { var flagged = row.Header.IsFlagged; row.Header.IsSeen = true; row.RefreshMarks(); + var pending = box is not null && UsesImap(box); if (box is not null) - _archive.UpdateFlags(box.Id, folder, [(row.Header.Id, true, flagged)]); + _archive.SetLocalFlags(box.Id, folder, [(row.Header.Id, true, flagged)], pending); NotifyFolderCounts(); RefreshFolderStats(); var session = SessionFor(box); - if (session is not { SupportsFolders: true }) + if (session is not { IsConnected: true, SupportsFolders: true }) return; - var result = await session.SetMessageFlagsAsync( - folder, - [row.Header.Id], - new MailFlagUpdate { Seen = true }, - token).ConfigureAwait(true); - if (result.IsSuccess) + Result result; + try { + result = await session.SetMessageFlagsAsync( + folder, + [row.Header.Id], + new MailFlagUpdate { Seen = true }, + CancellationToken.None).ConfigureAwait(true); + } + catch (OperationCanceledException) { + return; + } + + if (result.IsSuccess) { + if (box is not null) + _archive.ClearPendingFlags(box.Id, folder, [row.Header.Id]); + return; + } + + if (IsSoftFlagFailure(result)) return; if (folderRow is not null) ApplyUnreadDelta(folderRow, [row], false); row.Header.IsSeen = false; row.RefreshMarks(); if (box is not null) - _archive.UpdateFlags(box.Id, folder, [(row.Header.Id, false, flagged)]); + _archive.SetLocalFlags(box.Id, folder, [(row.Header.Id, false, flagged)], pending: false); NotifyFolderCounts(); RefreshFolderStats(); } + private static bool IsSoftFlagFailure(Result result) { + foreach (var message in result.Messages) { + if (message.Contains("Not connected", StringComparison.OrdinalIgnoreCase) + || message.Contains("cancel", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + private void ApplyReading(MailMessageBody body, bool keepKind = false) { var inner = UnwrapEnvelope && body.Envelope.HasInnerMessage; ReadingSubject = inner && !string.IsNullOrWhiteSpace(body.InnerSubject) @@ -4987,8 +5087,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { NoteUiMailChange(); var copied = new List(); foreach (var id in ids) { - if (await CopyUidToMailboxAsync(sourceBox, sourceSession, destBox, source, dest, id, token)) - copied.Add(id); + if (!await CopyUidToMailboxAsync(sourceBox, sourceSession, destBox, source, dest, id, token)) + continue; + await RemoveCopiedUidsAsync(sourceBox, sourceSession, source, [id], token); + copied.Add(id); } if (copied.Count == 0) { @@ -4996,7 +5098,6 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { return; } - await RemoveCopiedUidsAsync(sourceBox, sourceSession, source, copied, token); ApplyFolderMoveCounts(sourceBox, destBox, source, dest, copied.Count, rows); RemoveDroppedRows(sourceBox.Id, source, copied); await LoadFoldersAsync(destBox, destSession, token); @@ -5121,68 +5222,99 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { var targets = rows ?? TargetRows(); if (targets.Count == 0) return false; - var ok = false; - await RunAsync(Copy.UpdatingFlags, async token => { - foreach (var group in targets.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) { - var box = MailboxById(group.Key.MailboxId) ?? SelectedMailbox; - var session = SessionFor(box); - if (session is not { SupportsFolders: true }) { - Status = Copy.Connecting; - return; + var ok = true; + Status = Copy.UpdatingFlags; + foreach (var group in targets.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) { + var box = MailboxById(group.Key.MailboxId) ?? SelectedMailbox; + var session = SessionFor(box); + var folder = group.Key.Folder; + var groupRows = group.ToList(); + var previous = groupRows + .Select(row => (row, row.Header.IsSeen, row.Header.IsFlagged, row.Header.Priority)) + .ToList(); + var remote = session is { SupportsFolders: true }; + var pending = box is not null && UsesImap(box) && !update.Deleted; + if (!remote && box is null) { + Status = Copy.Connecting; + ok = false; + continue; + } + + NoteUiMailChange(); + if (update.Seen is { } seen && SelectedFolder is { } current + && current.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) + ApplyUnreadDelta(current, groupRows, seen); + if (apply is not null) { + foreach (var row in groupRows) { + apply(row); + row.RefreshMarks(); } + } - NoteUiMailChange(); - var folder = group.Key.Folder; - var groupRows = group.ToList(); - var previous = groupRows.Select(row => (row, row.Header.IsSeen, row.Header.IsFlagged)).ToList(); - if (update.Seen is { } seen && SelectedFolder is { } current - && current.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) - ApplyUnreadDelta(current, groupRows, seen); - if (apply is not null) { - foreach (var row in groupRows) { - apply(row); - row.RefreshMarks(); - } + if (box is not null && apply is not null) + _archive.SetLocalFlags( + box.Id, + folder, + groupRows.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged)), + pending); + NotifyFolderCounts(); + RefreshFolderStats(); + if (!remote || session is null) { + if (apply is not null) + continue; + Status = Copy.Connecting; + ok = false; + continue; + } - if (box is not null) - _archive.UpdateFlags( - box.Id, - folder, - groupRows.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged))); - } - - NotifyFolderCounts(); - RefreshFolderStats(); - var result = await session.SetMessageFlagsAsync( + Result result; + try { + result = await session.SetMessageFlagsAsync( folder, TargetIds(groupRows), update, - token).ConfigureAwait(true); - if (result.IsSuccess) - continue; - foreach (var item in previous) { - item.row.Header.IsSeen = item.IsSeen; - item.row.Header.IsFlagged = item.IsFlagged; - item.row.RefreshMarks(); - } - - if (box is not null) - _archive.UpdateFlags( - box.Id, - folder, - previous.Select(item => (item.row.Header.Id, item.IsSeen, item.IsFlagged))); - if (SelectedFolder is { } open - && open.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) - ApplyOpenedFolderCounts(); - NotifyFolderCounts(); - RefreshFolderStats(); - Status = string.Join(" ", result.Messages); - return; + CancellationToken.None).ConfigureAwait(true); + } + catch (OperationCanceledException) { + continue; } + if (result.IsSuccess) { + if (box is not null && pending) + _archive.ClearPendingFlags(box.Id, folder, TargetIds(groupRows)); + continue; + } + + if (IsSoftFlagFailure(result) || update.Deleted) { + if (!result.IsSuccess && update.Deleted) + ok = false; + continue; + } + + foreach (var item in previous) { + item.row.Header.IsSeen = item.IsSeen; + item.row.Header.IsFlagged = item.IsFlagged; + item.row.Header.Priority = item.Priority; + item.row.RefreshMarks(); + } + + if (box is not null && apply is not null) + _archive.SetLocalFlags( + box.Id, + folder, + previous.Select(item => (item.row.Header.Id, item.IsSeen, item.IsFlagged)), + pending: false); + if (SelectedFolder is { } open + && open.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) + ApplyOpenedFolderCounts(); + NotifyFolderCounts(); + RefreshFolderStats(); + Status = string.Join(" ", result.Messages); + ok = false; + } + + if (ok) Status = "Updated."; - ok = true; - }); return ok; }