(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] ## [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 ## [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. 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> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.4.4</Version> <Version>0.4.5</Version>
<Product>Postclient</Product> <Product>Postclient</Product>
<AssemblyTitle>Postclient</AssemblyTitle> <AssemblyTitle>Postclient</AssemblyTitle>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>

View File

@ -69,6 +69,10 @@ public sealed class ImapMailSession : IMailSession {
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
await MailKitAuth.AuthenticateAsync(client, account, resolved.Value, cancellationToken).ConfigureAwait(false); await MailKitAuth.AuthenticateAsync(client, account, resolved.Value, cancellationToken).ConfigureAwait(false);
} }
catch (OperationCanceledException) {
client.Dispose();
throw;
}
catch (Exception ex) { catch (Exception ex) {
client.Dispose(); client.Dispose();
return Result.UnprocessableEntity(MailAuthHint.Incoming(account, ex.Message)); 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)); return Result<IReadOnlyList<MailFolderInfo>>.Ok(MailFolderCatalog.Normalize(folders, layout));
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result<IReadOnlyList<MailFolderInfo>>.UnprocessableEntity(null, ex.Message); return Result<IReadOnlyList<MailFolderInfo>>.UnprocessableEntity(null, ex.Message);
} }
@ -240,7 +247,12 @@ public sealed class ImapMailSession : IMailSession {
Incomplete = range.Incomplete Incomplete = range.Incomplete
}); });
} }
catch (OperationCanceledException) {
ClearListCursor(folder);
throw;
}
catch (Exception ex) { catch (Exception ex) {
ClearListCursor(folder);
return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message); 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); var uid = await imapFolder.AppendAsync(new AppendRequest(mime), cancellationToken).ConfigureAwait(false);
return Result<uint?>.Ok(uid?.Id); return Result<uint?>.Ok(uid?.Id);
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result<uint?>.UnprocessableEntity(null, ex.Message); return Result<uint?>.UnprocessableEntity(null, ex.Message);
} }
@ -414,17 +429,42 @@ public sealed class ImapMailSession : IMailSession {
else else
await CopyThenDeleteAsync(source, dest, uids, cancellationToken).ConfigureAwait(false); await CopyThenDeleteAsync(source, dest, uids, cancellationToken).ConfigureAwait(false);
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception) when (client.Capabilities.HasFlag(ImapCapabilities.Move)) { 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(); return Result.Ok();
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message); 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( private static async Task CopyThenDeleteAsync(
IMailFolder source, IMailFolder source,
IMailFolder dest, IMailFolder dest,
@ -481,6 +521,9 @@ public sealed class ImapMailSession : IMailSession {
catch (Exception ex) when (attempt == 0 && IsBrokenConnection(ex)) { catch (Exception ex) when (attempt == 0 && IsBrokenConnection(ex)) {
DropSocket(); DropSocket();
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message); return Result.UnprocessableEntity(ex.Message);
} }
@ -518,6 +561,9 @@ public sealed class ImapMailSession : IMailSession {
await parent.CreateAsync(leaf, true, cancellationToken).ConfigureAwait(false); await parent.CreateAsync(leaf, true, cancellationToken).ConfigureAwait(false);
return Result.Ok(); return Result.Ok();
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message); return Result.UnprocessableEntity(ex.Message);
} }
@ -561,6 +607,9 @@ public sealed class ImapMailSession : IMailSession {
await imapFolder.RenameAsync(parent, leaf, cancellationToken).ConfigureAwait(false); await imapFolder.RenameAsync(parent, leaf, cancellationToken).ConfigureAwait(false);
return Result.Ok(); return Result.Ok();
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message); return Result.UnprocessableEntity(ex.Message);
} }
@ -614,6 +663,9 @@ public sealed class ImapMailSession : IMailSession {
return Result.Ok(); return Result.Ok();
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message); return Result.UnprocessableEntity(ex.Message);
} }
@ -672,6 +724,9 @@ public sealed class ImapMailSession : IMailSession {
return Result.Ok(); return Result.Ok();
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message); return Result.UnprocessableEntity(ex.Message);
} }
@ -707,6 +762,9 @@ public sealed class ImapMailSession : IMailSession {
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
return Result.Ok(); return Result.Ok();
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result.UnprocessableEntity(ex.Message); return Result.UnprocessableEntity(ex.Message);
} }
@ -1020,6 +1078,9 @@ public sealed class ImapMailSession : IMailSession {
if (rows.Count > 0 || folder.Count == 0) if (rows.Count > 0 || folder.Count == 0)
return rows; return rows;
} }
catch (OperationCanceledException) {
throw;
}
catch { catch {
} }

