mirror of
https://github.com/MAKS-IT-COM/maksit-postclient.git
synced 2026-11-14 22:26:09 +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]
|
## [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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
_archive.UpdateFlags(
|
if (stored.IsSuccess)
|
||||||
box.Id,
|
_archive.UpdateFlags(
|
||||||
folder,
|
box.Id,
|
||||||
headers.Where(header => read.Contains(header.Id))
|
folder,
|
||||||
.Select(header => (header.Id, true, header.IsFlagged || flagged.Contains(header.Id))));
|
headers.Where(header => read.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(
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
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(
|
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 + ");";
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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;
|
||||||
|
await UiAsync(() => {
|
||||||
|
openFolder = SelectedMailbox?.Id.Equals(slot.MailboxId, StringComparison.OrdinalIgnoreCase) == true
|
||||||
|
&& SelectedFolder?.FullName.Equals(slot.Folder, StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
if (fresh.Count > 0 && openFolder)
|
||||||
|
NoticeNewMail(fresh);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
if (fresh.Count > 0)
|
||||||
EnqueueFreshRules(slot.MailboxId, slot.Folder, fresh);
|
EnqueueFreshRules(slot.MailboxId, slot.Folder, fresh);
|
||||||
await UiAsync(() => {
|
if (openFolder && (fresh.Count > 0 || (applyFlags && sync.Flags.Count > 0)))
|
||||||
if (SelectedMailbox?.Id.Equals(slot.MailboxId, StringComparison.OrdinalIgnoreCase) == true
|
|
||||||
&& SelectedFolder?.FullName.Equals(slot.Folder, StringComparison.OrdinalIgnoreCase) == true)
|
|
||||||
NoticeNewMail(fresh);
|
|
||||||
}).ConfigureAwait(false);
|
|
||||||
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);
|
||||||
|
|||||||
@ -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)) {
|
||||||
if (string.IsNullOrWhiteSpace(inbox)) {
|
var box = MailboxById(group.Key) ?? SelectedMailbox;
|
||||||
PaintSpam(rows);
|
if (box is null)
|
||||||
return;
|
continue;
|
||||||
}
|
var inbox = InboxOf(box)?.FullName ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(inbox)) {
|
||||||
|
PaintSpam(group);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await MoveRowsAsync(rows, inbox);
|
await MoveRowsAsync(group.ToList(), inbox);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<SpamListItem> SpamExamples() =>
|
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 previous = Messages.Select(row => (row, row.Header.IsSeen, row.Header.IsFlagged)).ToList();
|
||||||
var result = await session.SetFolderSeenAsync(folder.FullName, seen, token);
|
foreach (var row in Messages) {
|
||||||
if (!result.IsSuccess) {
|
row.Header.IsSeen = seen;
|
||||||
Status = string.Join(" ", result.Messages);
|
row.RefreshMarks();
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
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);
|
_archive.SetSeen(box.Id, folder.FullName, seen);
|
||||||
foreach (var row in Messages) {
|
NotifyFolderCounts();
|
||||||
row.Header.IsSeen = seen;
|
RefreshFolderStats();
|
||||||
row.RefreshMarks();
|
Status = seen ? Copy.FolderMarkedRead : Copy.FolderMarkedUnread;
|
||||||
}
|
if (session is not { IsConnected: true, SupportsFolders: true })
|
||||||
|
return;
|
||||||
|
Result result;
|
||||||
|
try {
|
||||||
|
result = await session.SetFolderSeenAsync(folder.FullName, seen, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
folder.Unread = seen ? 0 : folder.Total;
|
if (result.IsSuccess) {
|
||||||
NotifyFolderCounts();
|
_archive.ClearPendingFlags(box.Id, folder.FullName, Messages.Select(row => row.Header.Id));
|
||||||
RefreshFolderStats();
|
return;
|
||||||
Status = seen ? Copy.FolderMarkedRead : Copy.FolderMarkedUnread;
|
}
|
||||||
});
|
|
||||||
|
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,21 +3606,25 @@ 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(
|
||||||
_archive.UpdateFlags(
|
folder, read, new MailFlagUpdate { Seen = true }, token);
|
||||||
box.Id,
|
if (stored.IsSuccess)
|
||||||
folder,
|
_archive.UpdateFlags(
|
||||||
headers.Where(h => read.Contains(h.Id))
|
box.Id,
|
||||||
.Select(h => (h.Id, true, h.IsFlagged || flagged.Contains(h.Id))));
|
folder,
|
||||||
|
headers.Where(h => read.Contains(h.Id))
|
||||||
|
.Select(h => (h.Id, true, h.IsFlagged || flagged.Contains(h.Id))));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flagged.Count > 0) {
|
if (flagged.Count > 0) {
|
||||||
await session.SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, token);
|
var stored = await session.SetMessageFlagsAsync(
|
||||||
_archive.UpdateFlags(
|
folder, flagged, new MailFlagUpdate { Flagged = true }, token);
|
||||||
box.Id,
|
if (stored.IsSuccess)
|
||||||
folder,
|
_archive.UpdateFlags(
|
||||||
headers.Where(h => flagged.Contains(h.Id))
|
box.Id,
|
||||||
.Select(h => (h.Id, h.IsSeen || read.Contains(h.Id), true)));
|
folder,
|
||||||
|
headers.Where(h => flagged.Contains(h.Id))
|
||||||
|
.Select(h => (h.Id, h.IsSeen || read.Contains(h.Id), true)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3624,23 +3673,16 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
continue;
|
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))
|
||||||
copied.Add(id);
|
continue;
|
||||||
|
await RemoveCopiedUidsAsync(source, sourceSession, sourceFolder, [id], token);
|
||||||
|
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;
|
||||||
folder,
|
try {
|
||||||
[row.Header.Id],
|
result = await session.SetMessageFlagsAsync(
|
||||||
new MailFlagUpdate { Seen = true },
|
folder,
|
||||||
token).ConfigureAwait(true);
|
[row.Header.Id],
|
||||||
if (result.IsSuccess)
|
new MailFlagUpdate { Seen = true },
|
||||||
|
CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.IsSuccess) {
|
||||||
|
if (box is not null)
|
||||||
|
_archive.ClearPendingFlags(box.Id, folder, [row.Header.Id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsSoftFlagFailure(result))
|
||||||
return;
|
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,8 +5087,10 @@ 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))
|
||||||
copied.Add(id);
|
continue;
|
||||||
|
await RemoveCopiedUidsAsync(sourceBox, sourceSession, source, [id], token);
|
||||||
|
copied.Add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copied.Count == 0) {
|
if (copied.Count == 0) {
|
||||||
@ -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,68 +5222,99 @@ 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;
|
||||||
Status = Copy.Connecting;
|
var groupRows = group.ToList();
|
||||||
return;
|
var previous = groupRows
|
||||||
|
.Select(row => (row, row.Header.IsSeen, row.Header.IsFlagged, row.Header.Priority))
|
||||||
|
.ToList();
|
||||||
|
var remote = session is { SupportsFolders: true };
|
||||||
|
var pending = box is not null && UsesImap(box) && !update.Deleted;
|
||||||
|
if (!remote && box is null) {
|
||||||
|
Status = Copy.Connecting;
|
||||||
|
ok = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteUiMailChange();
|
||||||
|
if (update.Seen is { } seen && SelectedFolder is { } current
|
||||||
|
&& current.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||||
|
ApplyUnreadDelta(current, groupRows, seen);
|
||||||
|
if (apply is not null) {
|
||||||
|
foreach (var row in groupRows) {
|
||||||
|
apply(row);
|
||||||
|
row.RefreshMarks();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
NoteUiMailChange();
|
if (box is not null && apply is not null)
|
||||||
var folder = group.Key.Folder;
|
_archive.SetLocalFlags(
|
||||||
var groupRows = group.ToList();
|
box.Id,
|
||||||
var previous = groupRows.Select(row => (row, row.Header.IsSeen, row.Header.IsFlagged)).ToList();
|
folder,
|
||||||
if (update.Seen is { } seen && SelectedFolder is { } current
|
groupRows.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged)),
|
||||||
&& current.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
pending);
|
||||||
ApplyUnreadDelta(current, groupRows, seen);
|
NotifyFolderCounts();
|
||||||
if (apply is not null) {
|
RefreshFolderStats();
|
||||||
foreach (var row in groupRows) {
|
if (!remote || session is null) {
|
||||||
apply(row);
|
if (apply is not null)
|
||||||
row.RefreshMarks();
|
continue;
|
||||||
}
|
Status = Copy.Connecting;
|
||||||
|
ok = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (box is not null)
|
Result result;
|
||||||
_archive.UpdateFlags(
|
try {
|
||||||
box.Id,
|
result = await session.SetMessageFlagsAsync(
|
||||||
folder,
|
|
||||||
groupRows.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged)));
|
|
||||||
}
|
|
||||||
|
|
||||||
NotifyFolderCounts();
|
|
||||||
RefreshFolderStats();
|
|
||||||
var result = await session.SetMessageFlagsAsync(
|
|
||||||
folder,
|
folder,
|
||||||
TargetIds(groupRows),
|
TargetIds(groupRows),
|
||||||
update,
|
update,
|
||||||
token).ConfigureAwait(true);
|
CancellationToken.None).ConfigureAwait(true);
|
||||||
if (result.IsSuccess)
|
}
|
||||||
continue;
|
catch (OperationCanceledException) {
|
||||||
foreach (var item in previous) {
|
continue;
|
||||||
item.row.Header.IsSeen = item.IsSeen;
|
|
||||||
item.row.Header.IsFlagged = item.IsFlagged;
|
|
||||||
item.row.RefreshMarks();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (box is not null)
|
|
||||||
_archive.UpdateFlags(
|
|
||||||
box.Id,
|
|
||||||
folder,
|
|
||||||
previous.Select(item => (item.row.Header.Id, item.IsSeen, item.IsFlagged)));
|
|
||||||
if (SelectedFolder is { } open
|
|
||||||
&& open.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
|
||||||
ApplyOpenedFolderCounts();
|
|
||||||
NotifyFolderCounts();
|
|
||||||
RefreshFolderStats();
|
|
||||||
Status = string.Join(" ", result.Messages);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.IsSuccess) {
|
||||||
|
if (box is not null && pending)
|
||||||
|
_archive.ClearPendingFlags(box.Id, folder, TargetIds(groupRows));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsSoftFlagFailure(result) || update.Deleted) {
|
||||||
|
if (!result.IsSuccess && update.Deleted)
|
||||||
|
ok = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in previous) {
|
||||||
|
item.row.Header.IsSeen = item.IsSeen;
|
||||||
|
item.row.Header.IsFlagged = item.IsFlagged;
|
||||||
|
item.row.Header.Priority = item.Priority;
|
||||||
|
item.row.RefreshMarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (box is not null && apply is not null)
|
||||||
|
_archive.SetLocalFlags(
|
||||||
|
box.Id,
|
||||||
|
folder,
|
||||||
|
previous.Select(item => (item.row.Header.Id, item.IsSeen, item.IsFlagged)),
|
||||||
|
pending: false);
|
||||||
|
if (SelectedFolder is { } open
|
||||||
|
&& open.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||||
|
ApplyOpenedFolderCounts();
|
||||||
|
NotifyFolderCounts();
|
||||||
|
RefreshFolderStats();
|
||||||
|
Status = string.Join(" ", result.Messages);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok)
|
||||||
Status = "Updated.";
|
Status = "Updated.";
|
||||||
ok = true;
|
|
||||||
});
|
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user