(bugfix): keep unread and flags stable across cancel and sync races

This commit is contained in:
Maksym Sadovnychyy 2026-10-31 21:30:11 +01:00
parent 8ff6f91673
commit 243c06879d
11 changed files with 696 additions and 167 deletions

View File

@ -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.

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.4.4</Version>
<Version>0.4.5</Version>
<Product>Postclient</Product>
<AssemblyTitle>Postclient</AssemblyTitle>
<Authors>Maksym Sadovnychyy</Authors>

View File

@ -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<IReadOnlyList<MailFolderInfo>>.Ok(MailFolderCatalog.Normalize(folders, layout));
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<IReadOnlyList<MailFolderInfo>>.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<MailFolderSync>.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<uint?>.Ok(uid?.Id);
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<uint?>.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<IList<UniqueId>> RemainingUidsAsync(
IMailFolder source,
IList<UniqueId> 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<UniqueId>();
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 {
}

View File

@ -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(

View File

@ -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<MailFolderSync>.UnprocessableEntity(null, ex.Message);
}
@ -180,10 +187,11 @@ public sealed class Pop3MailSession : IMailSession {
if (client is null)
return Result<MailMessageBody>.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;

View File

@ -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<IReadOnlyList<MailFolderInfo>>.Ok(
MailFolderCatalog.Normalize(folders, MailFolderLayout.Store)));
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Task.FromResult(Result<IReadOnlyList<MailFolderInfo>>.UnprocessableEntity(null, ex.Message));
}
@ -279,6 +285,9 @@ public sealed class PstMailSession : IMailSession {
Incomplete = incomplete
});
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) {
return Result<MailFolderSync>.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<uint?>.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);

View File

@ -118,8 +118,11 @@ public sealed class MailArchiveCatalog : IDisposable {
return store;
}
public void UpsertHeaders(string mailboxId, IEnumerable<MailArchiveHeader> headers) =>
OffUi(() => Use(mailboxId, store => store.UpsertHeaders(mailboxId, headers)));
public void UpsertHeaders(
string mailboxId,
IEnumerable<MailArchiveHeader> 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<uint> 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,

View File

@ -23,12 +23,25 @@ public sealed partial class MailArchiveStore : IDisposable {
EnsureSchema();
}
public void UpsertHeaders(string mailboxId, IEnumerable<MailArchiveHeader> headers) {
public void UpsertHeaders(
string mailboxId,
IEnumerable<MailArchiveHeader> 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<uint> 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 + ");";

View File

@ -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");

View File

@ -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);

View File

@ -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<SpamListItem> 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<bool> 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<bool> 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<uint>();
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;
}