View File

@ -200,12 +200,12 @@ public sealed class MailSyncEngine {
var page = await session.ListMessagesAsync(folder, known, cancellationToken, MailPipeline.HeaderPage) var page = await session.ListMessagesAsync(folder, known, cancellationToken, MailPipeline.HeaderPage)
.ConfigureAwait(false); .ConfigureAwait(false);
if (!page.IsSuccess) if (!page.IsSuccess)
return true; return false;
var sync = page.Value ?? new MailFolderSync(); var sync = page.Value ?? new MailFolderSync();
_archive.SetKeywordIndexEnabled(box.Id, box.InitialSyncCompleted); _archive.SetKeywordIndexEnabled(box.Id, box.InitialSyncCompleted);
if (sync.Flags.Count > 0) if (sync.Flags.Count > 0)
_archive.UpdateFlags(box.Id, folder, sync.Flags.Select(f => (f.Id, f.IsSeen, f.IsFlagged))); _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) { if (sync.Present is not null) {
var keep = sync.Present.ToHashSet(); var keep = sync.Present.ToHashSet();
var gone = _archive.Uids(box.Id, folder).Where(uid => !keep.Contains(uid)).ToList(); 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) { 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); .ConfigureAwait(false);
if (stored.IsSuccess)
_archive.UpdateFlags( _archive.UpdateFlags(
box.Id, box.Id,
folder, folder,
headers.Where(header => read.Contains(header.Id)) headers.Where(header => read.Contains(header.Id))
.Select(header => (header.Id, true, header.IsFlagged || flagged.Contains(header.Id)))); .Select(header => (header.Id, true, header.IsFlagged || flagged.Contains(header.Id))),
respectPending: true);
} }
if (flagged.Count > 0) if (flagged.Count > 0) {
await session.SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, cancellationToken) var stored = await session
.SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, cancellationToken)
.ConfigureAwait(false); .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( private async Task RetentionAsync(

View File

@ -55,6 +55,10 @@ public sealed class Pop3MailSession : IMailSession {
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
await MailKitAuth.AuthenticateAsync(client, account, resolved.Value, cancellationToken).ConfigureAwait(false); await MailKitAuth.AuthenticateAsync(client, account, resolved.Value, cancellationToken).ConfigureAwait(false);
} }
catch (OperationCanceledException) {
client.Dispose();
throw;
}
catch (Exception ex) { catch (Exception ex) {
client.Dispose(); client.Dispose();
return Result.UnprocessableEntity(MailAuthHint.Incoming(account, ex.Message)); return Result.UnprocessableEntity(MailAuthHint.Incoming(account, ex.Message));
@ -159,6 +163,9 @@ public sealed class Pop3MailSession : IMailSession {
Incomplete = true Incomplete = true
}); });
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message); return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message);
} }
@ -180,10 +187,11 @@ public sealed class Pop3MailSession : IMailSession {
if (client is null) if (client is null)
return Result<MailMessageBody>.UnprocessableEntity(null, "Not connected."); return Result<MailMessageBody>.UnprocessableEntity(null, "Not connected.");
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var timeout = interactive ? null : new CancellationTokenSource(MailFetch.BackfillTimeout);
using var abort = linked.Token.Register(DropSocket); using var linked = timeout is null
if (!interactive) ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
linked.CancelAfter(MailFetch.BackfillTimeout); : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
using var abort = timeout is null ? default : timeout.Token.Register(DropSocket);
MimeMessage mime; MimeMessage mime;
try { try {
var index = (int)id; var index = (int)id;

View File

@ -73,6 +73,9 @@ public sealed class PstMailSession : IMailSession {
OpenStore(path); OpenStore(path);
return Task.FromResult(Result.Ok()); return Task.FromResult(Result.Ok());
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Task.FromResult(Result.UnprocessableEntity(WriteError(ex))); return Task.FromResult(Result.UnprocessableEntity(WriteError(ex)));
} }
@ -104,6 +107,9 @@ public sealed class PstMailSession : IMailSession {
return Task.FromResult(Result<IReadOnlyList<MailFolderInfo>>.Ok( return Task.FromResult(Result<IReadOnlyList<MailFolderInfo>>.Ok(
MailFolderCatalog.Normalize(folders, MailFolderLayout.Store))); MailFolderCatalog.Normalize(folders, MailFolderLayout.Store)));
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Task.FromResult(Result<IReadOnlyList<MailFolderInfo>>.UnprocessableEntity(null, ex.Message)); return Task.FromResult(Result<IReadOnlyList<MailFolderInfo>>.UnprocessableEntity(null, ex.Message));
} }
@ -279,6 +285,9 @@ public sealed class PstMailSession : IMailSession {
Incomplete = incomplete Incomplete = incomplete
}); });
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message); return Result<MailFolderSync>.UnprocessableEntity(null, ex.Message);
} }
@ -329,6 +338,9 @@ public sealed class PstMailSession : IMailSession {
using var stream = new MemoryStream(eml, writable: false); using var stream = new MemoryStream(eml, writable: false);
mime = await MimeMessage.LoadAsync(stream, cancellationToken).ConfigureAwait(false); mime = await MimeMessage.LoadAsync(stream, cancellationToken).ConfigureAwait(false);
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
return Result<uint?>.UnprocessableEntity(null, ex.Message); return Result<uint?>.UnprocessableEntity(null, ex.Message);
} }
@ -603,6 +615,15 @@ public sealed class PstMailSession : IMailSession {
if (!copy.IsSuccess) if (!copy.IsSuccess)
return copy; return copy;
} }
catch (OperationCanceledException) {
try {
OpenStore(path);
}
catch {
}
throw;
}
catch (Exception ex) { catch (Exception ex) {
try { try {
OpenStore(path); OpenStore(path);

View File

@ -118,8 +118,11 @@ public sealed class MailArchiveCatalog : IDisposable {
return store; return store;
} }
public void UpsertHeaders(string mailboxId, IEnumerable<MailArchiveHeader> headers) => public void UpsertHeaders(
OffUi(() => Use(mailboxId, store => store.UpsertHeaders(mailboxId, headers))); string mailboxId,
IEnumerable<MailArchiveHeader> headers,
bool updateFlags = false) =>
OffUi(() => Use(mailboxId, store => store.UpsertHeaders(mailboxId, headers, updateFlags)));
public void SetKeywordIndexEnabled(string mailboxId, bool enabled) => public void SetKeywordIndexEnabled(string mailboxId, bool enabled) =>
OffUi(() => { OffUi(() => {
@ -146,8 +149,27 @@ public sealed class MailArchiveCatalog : IDisposable {
public void UpdateFlags( public void UpdateFlags(
string mailboxId, string mailboxId,
string folder, string folder,
IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags) => IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags,
OffUi(() => Use(mailboxId, store => store.UpdateFlags(mailboxId, folder, 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( public void UpsertBody(
string mailboxId, string mailboxId,

View File

@ -23,12 +23,25 @@ public sealed partial class MailArchiveStore : IDisposable {
EnsureSchema(); EnsureSchema();
} }
public void UpsertHeaders(string mailboxId, IEnumerable<MailArchiveHeader> headers) { public void UpsertHeaders(
string mailboxId,
IEnumerable<MailArchiveHeader> headers,
bool updateFlags = false) {
ArgumentNullException.ThrowIfNull(headers); ArgumentNullException.ThrowIfNull(headers);
lock (_gate) { lock (_gate) {
using var tx = _db.BeginTransaction(); using var tx = _db.BeginTransaction();
foreach (var header in headers) foreach (var header in headers) {
UpsertHeader(tx, mailboxId, header, updateFlags: true); 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(); tx.Commit();
} }
} }
@ -36,11 +49,19 @@ public sealed partial class MailArchiveStore : IDisposable {
public void UpdateFlags( public void UpdateFlags(
string mailboxId, string mailboxId,
string folder, string folder,
IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags) { IEnumerable<(uint Uid, bool IsSeen, bool IsFlagged)> flags,
bool respectPending = true) {
ArgumentNullException.ThrowIfNull(flags); ArgumentNullException.ThrowIfNull(flags);
lock (_gate) { lock (_gate) {
using var tx = _db.BeginTransaction(); using var tx = _db.BeginTransaction();
foreach (var row in flags) { 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(); using var cmd = _db.CreateCommand();
cmd.Transaction = tx; cmd.Transaction = tx;
cmd.CommandText = """ cmd.CommandText = """
@ -54,12 +75,83 @@ public sealed partial class MailArchiveStore : IDisposable {
cmd.Parameters.AddWithValue("$f", folder); cmd.Parameters.AddWithValue("$f", folder);
cmd.Parameters.AddWithValue("$u", row.Uid); cmd.Parameters.AddWithValue("$u", row.Uid);
cmd.ExecuteNonQuery(); 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(); 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( public void UpsertBody(
string mailboxId, string mailboxId,
MailArchiveHeader header, MailArchiveHeader header,
@ -1297,6 +1389,14 @@ public sealed partial class MailArchiveStore : IDisposable {
fingerprint TEXT PRIMARY KEY, fingerprint TEXT PRIMARY KEY,
dismissed_utc TEXT NOT NULL DEFAULT '' 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(); cmd.ExecuteNonQuery();
EnsureColumn("messages", "trashed_utc", "TEXT NOT NULL DEFAULT ''"); EnsureColumn("messages", "trashed_utc", "TEXT NOT NULL DEFAULT ''");
@ -1304,6 +1404,73 @@ public sealed partial class MailArchiveStore : IDisposable {
EnsureColumn("messages", "spam_score", "REAL"); 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) { private void EnsureColumn(string table, string column, string definition) {
using var info = _db.CreateCommand(); using var info = _db.CreateCommand();
info.CommandText = "PRAGMA table_info(" + table + ");"; 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] [Fact]
public void UpsertHeaders_KeepsPreviousSizeWhenIncomingIsZero() { public void UpsertHeaders_KeepsPreviousSizeWhenIncomingIsZero() {
var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); 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; return true;
} }
if (token.IsCancellationRequested || Volatile.Read(ref _mailEpoch) != epoch) if (token.IsCancellationRequested)
return true; return true;
var applyFlags = Volatile.Read(ref _mailEpoch) == epoch;
_folderFailUtc.Remove(key); _folderFailUtc.Remove(key);
var sync = listed.Value ?? new MailFolderSync(); 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(); var fresh = sync.Headers.Where(header => !known.Contains(header.Id)).ToList();
if (fresh.Count > 0) { var openFolder = false;
EnqueueFreshRules(slot.MailboxId, slot.Folder, fresh);
await UiAsync(() => { await UiAsync(() => {
if (SelectedMailbox?.Id.Equals(slot.MailboxId, StringComparison.OrdinalIgnoreCase) == true openFolder = SelectedMailbox?.Id.Equals(slot.MailboxId, StringComparison.OrdinalIgnoreCase) == true
&& SelectedFolder?.FullName.Equals(slot.Folder, StringComparison.OrdinalIgnoreCase) == true) && SelectedFolder?.FullName.Equals(slot.Folder, StringComparison.OrdinalIgnoreCase) == true;
if (fresh.Count > 0 && openFolder)
NoticeNewMail(fresh); NoticeNewMail(fresh);
}).ConfigureAwait(false); }).ConfigureAwait(false);
if (fresh.Count > 0)
EnqueueFreshRules(slot.MailboxId, slot.Folder, fresh);
if (openFolder && (fresh.Count > 0 || (applyFlags && sync.Flags.Count > 0)))
await ShowArchiveAsync(slot.MailboxId, slot.Folder, token).ConfigureAwait(false); await ShowArchiveAsync(slot.MailboxId, slot.Folder, token).ConfigureAwait(false);
}
await SetIndexLineAsync(GlobalIndexLine(), token).ConfigureAwait(false); await SetIndexLineAsync(GlobalIndexLine(), token).ConfigureAwait(false);
var counts = _archive.FolderCounts(slot.MailboxId, slot.Folder); var counts = _archive.FolderCounts(slot.MailboxId, slot.Folder);

View File

@ -1829,7 +1829,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
return; return;
if (IsLoadedBody(row.Header) && _reading is not null) { if (IsLoadedBody(row.Header) && _reading is not null) {
if (!row.Header.IsSeen) if (!row.Header.IsSeen)
_ = MarkOpenedReadAsync(row.Header, CancellationToken.None); _ = MarkOpenedReadAsync(row.Header);
ShowMessageWindow(_reading); ShowMessageWindow(_reading);
return; return;
} }
@ -1932,34 +1932,47 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
[RelayCommand(CanExecute = nameof(CanOrganize))] [RelayCommand(CanExecute = nameof(CanOrganize))]
private async Task MarkSpamAsync() { private async Task MarkSpamAsync() {
var rows = TargetRows(); var rows = TargetRows();
if (rows.Count == 0 || SelectedMailbox is null) if (rows.Count == 0)
return; return;
foreach (var row in rows) foreach (var row in rows)
_archive.MarkSpam(row.MailboxId, row.Header.Folder, row.Header.Id, EmbeddingModelSpec.Id); _archive.MarkSpam(row.MailboxId, row.Header.Folder, row.Header.Id, EmbeddingModelSpec.Id);
var junk = JunkFolder(SelectedMailbox); var moved = false;
if (string.IsNullOrWhiteSpace(junk)) { foreach (var group in rows.GroupBy(row => row.MailboxId, StringComparer.OrdinalIgnoreCase)) {
Status = Copy.SpamNoJunk; var box = MailboxById(group.Key) ?? SelectedMailbox;
PaintSpam(rows); if (box is null)
return; 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))] [RelayCommand(CanExecute = nameof(CanOrganize))]
private async Task MarkNotSpamAsync() { private async Task MarkNotSpamAsync() {
var rows = TargetRows(); var rows = TargetRows();
if (rows.Count == 0 || SelectedMailbox is null) if (rows.Count == 0)
return; return;
foreach (var row in rows) foreach (var row in rows)
_archive.DismissMessage(row.MailboxId, row.Header.Folder, row.Header.Id); _archive.DismissMessage(row.MailboxId, row.Header.Folder, row.Header.Id);
var inbox = InboxOf(SelectedMailbox)?.FullName ?? ""; 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)) { if (string.IsNullOrWhiteSpace(inbox)) {
PaintSpam(rows); PaintSpam(group);
return; continue;
} }
await MoveRowsAsync(rows, inbox); await MoveRowsAsync(group.ToList(), inbox);
}
} }
public IReadOnlyList<SpamListItem> SpamExamples() => public IReadOnlyList<SpamListItem> SpamExamples() =>
@ -2197,7 +2210,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
row => row.Header.Priority = priority); row => row.Header.Priority = priority);
} }
[RelayCommand(CanExecute = nameof(CanOrganize), AllowConcurrentExecutions = true)] [RelayCommand(CanExecute = nameof(CanOrganize))]
private async Task DeleteMessagesAsync() { private async Task DeleteMessagesAsync() {
var rows = TargetRows(); var rows = TargetRows();
if (rows.Count == 0) if (rows.Count == 0)
@ -2375,27 +2388,59 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var session = ActiveSession; var session = ActiveSession;
var box = SelectedMailbox; var box = SelectedMailbox;
var folder = SelectedFolder; 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; return;
await RunAsync(Copy.UpdatingFlags, async token => {
NoteUiMailChange(); NoteUiMailChange();
var result = await session.SetFolderSeenAsync(folder.FullName, seen, token); var previous = Messages.Select(row => (row, row.Header.IsSeen, row.Header.IsFlagged)).ToList();
if (!result.IsSuccess) {
Status = string.Join(" ", result.Messages);
return;
}
_archive.SetSeen(box.Id, folder.FullName, seen);
foreach (var row in Messages) { foreach (var row in Messages) {
row.Header.IsSeen = seen; row.Header.IsSeen = seen;
row.RefreshMarks(); row.RefreshMarks();
} }
folder.Unread = seen ? 0 : folder.Total; 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);
NotifyFolderCounts(); NotifyFolderCounts();
RefreshFolderStats(); RefreshFolderStats();
Status = seen ? Copy.FolderMarkedRead : Copy.FolderMarkedUnread; 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;
}
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) { private async Task<bool> ConfirmAsync(string title, string message) {
@ -3561,7 +3606,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
if (read.Count > 0) { if (read.Count > 0) {
await session.SetMessageFlagsAsync(folder, read, new MailFlagUpdate { Seen = true }, token); var stored = await session.SetMessageFlagsAsync(
folder, read, new MailFlagUpdate { Seen = true }, token);
if (stored.IsSuccess)
_archive.UpdateFlags( _archive.UpdateFlags(
box.Id, box.Id,
folder, folder,
@ -3570,7 +3617,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
if (flagged.Count > 0) { if (flagged.Count > 0) {
await session.SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, token); var stored = await session.SetMessageFlagsAsync(
folder, flagged, new MailFlagUpdate { Flagged = true }, token);
if (stored.IsSuccess)
_archive.UpdateFlags( _archive.UpdateFlags(
box.Id, box.Id,
folder, folder,
@ -3624,23 +3673,16 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
continue; continue;
await EnsureFoldersForRulesAsync(destBox.Id); await EnsureFoldersForRulesAsync(destBox.Id);
foreach (var id in pair.Value) { foreach (var id in pair.Value) {
if (await CopyUidToMailboxAsync(source, sourceSession, destBox, sourceFolder, pair.Key.Folder, id, token)) if (!await CopyUidToMailboxAsync(source, sourceSession, destBox, sourceFolder, pair.Key.Folder, id, token))
continue;
await RemoveCopiedUidsAsync(source, sourceSession, sourceFolder, [id], token);
copied.Add(id); copied.Add(id);
} }
} }
if (copied.Count == 0) if (copied.Count == 0)
return; return;
var unique = copied.Distinct().ToList(); RemoveDroppedRows(source.Id, sourceFolder, 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);
} }
private async Task<bool> CopyUidToMailboxAsync( private async Task<bool> CopyUidToMailboxAsync(
@ -3874,6 +3916,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
if (missingFolders) if (missingFolders)
await LoadFoldersAsync(box, live, token).ConfigureAwait(false); await LoadFoldersAsync(box, live, token).ConfigureAwait(false);
KickSyncSession(box, password); KickSyncSession(box, password);
await FlushPendingFlagsAsync(box).ConfigureAwait(false);
return true; return true;
} }
@ -3904,6 +3947,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
pst.StorePathChanged += path => PersistPstStorePath(box, path); pst.StorePathChanged += path => PersistPstStorePath(box, path);
await LoadFoldersAsync(box, session, token).ConfigureAwait(false); await LoadFoldersAsync(box, session, token).ConfigureAwait(false);
KickSyncSession(box, password); KickSyncSession(box, password);
await FlushPendingFlagsAsync(box).ConfigureAwait(false);
await UiAsync(() => { await UiAsync(() => {
EnsureIndexing(box.Id); EnsureIndexing(box.Id);
NotifyFolderCommands(); 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() { private async Task ConnectIdleMailboxesAsync() {
foreach (var box in Mailboxes.ToList()) { foreach (var box in Mailboxes.ToList()) {
if (SessionFor(box) is { IsConnected: true }) 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)) if (!EnsureArchive(mailboxId))
return; return;
var ready = IsInitialSyncComplete(mailboxId); var ready = IsInitialSyncComplete(mailboxId);
_archive.SetKeywordIndexEnabled(mailboxId, ready); _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.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) { if (sync.Present is not null) {
var keep = sync.Present.ToHashSet(); var keep = sync.Present.ToHashSet();
var gone = _archive.Uids(mailboxId, folder).Where(uid => !keep.Contains(uid)).ToList(); var gone = _archive.Uids(mailboxId, folder).Where(uid => !keep.Contains(uid)).ToList();
if (gone.Count > 0) if (gone.Count > 0)
DropArchived(mailboxId, folder, gone); DropArchived(mailboxId, folder, gone);
} }
} }
private bool EnsureArchive(string mailboxId) { private bool EnsureArchive(string mailboxId) {
@ -4000,10 +4074,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (Volatile.Read(ref _mailEpoch) != epoch) var applyFlags = Volatile.Read(ref _mailEpoch) == epoch;
return;
var sync = result.Value ?? new MailFolderSync(); 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(); var fresh = sync.Headers.Where(h => !known.Contains(h.Id)).ToList();
if (known.Count > 0) { if (known.Count > 0) {
await UiAsync(() => { await UiAsync(() => {
@ -4325,11 +4398,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
StoreArchivedBody(box.Id, header, body); StoreArchivedBody(box.Id, header, body);
await Dispatcher.UIThread.InvokeAsync(async () => { await Dispatcher.UIThread.InvokeAsync(async () => {
MarkRowAttachments(header); MarkRowAttachments(header);
if (SelectedMessage is null var stillSelected = SelectedMessage is not null
|| SelectedMessage.Header.Id != header.Id && SelectedMessage.Header.Id == header.Id
|| !SelectedMessage.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) { && SelectedMessage.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase);
if (!stillSelected) {
if (Status == Copy.OpeningMessage) if (Status == Copy.OpeningMessage)
Status = ""; Status = "";
// Body finished after the user moved on — still keep the Seen mark.
await MarkOpenedReadAsync(header).ConfigureAwait(true);
return; return;
} }
@ -4353,7 +4429,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
Status = explain; Status = explain;
} }
await MarkOpenedReadAsync(header, token).ConfigureAwait(true); await MarkOpenedReadAsync(header).ConfigureAwait(true);
if (_pendingWindow is { } pending if (_pendingWindow is { } pending
&& pending.Header.Id == header.Id && pending.Header.Id == header.Id
&& pending.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) { && pending.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) {
@ -4387,7 +4463,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
MessageWindowRequested?.Invoke(vm); MessageWindowRequested?.Invoke(vm);
} }
private async Task MarkOpenedReadAsync(MailMessageHeader header, CancellationToken token) { private async Task MarkOpenedReadAsync(MailMessageHeader header) {
var row = Messages.FirstOrDefault(m => var row = Messages.FirstOrDefault(m =>
m.Header.Id == header.Id m.Header.Id == header.Id
&& m.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)); && m.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase));
@ -4405,30 +4481,54 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var flagged = row.Header.IsFlagged; var flagged = row.Header.IsFlagged;
row.Header.IsSeen = true; row.Header.IsSeen = true;
row.RefreshMarks(); row.RefreshMarks();
var pending = box is not null && UsesImap(box);
if (box is not null) 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(); NotifyFolderCounts();
RefreshFolderStats(); RefreshFolderStats();
var session = SessionFor(box); var session = SessionFor(box);
if (session is not { SupportsFolders: true }) if (session is not { IsConnected: true, SupportsFolders: true })
return; return;
var result = await session.SetMessageFlagsAsync( Result result;
try {
result = await session.SetMessageFlagsAsync(
folder, folder,
[row.Header.Id], [row.Header.Id],
new MailFlagUpdate { Seen = true }, new MailFlagUpdate { Seen = true },
token).ConfigureAwait(true); CancellationToken.None).ConfigureAwait(true);
if (result.IsSuccess) }
catch (OperationCanceledException) {
return;
}
if (result.IsSuccess) {
if (box is not null)
_archive.ClearPendingFlags(box.Id, folder, [row.Header.Id]);
return;
}
if (IsSoftFlagFailure(result))
return; return;
if (folderRow is not null) if (folderRow is not null)
ApplyUnreadDelta(folderRow, [row], false); ApplyUnreadDelta(folderRow, [row], false);
row.Header.IsSeen = false; row.Header.IsSeen = false;
row.RefreshMarks(); row.RefreshMarks();
if (box is not null) 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(); NotifyFolderCounts();
RefreshFolderStats(); 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) { private void ApplyReading(MailMessageBody body, bool keepKind = false) {
var inner = UnwrapEnvelope && body.Envelope.HasInnerMessage; var inner = UnwrapEnvelope && body.Envelope.HasInnerMessage;
ReadingSubject = inner && !string.IsNullOrWhiteSpace(body.InnerSubject) ReadingSubject = inner && !string.IsNullOrWhiteSpace(body.InnerSubject)
@ -4987,7 +5087,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
NoteUiMailChange(); NoteUiMailChange();
var copied = new List<uint>(); var copied = new List<uint>();
foreach (var id in ids) { foreach (var id in ids) {
if (await CopyUidToMailboxAsync(sourceBox, sourceSession, destBox, source, dest, id, token)) if (!await CopyUidToMailboxAsync(sourceBox, sourceSession, destBox, source, dest, id, token))
continue;
await RemoveCopiedUidsAsync(sourceBox, sourceSession, source, [id], token);
copied.Add(id); copied.Add(id);
} }
@ -4996,7 +5098,6 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
return; return;
} }
await RemoveCopiedUidsAsync(sourceBox, sourceSession, source, copied, token);
ApplyFolderMoveCounts(sourceBox, destBox, source, dest, copied.Count, rows); ApplyFolderMoveCounts(sourceBox, destBox, source, dest, copied.Count, rows);
RemoveDroppedRows(sourceBox.Id, source, copied); RemoveDroppedRows(sourceBox.Id, source, copied);
await LoadFoldersAsync(destBox, destSession, token); await LoadFoldersAsync(destBox, destSession, token);
@ -5121,20 +5222,25 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var targets = rows ?? TargetRows(); var targets = rows ?? TargetRows();
if (targets.Count == 0) if (targets.Count == 0)
return false; return false;
var ok = false; var ok = true;
await RunAsync(Copy.UpdatingFlags, async token => { Status = Copy.UpdatingFlags;
foreach (var group in targets.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) { foreach (var group in targets.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) {
var box = MailboxById(group.Key.MailboxId) ?? SelectedMailbox; var box = MailboxById(group.Key.MailboxId) ?? SelectedMailbox;
var session = SessionFor(box); var session = SessionFor(box);
if (session is not { SupportsFolders: true }) { 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; Status = Copy.Connecting;
return; ok = false;
continue;
} }
NoteUiMailChange(); 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 if (update.Seen is { } seen && SelectedFolder is { } current
&& current.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) && current.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase))
ApplyUnreadDelta(current, groupRows, seen); ApplyUnreadDelta(current, groupRows, seen);
@ -5143,46 +5249,72 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
apply(row); apply(row);
row.RefreshMarks(); row.RefreshMarks();
} }
if (box is not null)
_archive.UpdateFlags(
box.Id,
folder,
groupRows.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged)));
} }
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(); NotifyFolderCounts();
RefreshFolderStats(); RefreshFolderStats();
var result = await session.SetMessageFlagsAsync( if (!remote || session is null) {
if (apply is not null)
continue;
Status = Copy.Connecting;
ok = false;
continue;
}
Result result;
try {
result = await session.SetMessageFlagsAsync(
folder, folder,
TargetIds(groupRows), TargetIds(groupRows),
update, update,
token).ConfigureAwait(true); CancellationToken.None).ConfigureAwait(true);
if (result.IsSuccess) }
catch (OperationCanceledException) {
continue; 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) { foreach (var item in previous) {
item.row.Header.IsSeen = item.IsSeen; item.row.Header.IsSeen = item.IsSeen;
item.row.Header.IsFlagged = item.IsFlagged; item.row.Header.IsFlagged = item.IsFlagged;
item.row.Header.Priority = item.Priority;
item.row.RefreshMarks(); item.row.RefreshMarks();
} }
if (box is not null) if (box is not null && apply is not null)
_archive.UpdateFlags( _archive.SetLocalFlags(
box.Id, box.Id,
folder, folder,
previous.Select(item => (item.row.Header.Id, item.IsSeen, item.IsFlagged))); previous.Select(item => (item.row.Header.Id, item.IsSeen, item.IsFlagged)),
pending: false);
if (SelectedFolder is { } open if (SelectedFolder is { } open
&& open.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) && open.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase))
ApplyOpenedFolderCounts(); ApplyOpenedFolderCounts();
NotifyFolderCounts(); NotifyFolderCounts();
RefreshFolderStats(); RefreshFolderStats();
Status = string.Join(" ", result.Messages); Status = string.Join(" ", result.Messages);
return; ok = false;
} }
if (ok)
Status = "Updated."; Status = "Updated.";
ok = true;
});
return ok; return ok;
} }