Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/mcp-content-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions src/BetterMail.App/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -840,10 +840,12 @@
<TextBlock Text="{Binding RetryHistory}" IsVisible="{Binding HasFailure}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
<SelectableTextBlock Text="{Binding FailureDetails}" TextWrapping="Wrap" FontSize="12" />
<TextBlock Text="{Binding RecoveryGuidance}" IsVisible="{Binding HasFailure}" TextWrapping="Wrap" FontSize="12" />
<TextBlock Text="{Binding AutomaticRecoveryDetails}" IsVisible="{Binding HasAutomaticRecoveryDetails}" TextWrapping="Wrap" FontSize="12" />
<SelectableTextBlock Text="{Binding StatusCheckDetails}" IsVisible="{Binding HasStatusCheck}" TextWrapping="Wrap" FontSize="12" FontWeight="SemiBold" />
</StackPanel>
<WrapPanel Grid.Row="4" Grid.ColumnSpan="2">
<Button Content="Check status" Margin="0,0,8,0" Command="{Binding $parent[Window].DataContext.CheckBusyActionCommand}" CommandParameter="{Binding}" />
<Button Content="Recover and retry" Margin="0,0,8,0" IsVisible="{Binding CanRecover}" Command="{Binding $parent[Window].DataContext.RecoverBusyActionCommand}" CommandParameter="{Binding}" />
<Button Content="Retry" Margin="0,0,8,0" IsEnabled="{Binding CanRetry}" Command="{Binding $parent[Window].DataContext.RetryBusyActionCommand}" CommandParameter="{Binding}" />
<Button Content="Cancel" HorizontalAlignment="Right"
Command="{Binding $parent[Window].DataContext.CancelBusyActionCommand}"
Expand Down
41 changes: 40 additions & 1 deletion src/BetterMail.App/MainWindowViewModel.Actions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ namespace BetterMail.App;
public sealed partial class MainWindowViewModel
{
private readonly Dictionary<string, string> _busyChecks = [];
public AsyncCommand<MailAction> 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<MailAction> RetryBusyActionCommand { get; }
public AsyncCommand<MailAction> CheckBusyActionCommand { get; }

Expand Down Expand Up @@ -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<MailAction>(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();
Expand Down Expand Up @@ -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.
}
Expand Down
1 change: 1 addition & 0 deletions src/BetterMail.App/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MailAction>(RecoverBusyActionAsync, static action => action.CanRecover);
RetryBusyActionCommand = new AsyncCommand<MailAction>(RetryBusyActionAsync, static action => action.CanRetry);
CheckBusyActionCommand = new AsyncCommand<MailAction>(CheckBusyActionAsync, static action => !action.Running);
CancelBusyActionCommand = new AsyncCommand<MailAction>(CancelBusyActionAsync, static action => action.CanCancel);
Expand Down
2 changes: 1 addition & 1 deletion src/BetterMail.App/McpMailTools.Capabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
11 changes: 11 additions & 0 deletions src/BetterMail.App/McpMailTools.MailActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,16 @@ public async Task<bool> 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<string> 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;
}

}
69 changes: 69 additions & 0 deletions src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;

namespace BetterMail.Core;

public sealed partial class EncryptedMailStore
{
public Task<MailAction?> ClaimAutomaticRecoveryAsync(string id, CancellationToken token = default) =>
WithLockAsync<MailAction?>(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<bool> 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);
Comment on lines +64 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep obsolete IDs suppressed after state recovery

When a standalone UpdateState action is recovered to a new provider ID, this deletes the old cached row but records no durable tombstone or alias. The repaired state action is deleted by CompleteMessageStateAsync, after which a delayed source-folder page containing the old ID passes through ApplySyncPageAsync and is blindly upserted, leaving both the valid and obsolete copies visible and allowing subsequent actions to target the dead ID again. Preserve enough recovery metadata to reject stale sync records for the replaced ID.

Useful? React with 👍 / 👎.

await transaction.CommitAsync(token).ConfigureAwait(false);
return true;
}, token);
}
9 changes: 7 additions & 2 deletions src/BetterMail.Core/MailAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading