From e1f65e0a1881c7d4ee0556ab9b5c45223dd5abda Mon Sep 17 00:00:00 2001
From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com>
Date: Sun, 13 Sep 2026 20:32:44 +0000
Subject: [PATCH] Automatically recover stale mail action IDs and retry once
---
docs/mcp-content-actions.md | 11 +-
src/BetterMail.App/MainWindow.axaml | 2 +
.../MainWindowViewModel.Actions.cs | 41 ++++++-
src/BetterMail.App/MainWindowViewModel.cs | 1 +
.../McpMailTools.Capabilities.cs | 2 +-
.../McpMailTools.MailActions.cs | 11 ++
.../EncryptedMailStore.ActionRecovery.cs | 69 +++++++++++
src/BetterMail.Core/MailAction.cs | 9 +-
src/BetterMail.Core/MailActionDiagnostics.cs | 59 +++++++++-
.../EncryptedMailStoreTests.cs | 110 +++++++++++++++++-
.../MainWindowViewModelTests.cs | 63 ++++++++++
tests/BetterMail.Tests/McpContentTests.cs | 2 +
tests/BetterMail.Tests/McpTests.cs | 2 +-
13 files changed, 371 insertions(+), 11 deletions(-)
create mode 100644 src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs
diff --git a/docs/mcp-content-actions.md b/docs/mcp-content-actions.md
index 64dbbe6..f92012b 100644
--- a/docs/mcp-content-actions.md
+++ b/docs/mcp-content-actions.md
@@ -43,5 +43,12 @@ that requires the exact Internet Message-ID within the same mailbox. A missing o
is not evidence of deletion or delivery. `retry_mail_action` explicitly authorizes another attempt
without erasing history; a subsequent failure pauses again. Fix the reported cause first. Unconfirmed
sends cannot be retried through this tool. Earlier pending actions for the same message must be resolved
-first. A changed server ID is reported, not automatically rebound; verify and perform the intended
-operation in the provider before cancelling the obsolete local action.
+first. `recover_mail_action` rechecks a unique same-mailbox Internet Message-ID match, fetches the current
+message, then atomically repairs the queued ID and authorizes a retry. A move already at its
+destination is confirmed without repeating it. Changed queue state, ambiguous searches and
+unconfirmed sends are never repaired. The app exposes the same operation as **Recover and retry**.
+
+Missing-object failures on moves/state actions automatically attempt identity recovery once per
+queued action, including legacy paused actions. A verified recovery is retried in the same Busy
+processing pass. The attempt marker survives restart; uncertainty or failure of the repaired
+attempt leaves it paused with details. Sends and drafts are excluded from automatic recovery.
diff --git a/src/BetterMail.App/MainWindow.axaml b/src/BetterMail.App/MainWindow.axaml
index 928c1a4..2a5bd7b 100644
--- a/src/BetterMail.App/MainWindow.axaml
+++ b/src/BetterMail.App/MainWindow.axaml
@@ -840,10 +840,12 @@
+
+
_busyChecks = [];
+ public AsyncCommand RecoverBusyActionCommand { get; }
+
+ private async Task RecoverBusyActionAsync(MailAction action)
+ {
+ if (_store is null || _provider is null) return;
+ _busyChecks[action.Id] = "Verifying message identity…";
+ await RefreshBusyActionsAsync();
+ try
+ {
+ var account = Accounts.Single(account => account.AccountId == action.AccountId);
+ var mailbox = Mailboxes.Single(mailbox => mailbox.Id == action.MailboxId);
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ Status = await new MailActionDiagnostics(_store, _provider).RecoverAsync(account, mailbox, action.Id, timeout.Token);
+ _busyChecks[action.Id] = Status;
+ await RefreshBusyActionsAsync();
+ await LoadMessagesAsync();
+ _ = SyncAsync();
+ }
+ catch (Exception error) { _busyChecks[action.Id] = error.Message; await RefreshBusyActionsAsync(); }
+ }
+
public AsyncCommand RetryBusyActionCommand { get; }
public AsyncCommand CheckBusyActionCommand { get; }
@@ -132,12 +153,20 @@ private async Task ProcessMailActionsAsync()
}
var blocked = new HashSet<(string Mailbox, string Item)>();
var batch = await _store.GetMailActionsAsync();
- foreach (var pending in batch.Where(static action => action.Kind != MailActionKind.Send))
+ var queue = new Queue(batch.Where(static action => action.Kind != MailActionKind.Send));
+ while (queue.TryDequeue(out var pending))
{
if (blocked.Contains((pending.MailboxId, pending.ItemId))) continue;
var account = Accounts.FirstOrDefault(account => account.AccountId == pending.AccountId);
var mailbox = Mailboxes.FirstOrDefault(mailbox => mailbox.Id == pending.MailboxId && mailbox.AccountId == pending.AccountId);
if (account is null || mailbox is null) continue;
+ // Also recover legacy paused items once, without requiring a manual Retry.
+ if (pending.CanAutomaticallyRecover)
+ {
+ using var recoveryTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ await new MailActionDiagnostics(_store, _provider).TryAutomaticRecoveryAsync(account, mailbox, pending.Id, recoveryTimeout.Token);
+ await RefreshBusyActionsAsync();
+ }
var action = await _store.StartMailActionAsync(pending.Id);
if (action is null) continue;
await RefreshBusyActionsAsync();
@@ -190,6 +219,16 @@ private async Task ProcessMailActionsAsync()
{
await _store.FailMailActionAsync(action.Id, exception.Message);
blocked.Add((action.MailboxId, action.ItemId));
+ var failed = await _store.GetMailActionAsync(action.Id);
+ if (failed is { CanAutomaticallyRecover: true })
+ {
+ using var recoveryTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ if (await new MailActionDiagnostics(_store, _provider).TryAutomaticRecoveryAsync(account, mailbox, action.Id, recoveryTimeout.Token))
+ {
+ blocked.Remove((action.MailboxId, action.ItemId));
+ if (await _store.GetMailActionAsync(action.Id) is { Accepted: false } repaired) queue.Enqueue(repaired);
+ }
+ }
// A failed action remains pending at its intended destination. Do not
// reinsert it in the source list; Busy provides failure/recovery details.
}
diff --git a/src/BetterMail.App/MainWindowViewModel.cs b/src/BetterMail.App/MainWindowViewModel.cs
index ca3e56c..47e1bf0 100644
--- a/src/BetterMail.App/MainWindowViewModel.cs
+++ b/src/BetterMail.App/MainWindowViewModel.cs
@@ -215,6 +215,7 @@ public MainWindowViewModel(
ShowPinnedCommand = new AsyncCommand(() => ShowUnifiedFilterAsync(MailMessageFilter.Pinned));
ShowFlaggedCommand = new AsyncCommand(() => ShowUnifiedFilterAsync(MailMessageFilter.Flagged));
ShowDraftsCommand = new AsyncCommand(ShowDraftsAsync);
+ RecoverBusyActionCommand = new AsyncCommand(RecoverBusyActionAsync, static action => action.CanRecover);
RetryBusyActionCommand = new AsyncCommand(RetryBusyActionAsync, static action => action.CanRetry);
CheckBusyActionCommand = new AsyncCommand(CheckBusyActionAsync, static action => !action.Running);
CancelBusyActionCommand = new AsyncCommand(CancelBusyActionAsync, static action => action.CanCancel);
diff --git a/src/BetterMail.App/McpMailTools.Capabilities.cs b/src/BetterMail.App/McpMailTools.Capabilities.cs
index 1f768ad..0f60868 100644
--- a/src/BetterMail.App/McpMailTools.Capabilities.cs
+++ b/src/BetterMail.App/McpMailTools.Capabilities.cs
@@ -39,7 +39,7 @@ public object GetActionGuide(string topic = "all")
permissions = "allowWrites controls content changes; allowSending additionally controls mail sending and calendar invitations/updates/cancellations. Calendar, contacts, tasks and notes require an explicitly enabled allowedWorkspaceAccounts accountKey. Mail operations require an enabled mailboxId. Drive operations independently require an allowedDriveAccounts accountKey. These settings do not replace user authorization to send or publicly share content.",
replies = "A new message is not a reply. Use create_reply_draft or create_forward_draft for the corresponding action; the general form is create_response_draft with mailboxId, source messageId and explicit kind (Reply, ReplyAll, Forward), To, Cc and Bcc. Read read_mail/read_mail_headers first, honor Reply-To and user recipient restrictions. Empty Cc/Bcc means none; no recipients are inferred. The provider creates a saved response draft; upload attachments to its returned id, read_draft, then send_draft only when authorized. The client must read actual local file bytes; a Windows path cannot be used by a Linux/remote client or passed to BetterMail as an upload. If the client cannot access those bytes, request the file through that client's supported file mechanism.",
workspaces = "Use list_workspace_accounts first. Calendar: list_calendars → list_events → create/update/delete_event, or create_event_from_mail using an independently allowed source mailbox. People: search_contacts → create/update/delete_contact; optional mailboxId selects an allowed shared address book. Tasks: list_task_lists → list_tasks; create/rename/delete_task_list and create/update/complete/delete tasks. Notes: list_notebooks → list_note_sections → list_note_pages → read/create/update/delete_note_page. Remote mutations can have uncertain outcomes; inspect state before retrying. Workspace reads use provider APIs and require connectivity; cache/UI refresh happens through normal background sync.",
- mailActions = "set_mail_state changes read/flag/pin explicitly. move_mail to a well-known folder handles archive/delete/junk/not-junk; list_folders supplies IDs. Repeat scoped calls for multi-selection. list_drafts/read_draft includes sync issue metadata; delete_draft removes unwanted drafts. list_busy/get_action includes failure details and pause status; three failures pause automatic attempts. check_mail_action investigates server state without mutations; retry_mail_action explicitly retries after fixing the cause, preserving history. Unconfirmed sends cannot be retried. cancel_mail_action attempts cancellation before execution; sync_mail leaves paused actions paused. Existing attachment read/export tools and Drive upload tools can be composed to save attachments to Drive.",
+ mailActions = "set_mail_state changes read/flag/pin explicitly. move_mail to a well-known folder handles archive/delete/junk/not-junk; list_folders supplies IDs. Repeat scoped calls for multi-selection. list_drafts/read_draft includes sync issue metadata; delete_draft removes unwanted drafts. list_busy/get_action includes failure details and pause status; three failures pause automatic attempts. check_mail_action investigates server state without mutations; recover_mail_action rechecks exact identity and repairs stale move/state IDs, rejecting ambiguity and concurrent changes; retry_mail_action explicitly retries after fixing the cause, preserving history. Unconfirmed sends cannot be retried. cancel_mail_action attempts cancellation before execution; sync_mail leaves paused actions paused. Existing attachment read/export tools and Drive upload tools can be composed to save attachments to Drive.",
limitations = "Content operations only: settings, account sign-in and granting MCP access remain in the app. Cross-account mail moves, Google Drive, and creating/deleting OneNote notebooks or sections are not supported by the current app/provider. A registered tool is not a guarantee that every account/provider supports the action. Microsoft OneNote library limits still apply. Read current state before destructive or replacement updates; provider workspace writes generally lack atomic version checks. Do not claim a queued operation was completed remotely; inspect Busy state.",
mailAttachments = new
{
diff --git a/src/BetterMail.App/McpMailTools.MailActions.cs b/src/BetterMail.App/McpMailTools.MailActions.cs
index 1ddffaf..dfffb05 100644
--- a/src/BetterMail.App/McpMailTools.MailActions.cs
+++ b/src/BetterMail.App/McpMailTools.MailActions.cs
@@ -132,5 +132,16 @@ public async Task RetryMailAction(string mailboxId, string actionId)
await refreshAndSync();
return queued;
}
+ [McpServerTool(Name = "recover_mail_action", Destructive = true), Description("Verify a failed move/state action against an exact same-mailbox Internet Message-ID match, repair its stale server ID and queue a retry. If already at the move destination, confirm without repeating it. Preserves history and dependent actions. Ambiguous lookups and concurrently changed actions are rejected. Does not recover sends or drafts. Requires edit permission.")]
+ public async Task RecoverMailAction(string mailboxId, string actionId)
+ {
+ var sender = await SenderAsync(mailboxId, true);
+ var provider = mailProvider?.Invoke() ?? throw new McpException("Mail provider unavailable.");
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ var result = await new MailActionDiagnostics(store, provider).RecoverAsync(sender.Account, sender.Mailbox, actionId,
+ timeout.Token, () => Authorize(mailboxId, true));
+ await refreshAndSync();
+ return result;
+ }
}
diff --git a/src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs b/src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs
new file mode 100644
index 0000000..715a6bc
--- /dev/null
+++ b/src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs
@@ -0,0 +1,69 @@
+using System.Text.Json;
+using Microsoft.Data.Sqlite;
+
+namespace BetterMail.Core;
+
+public sealed partial class EncryptedMailStore
+{
+ public Task ClaimAutomaticRecoveryAsync(string id, CancellationToken token = default) =>
+ WithLockAsync(async connection =>
+ {
+ var actions = await ReadActionsAsync(connection, null, token).ConfigureAwait(false);
+ var action = actions.FirstOrDefault(action => action.Id == id);
+ if (action is not { CanAutomaticallyRecover: true } || actions.TakeWhile(item => item.Id != id).Any(item =>
+ item.MailboxId == action.MailboxId && item.ItemId == action.ItemId && !item.Accepted)) return null;
+ action = action with { AutomaticRecoveryAttempted = true,
+ AutomaticRecoveryDetails = "Automatic recovery was attempted. Check status if it did not complete." };
+ await WriteActionAsync(connection, null, action, token).ConfigureAwait(false);
+ return action;
+ }, token);
+
+ public Task RecordAutomaticRecoveryAsync(string id, string details, CancellationToken token = default) =>
+ WithLockAsync(async connection =>
+ {
+ var action = (await ReadActionsAsync(connection, null, token).ConfigureAwait(false)).FirstOrDefault(action => action.Id == id);
+ if (action is { AutomaticRecoveryAttempted: true })
+ await WriteActionAsync(connection, null, action with { AutomaticRecoveryDetails = details }, token).ConfigureAwait(false);
+ }, token);
+
+ public Task RecoverMailActionAsync(MailAction expected, MailMessage verified, string expectedIdentity,
+ CancellationToken token = default) => WithLockAsync(async connection =>
+ {
+ await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(token).ConfigureAwait(false);
+ var actions = await ReadActionsAsync(connection, transaction, token).ConfigureAwait(false);
+ var current = actions.FirstOrDefault(action => action.Id == expected.Id);
+ // Lookup happens outside the store lock. Reject cancellation, edits or execution since it began.
+ if (current is not { CanRecover: true } || JsonSerializer.Serialize(current) != JsonSerializer.Serialize(expected) ||
+ verified.MailboxId != current.MailboxId || verified.IsDeleted ||
+ string.IsNullOrWhiteSpace(expectedIdentity) || verified.InternetMessageId != expectedIdentity ||
+ string.IsNullOrWhiteSpace(verified.ProviderId) || string.IsNullOrWhiteSpace(verified.FolderId)) return false;
+ var related = actions.Where(action => action.MailboxId == current.MailboxId && action.ItemId == current.ItemId &&
+ action.Kind is MailActionKind.Move or MailActionKind.UpdateState).ToArray();
+ if (related.Any(action => action.Running) || related.TakeWhile(action => action.Id != current.Id).Any(action => !action.Accepted)) return false;
+ var alreadyMoved = current.Kind == MailActionKind.Move && verified.FolderId == current.DestinationId;
+ foreach (var action in related)
+ await WriteActionAsync(connection, transaction, action with
+ {
+ ProviderId = verified.ProviderId,
+ PreviousProviderIds = (action.PreviousProviderIds ?? []).Append(current.ProviderId!).Distinct().ToArray(),
+ SourceFolderId = action.Kind == MailActionKind.Move && !action.Accepted ? verified.FolderId : action.SourceFolderId,
+ SourceWasUnread = action.Kind == MailActionKind.Move && !action.Accepted ? verified.IsUnread : action.SourceWasUnread,
+ Accepted = action.Accepted || action.Id == current.Id && alreadyMoved,
+ RecoveredAtFailureCount = action.Id == current.Id ? action.FailureCount : action.RecoveredAtFailureCount,
+ RetryAuthorizedAtFailureCount = action.Id == current.Id ? action.FailureCount : action.RetryAuthorizedAtFailureCount
+ }, token).ConfigureAwait(false);
+ var desired = related.LastOrDefault(action => action.Kind == MailActionKind.Move && !action.Accepted &&
+ !(action.Id == current.Id && alreadyMoved));
+ await UpsertMessageAsync(connection, transaction, verified with
+ {
+ FolderId = desired?.DestinationId ?? verified.FolderId,
+ IsRead = desired is not null || verified.IsRead
+ }, token).ConfigureAwait(false);
+ foreach (var state in related.Where(action => action.Kind == MailActionKind.UpdateState && !action.Accepted))
+ await SetActionStateAsync(connection, transaction, state with { ProviderId = verified.ProviderId }, false, token).ConfigureAwait(false);
+ if (verified.ProviderId != current.ProviderId)
+ await DeleteMessageAsync(connection, transaction, current.MailboxId, current.ProviderId!, token).ConfigureAwait(false);
+ await transaction.CommitAsync(token).ConfigureAwait(false);
+ return true;
+ }, token);
+}
diff --git a/src/BetterMail.Core/MailAction.cs b/src/BetterMail.Core/MailAction.cs
index 93f8cf6..1cc355f 100644
--- a/src/BetterMail.Core/MailAction.cs
+++ b/src/BetterMail.Core/MailAction.cs
@@ -24,13 +24,18 @@ public sealed record MailAction(
bool? ReadValue = null, bool? FlagValue = null, bool? PinValue = null,
bool? PreviousRead = null, bool? PreviousFlagged = null, bool? PreviousPinned = null, int FailureCount = 0,
int? RetryAuthorizedAtFailureCount = null, DateTimeOffset? LastAttemptAt = null,
- DateTimeOffset? LastFailureAt = null, string? LastError = null)
+ DateTimeOffset? LastFailureAt = null, string? LastError = null,
+ bool AutomaticRecoveryAttempted = false, string? AutomaticRecoveryDetails = null, int? RecoveredAtFailureCount = null)
{
[System.Text.Json.Serialization.JsonIgnore]
public string? StatusCheckDetails { get; init; }
[System.Text.Json.Serialization.JsonIgnore]
public bool HasStatusCheck => StatusCheckDetails is not null;
- public bool IsRetryPaused => !Accepted && !Running && FailureCount >= 3 && RetryAuthorizedAtFailureCount != FailureCount;
+ public bool IsRetryPaused => !Accepted && !Running && (FailureCount >= 3 || (AutomaticRecoveryAttempted || RecoveredAtFailureCount is not null) && FailureCount > (RecoveredAtFailureCount ?? -1)) && RetryAuthorizedAtFailureCount != FailureCount;
+ public bool HasAutomaticRecoveryDetails => !string.IsNullOrEmpty(AutomaticRecoveryDetails);
+ public bool CanAutomaticallyRecover => CanRecover && !AutomaticRecoveryAttempted &&
+ FailureDetails?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true;
+ public bool CanRecover => CanRetry && Kind is MailActionKind.Move or MailActionKind.UpdateState;
public bool CanRetry => !Running && !Accepted && !SendAttempted && FailureCount > 0;
public string? FailureDetails => Error ?? LastError;
public bool HasFailure => FailureCount > 0 || FailureDetails is not null;
diff --git a/src/BetterMail.Core/MailActionDiagnostics.cs b/src/BetterMail.Core/MailActionDiagnostics.cs
index 04f8719..2d6b8fa 100644
--- a/src/BetterMail.Core/MailActionDiagnostics.cs
+++ b/src/BetterMail.Core/MailActionDiagnostics.cs
@@ -2,7 +2,7 @@
namespace BetterMail.Core;
-/// Read-only investigation; a failed lookup never discards mail or authorizes a resend.
+/// Identity checks and guarded recovery; a failed lookup never discards mail or authorizes a resend.
public sealed class MailActionDiagnostics(EncryptedMailStore store, IMailProvider provider)
{
public async Task CheckAsync(MailAccount account, Mailbox mailbox, string actionId, CancellationToken token = default)
@@ -39,10 +39,63 @@ public async Task CheckAsync(MailAccount account, Mailbox mailbox, strin
: "No exact identity match was found in this bounded server search. That does not prove deletion or delivery. Check the provider mailbox and destination; the pending action is kept.";
}
+ public async Task TryAutomaticRecoveryAsync(MailAccount account, Mailbox mailbox, string actionId, CancellationToken token = default)
+ {
+ var candidate = await store.GetMailActionAsync(actionId, token);
+ if (candidate?.AccountId != account.AccountId || candidate.MailboxId != mailbox.Id || mailbox.AccountId != account.AccountId) return false;
+ var claimed = await store.ClaimAutomaticRecoveryAsync(actionId, token);
+ if (claimed is null) return false;
+ try
+ {
+ var result = await RecoverAsync(account, mailbox, actionId, token, snapshot: claimed);
+ await store.RecordAutomaticRecoveryAsync(actionId, "Automatic recovery: " + result, token);
+ return true;
+ }
+ catch (Exception error)
+ {
+ await store.RecordAutomaticRecoveryAsync(actionId, "Automatic recovery could not complete: " + error.Message);
+ return false;
+ }
+ }
+
+ public async Task RecoverAsync(MailAccount account, Mailbox mailbox, string actionId,
+ CancellationToken token = default, Action? authorize = null, MailAction? snapshot = null)
+ {
+ var expected = snapshot ?? await store.GetMailActionAsync(actionId, token)
+ ?? throw new InvalidOperationException("Action is no longer pending.");
+ if (!expected.CanRecover || expected.AccountId != account.AccountId || expected.MailboxId != mailbox.Id || mailbox.AccountId != account.AccountId)
+ throw new InvalidOperationException("Only a failed move or state action in this mailbox can be recovered.");
+ var cached = await store.GetMessageAsync(mailbox.Id, expected.ProviderId!, token);
+ if (string.IsNullOrWhiteSpace(cached?.InternetMessageId))
+ throw new InvalidOperationException("No stable message identity is cached. Recovery cannot use a subject match alone.");
+ MailMessage? verified = null;
+ try { verified = await provider.GetMessageAsync(account, mailbox, expected.ProviderId!, token); }
+ catch (HttpRequestException error) when (error.StatusCode == HttpStatusCode.NotFound) { }
+ if (verified is null)
+ {
+ var results = await provider.SearchMessagesAsync(account, mailbox, cached.Subject, 100, token);
+ var matches = results.Where(message => message.MailboxId == mailbox.Id && !message.IsDeleted && message.InternetMessageId == cached.InternetMessageId)
+ .DistinctBy(message => message.ProviderId).ToArray();
+ if (results.Count >= 100 || matches.Length != 1)
+ throw new InvalidOperationException("No unique exact identity match in the bounded search. Nothing was changed.");
+ verified = await provider.GetMessageAsync(account, mailbox, matches[0].ProviderId, token);
+ if (verified.ProviderId != matches[0].ProviderId)
+ throw new InvalidOperationException("The message changed during lookup. Check status again.");
+ }
+ if (verified.MailboxId != mailbox.Id || verified.IsDeleted || verified.InternetMessageId != cached.InternetMessageId)
+ throw new InvalidOperationException("The server message does not match the cached identity. Nothing was changed.");
+ authorize?.Invoke();
+ if (!await store.RecoverMailActionAsync(expected, verified with { Body = verified.Body ?? cached.Body }, cached.InternetMessageId, token))
+ throw new InvalidOperationException("The queued action changed or an earlier action must finish. Check status again.");
+ return expected.Kind == MailActionKind.Move && verified.FolderId == expected.DestinationId
+ ? "The message is already at the destination. The move was confirmed without moving it again."
+ : "The server ID was verified and repaired. Retry queued with the original destination and failure history kept.";
+ }
+
private static string Describe(MailAction action, MailMessage message) =>
message.FolderId == action.DestinationId && action.Kind == MailActionKind.Move
- ? "The message was found in the requested destination. No further move appears necessary. Verify in your provider before cancelling the obsolete pending action."
+ ? "The message was found in the requested destination. No further move appears necessary. Use Recover and retry to verify and confirm this move without moving it again."
: message.ProviderId != action.ProviderId
- ? "The same message was found under a different server ID. Retrying the old ID will still fail. Locate this message in your provider to perform the intended action, then cancel the obsolete pending action. No mail was deleted or changed by this check."
+ ? "The same message was found under a different server ID. Retrying the old ID will still fail. Use Recover and retry to verify its identity again and repair the queued action. No mail was deleted or changed by this check."
: "The message is still available under its saved server ID. Check that the destination exists and the account has permission, then retry. No action was changed by this check.";
}
diff --git a/tests/BetterMail.Tests/EncryptedMailStoreTests.cs b/tests/BetterMail.Tests/EncryptedMailStoreTests.cs
index 7e183ce..58bf3c3 100644
--- a/tests/BetterMail.Tests/EncryptedMailStoreTests.cs
+++ b/tests/BetterMail.Tests/EncryptedMailStoreTests.cs
@@ -43,10 +43,12 @@ await WithStoreAsync(async (store, token) =>
public class BusyDiagnosticProvider : System.Reflection.DispatchProxy
{
public MailMessage Result = null!;
+ public IReadOnlyList? Results;
protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args) => method!.Name switch
{
+ "GetMessageAsync" when (string)args![2]! == Result.ProviderId => Task.FromResult(Result),
"GetMessageAsync" => Task.FromException(new HttpRequestException("Missing", null, System.Net.HttpStatusCode.NotFound)),
- "SearchMessagesAsync" => Task.FromResult>([Result]),
+ "SearchMessagesAsync" => Task.FromResult>(Results ?? [Result]),
_ => throw new InvalidOperationException("Status check must not mutate the provider: " + method.Name)
};
}
@@ -82,6 +84,112 @@ Task Queue(bool value) => store.QueueMessageStateAsync(account, mess
});
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task RecoveryRemapsQueuedChainAndRecognizesAlreadyMovedMail(bool alreadyMoved)
+ {
+ await WithStoreAsync(async (store, token) =>
+ {
+ var account = new MailAccount("microsoft365", "account", "tenant", "alex@example.test", "Alex", ProviderCapabilities.Mail);
+ var mailbox = new Mailbox(account.AccountId, account.EmailAddress, account.DisplayName);
+ var message = Message(mailbox.Id, "old", "Subject", "Full body") with { InternetMessageId = "" };
+ await store.ApplySyncPageAsync("seed", new([message], null, false), token);
+ var first = await store.QueueMoveAsync(account, message, new(mailbox.Id, "archive", "Archive", 0, 0), token);
+ await store.StartMailActionAsync(first.Id, token);
+ var later = await store.QueueMoveAsync(account, message with { FolderId = "archive" }, new(mailbox.Id, "done", "Done", 0, 0), token);
+ await store.FailMailActionAsync(first.Id, "Missing", token);
+ var provider = System.Reflection.DispatchProxy.Create();
+ ((BusyDiagnosticProvider)(object)provider).Result = message with { ProviderId = "new", FolderId = alreadyMoved ? "archive" : "actual-source" };
+ await new MailActionDiagnostics(store, provider).RecoverAsync(account, mailbox, first.Id, token);
+ Assert.Null(await store.GetMessageAsync(mailbox.Id, "old", token));
+ Assert.Equal("Full body", (await store.GetMessageAsync(mailbox.Id, "new", token))!.Body);
+ Assert.Equal("done", (await store.GetMessageAsync(mailbox.Id, "new", token))!.FolderId);
+ var repaired = (await store.GetMailActionAsync(first.Id, token))!;
+ Assert.Equal(alreadyMoved, repaired.Accepted);
+ Assert.Equal(1, repaired.FailureCount);
+ Assert.Equal("new", repaired.ProviderId);
+ Assert.Contains("old", repaired.PreviousProviderIds!);
+ Assert.Equal("new", (await store.GetMailActionAsync(later.Id, token))!.ProviderId);
+ // A late sync for the obsolete ID must not resurrect the old row.
+ await store.ApplySyncPageAsync("stale", new([message], null, false), token);
+ Assert.Null(await store.GetMessageAsync(mailbox.Id, "old", token));
+ if (alreadyMoved) Assert.NotNull(await store.StartMailActionAsync(later.Id, token));
+ else
+ {
+ Assert.Null(await store.StartMailActionAsync(later.Id, token));
+ Assert.True(await store.CancelMailActionAsync(later.Id, token));
+ Assert.True(await store.CancelMailActionAsync(first.Id, token));
+ Assert.Equal("actual-source", (await store.GetMessageAsync(mailbox.Id, "new", token))!.FolderId);
+ }
+ });
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task AutomaticRecoveryIsClaimedOnceAndFailedRepairedAttemptPauses(bool ambiguous)
+ {
+ await WithStoreAsync(async (store, token) =>
+ {
+ var account = new MailAccount("microsoft365", "account", "tenant", "alex@example.test", "Alex", ProviderCapabilities.Mail);
+ var mailbox = new Mailbox(account.AccountId, account.EmailAddress, account.DisplayName);
+ var message = Message(mailbox.Id, "old", "Subject", "Body") with { InternetMessageId = "" };
+ await store.ApplySyncPageAsync("seed", new([message], null, false), token);
+ var action = await store.QueueMoveAsync(account, message, new(mailbox.Id, "archive", "Archive", 0, 0), token);
+ await store.StartMailActionAsync(action.Id, token);
+ await store.FailMailActionAsync(action.Id, "The specified object was not found in the store.", token);
+ var provider = System.Reflection.DispatchProxy.Create();
+ ((BusyDiagnosticProvider)(object)provider).Result = message with { ProviderId = "new" };
+ if (ambiguous)
+ {
+ var fake = (BusyDiagnosticProvider)(object)provider;
+ fake.Results = [fake.Result, fake.Result with { ProviderId = "another" }];
+ }
+ var service = new MailActionDiagnostics(store, provider);
+ Assert.Equal(!ambiguous, await service.TryAutomaticRecoveryAsync(account, mailbox, action.Id, token));
+ Assert.False(await service.TryAutomaticRecoveryAsync(account, mailbox, action.Id, token));
+ if (ambiguous)
+ {
+ Assert.Null(await store.StartMailActionAsync(action.Id, token));
+ Assert.Contains("No unique exact identity", (await store.GetMailActionAsync(action.Id, token))!.AutomaticRecoveryDetails!);
+ return;
+ }
+ var attempt = await store.StartMailActionAsync(action.Id, token);
+ Assert.Equal("new", attempt!.ProviderId);
+ await store.FailMailActionAsync(action.Id, "Still not found", token);
+ Assert.Null(await store.StartMailActionAsync(action.Id, token));
+ Assert.False(await service.TryAutomaticRecoveryAsync(account, mailbox, action.Id, token));
+ Assert.True((await store.GetMailActionAsync(action.Id, token))!.IsRetryPaused);
+ });
+ }
+
+ [Fact]
+ public async Task RecoveryRejectsEditedActionAndAmbiguousServerMatches()
+ {
+ await WithStoreAsync(async (store, token) =>
+ {
+ var account = new MailAccount("microsoft365", "account", "tenant", "alex@example.test", "Alex", ProviderCapabilities.Mail);
+ var mailbox = new Mailbox(account.AccountId, account.EmailAddress, account.DisplayName);
+ var message = Message(mailbox.Id, "old", "Subject", "Body") with { InternetMessageId = "" };
+ await store.ApplySyncPageAsync("seed", new([message], null, false), token);
+ var action = await store.QueueMoveAsync(account, message, new(mailbox.Id, "archive", "Archive", 0, 0), token);
+ await store.StartMailActionAsync(action.Id, token);
+ await store.FailMailActionAsync(action.Id, "Missing", token);
+ var expected = (await store.GetMailActionAsync(action.Id, token))!;
+ var verified = message with { ProviderId = "new" };
+ var provider = System.Reflection.DispatchProxy.Create();
+ var fake = (BusyDiagnosticProvider)(object)provider;
+ fake.Result = verified;
+ fake.Results = [verified, verified with { ProviderId = "copy" }];
+ await Assert.ThrowsAsync(() => new MailActionDiagnostics(store, provider).RecoverAsync(account, mailbox, action.Id, token));
+ Assert.Equal("old", (await store.GetMailActionAsync(action.Id, token))!.ProviderId);
+ await store.QueueMoveAsync(account, message, new(mailbox.Id, "different", "Different", 0, 0), token);
+ Assert.False(await store.RecoverMailActionAsync(expected, verified, message.InternetMessageId, token));
+ Assert.Null(await store.GetMessageAsync(mailbox.Id, "new", token));
+ });
+ }
+
[Fact]
public async Task PausedMoveLivesInDestinationAcrossSyncRestartAndCancellation()
{
diff --git a/tests/BetterMail.Tests/MainWindowViewModelTests.cs b/tests/BetterMail.Tests/MainWindowViewModelTests.cs
index 9440ddf..cdc5507 100644
--- a/tests/BetterMail.Tests/MainWindowViewModelTests.cs
+++ b/tests/BetterMail.Tests/MainWindowViewModelTests.cs
@@ -6,6 +6,69 @@ namespace BetterMail.Tests;
public sealed class MainWindowViewModelTests
{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task BusyProcessorAutomaticallyRepairsAndRetriesMissingMessageOnce(bool retryFails)
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-auto-recover-" + Guid.NewGuid());
+ var token = TestContext.Current.CancellationToken;
+ try
+ {
+ var path = Path.Combine(directory, "mail.db");
+ var key = new string('A', 64);
+ await using var store = new EncryptedMailStore(path, key);
+ await store.InitializeAsync(token);
+ var account = new MailAccount("microsoft365", "account", "tenant", "alex@example.test", "Alex", ProviderCapabilities.Mail);
+ var mailbox = new Mailbox(account.AccountId, account.EmailAddress, account.DisplayName);
+ var original = Message(mailbox.Id, "inbox", "Example", "Body") with { ProviderId = "old", InternetMessageId = "", IsRead = true };
+ await store.ApplySyncPageAsync("seed", new([original], null, false), token);
+ var action = await store.QueueMoveAsync(account, original, new(mailbox.Id, "archive", "Archive", 0, 0), token);
+ var provider = System.Reflection.DispatchProxy.Create();
+ var fake = (RecoveryMailProvider)(object)provider;
+ fake.Message = original with { ProviderId = "new" };
+ fake.RetryFails = retryFails;
+ var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null, provider);
+ vm.Accounts.Add(account); vm.Mailboxes.Add(mailbox);
+ await (Task)typeof(MainWindowViewModel).GetMethod("ProcessMailActionsAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.Invoke(vm, null)!;
+ Assert.Equal(new[] { "old", "new" }, fake.Moves);
+ var recovered = (await store.GetMailActionAsync(action.Id, token))!;
+ Assert.True(recovered.AutomaticRecoveryAttempted);
+ Assert.Equal(!retryFails, recovered.Accepted);
+ Assert.Equal(retryFails, recovered.IsRetryPaused);
+ await using var reopened = new EncryptedMailStore(path, key);
+ await reopened.InitializeAsync(token);
+ Assert.Null(await reopened.ClaimAutomaticRecoveryAsync(action.Id, token));
+ if (retryFails) Assert.Null(await reopened.StartMailActionAsync(action.Id, token));
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
+ public class RecoveryMailProvider : System.Reflection.DispatchProxy
+ {
+ public MailMessage Message = null!;
+ public bool RetryFails;
+ public List Moves = [];
+ protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args)
+ {
+ if (method!.Name == "MoveMessageWithResultAsync")
+ {
+ var id = (string)args![2]!;
+ Moves.Add(id);
+ return id == "old" || RetryFails
+ ? Task.FromException<(string, string)>(new HttpRequestException("Object not found", null, System.Net.HttpStatusCode.NotFound))
+ : Task.FromResult((id, "archive"));
+ }
+ return method.Name switch
+ {
+ "GetMessageAsync" => (string)args![2]! == "new" ? Task.FromResult(Message)
+ : Task.FromException(new HttpRequestException("Object not found", null, System.Net.HttpStatusCode.NotFound)),
+ "SearchMessagesAsync" => Task.FromResult>([Message]),
+ _ => throw new InvalidOperationException("Unexpected provider operation: " + method.Name)
+ };
+ }
+ }
+
[Fact]
public async Task PrimaryAndSharedMailboxesCanInterleaveAndPersistCollapseDefaults()
{
diff --git a/tests/BetterMail.Tests/McpContentTests.cs b/tests/BetterMail.Tests/McpContentTests.cs
index 6ae6a9c..1d72b59 100644
--- a/tests/BetterMail.Tests/McpContentTests.cs
+++ b/tests/BetterMail.Tests/McpContentTests.cs
@@ -24,8 +24,10 @@ await WithTools(async (store, tools, fake, account, mailbox, setSettings) =>
await store.FailMailActionAsync(action.Id, "Missing");
}
await Assert.ThrowsAsync(() => tools.RetryMailAction(mailbox.Id, action.Id));
+ await Assert.ThrowsAsync(() => tools.RecoverMailAction(mailbox.Id, action.Id));
setSettings(new(Enabled: true, AllowWrites: true, MailboxIds: [mailbox.Id]));
await Assert.ThrowsAsync(() => tools.RetryMailAction("other", action.Id));
+ await Assert.ThrowsAsync(() => tools.RecoverMailAction("other", action.Id));
Assert.True(await tools.RetryMailAction(mailbox.Id, action.Id));
var pending = (await store.GetMailActionAsync(action.Id))!;
Assert.Equal(3, pending.FailureCount);
diff --git a/tests/BetterMail.Tests/McpTests.cs b/tests/BetterMail.Tests/McpTests.cs
index 7221308..501c0a5 100644
--- a/tests/BetterMail.Tests/McpTests.cs
+++ b/tests/BetterMail.Tests/McpTests.cs
@@ -320,7 +320,7 @@ async Task Status(string? bearer = null, string? host = null, st
Assert.Contains(available, tool => tool.Name == "send_draft");
foreach (var name in new[] { "begin_attachment_upload", "upload_attachment_chunk", "complete_attachment_upload", "update_draft", "get_drive_upload", "move_drive_item", "download_drive_file", "complete_drive_upload", "share_drive_file" })
Assert.Contains(available, tool => tool.Name == name);
- foreach (var name in new[] { "get_action_guide", "create_response_draft", "create_reply_draft", "create_forward_draft", "search_drive", "set_mail_state", "read_mail_headers", "cancel_mail_action", "search_discovered_people", "list_workspace_accounts", "list_calendars", "list_events", "create_event", "create_event_from_mail", "update_event", "delete_event", "search_contacts", "create_contact", "update_contact", "delete_contact", "list_task_lists", "list_tasks", "create_task", "update_task", "set_task_completed", "delete_task", "create_task_list", "rename_task_list", "delete_task_list", "list_notebooks", "list_note_sections", "list_note_pages", "read_note_page", "create_note_page", "update_note_page", "delete_note_page" })
+ foreach (var name in new[] { "get_action_guide", "create_response_draft", "create_reply_draft", "create_forward_draft", "search_drive", "set_mail_state", "read_mail_headers", "cancel_mail_action", "recover_mail_action", "search_discovered_people", "list_workspace_accounts", "list_calendars", "list_events", "create_event", "create_event_from_mail", "update_event", "delete_event", "search_contacts", "create_contact", "update_contact", "delete_contact", "list_task_lists", "list_tasks", "create_task", "update_task", "set_task_completed", "delete_task", "create_task_list", "rename_task_list", "delete_task_list", "list_notebooks", "list_note_sections", "list_note_pages", "read_note_page", "create_note_page", "update_note_page", "delete_note_page" })
Assert.Contains(available, tool => tool.Name == name);
var guide = await client.CallToolAsync("get_action_guide", new Dictionary { ["topic"] = "mail" }, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(guide.IsError == true);