mirror of
https://github.com/MAKS-IT-COM/maksit-postclient.git
synced 2026-11-15 06:26:11 +01:00
(bugfix): keep unread and flags stable across cancel and sync races
This commit is contained in:
parent
8ff6f91673
commit
243c06879d
13
CHANGELOG.md
13
CHANGELOG.md
@ -6,6 +6,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.5] - 2026-10-31
|
||||
|
||||
Unread and flag updates stay local when the server store is cancelled or races with sync. Pending Seen/Flagged wait for STORE ack before background sync can overwrite them.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Opening an unread message no longer brings the unread dot back when you select another row: mark-as-read is not cancelled with the reading pane, and IMAP cancel is not treated as a store failure.
|
||||
- Manual Mark Read/Unread/Flag and folder mark-all-read keep optimistic state off the busy `_work` token; IMAP flag stores use a non-folder CTS and pending archive flags until STORE succeeds (or soft “not connected”, which stays pending and flushes on reconnect).
|
||||
- Background sync and header upserts no longer clobber newer local Seen/Flagged; `UpsertHeaders` skips flag overwrite by default and `UpdateFlags` respects pending UIDs.
|
||||
- IMAP MOVE no longer falls back to copy-then-delete on cancel; remaining UIDs are checked before copy/expunge. Cross-mailbox moves delete each source UID after a successful copy.
|
||||
- Rules and headless sync write archive flags only after a successful STORE. SyncFolder no longer advances past a folder on list failure.
|
||||
- POP3 Mark Read works locally; spam junk/inbox destinations resolve per message mailbox; Priority is restored on failed flag updates.
|
||||
|
||||
## [0.4.4] - 2026-10-29
|
||||
|
||||
HTML mail blocks remote images by default. Windows setup upgrades keep Identity Hub sibling DLLs. Store MSIX is built with each release.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
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))));
|
||||
.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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 + ");";
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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) {
|
||||
EnqueueFreshRules(slot.MailboxId, slot.Folder, fresh);
|
||||
var openFolder = false;
|
||||
await UiAsync(() => {
|
||||
if (SelectedMailbox?.Id.Equals(slot.MailboxId, StringComparison.OrdinalIgnoreCase) == true
|
||||
&& SelectedFolder?.FullName.Equals(slot.Folder, StringComparison.OrdinalIgnoreCase) == true)
|
||||
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);
|
||||
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);
|
||||
|
||||
@ -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 ?? "";
|
||||
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(rows);
|
||||
return;
|
||||
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;
|
||||
}
|
||||
|
||||
_archive.SetSeen(box.Id, folder.FullName, seen);
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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,7 +3606,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
}
|
||||
|
||||
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(
|
||||
box.Id,
|
||||
folder,
|
||||
@ -3570,7 +3617,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
}
|
||||
|
||||
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(
|
||||
box.Id,
|
||||
folder,
|
||||
@ -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))
|
||||
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(
|
||||
Result result;
|
||||
try {
|
||||
result = await session.SetMessageFlagsAsync(
|
||||
folder,
|
||||
[row.Header.Id],
|
||||
new MailFlagUpdate { Seen = true },
|
||||
token).ConfigureAwait(true);
|
||||
if (result.IsSuccess)
|
||||
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,7 +5087,9 @@ 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))
|
||||
if (!await CopyUidToMailboxAsync(sourceBox, sourceSession, destBox, source, dest, id, token))
|
||||
continue;
|
||||
await RemoveCopiedUidsAsync(sourceBox, sourceSession, source, [id], token);
|
||||
copied.Add(id);
|
||||
}
|
||||
|
||||
@ -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,20 +5222,25 @@ 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 => {
|
||||
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);
|
||||
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;
|
||||
return;
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
@ -5143,46 +5249,72 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
apply(row);
|
||||
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();
|
||||
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,
|
||||
TargetIds(groupRows),
|
||||
update,
|
||||
token).ConfigureAwait(true);
|
||||
if (result.IsSuccess)
|
||||
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)
|
||||
_archive.UpdateFlags(
|
||||
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)));
|
||||
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);
|
||||
return;
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (ok)
|
||||
Status = "Updated.";
|
||||
ok = true;
|
||||
});
|
||||
return ok;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user