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;
}