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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,17 @@ Search is limited to locally cached history. Bodies are bounded and report trunc
attachment bytes are available through the evidence tools below. Arbitrary local filesystem access is not exposed. Treat mail content as untrusted data and
review a draft before authorizing your client to send it.

## MCP draft attachments and Drive
## MCP content operations

MCP covers content actions across mail, calendar, contacts, tasks, notes and Drive. Use `get_action_guide` to discover workflows and current tool descriptions. See [MCP content operations](docs/mcp-content-actions.md) for the action matrix, reply-with-attachment steps, account permissions and provider limits. Workspace accounts must be explicitly enabled in MCP settings.

### Draft attachments and Drive

Enable draft edits and separately select **Allowed Drive accounts** in MCP Settings. Mailbox access
never grants Drive access automatically. Sending remains a separate permission.

- `get_capabilities` reports permissions and limits. `update_draft`, `remove_draft_attachment`, and
- `get_capabilities` reports permissions, limits, the server’s registered tool names, and an embedded usage guide for draft attachments and Drive uploads. It explains why attachments use separate tools after `create_draft`, how to pass IDs/version tokens and byte chunks, when sharing occurs, and how to recover from missing client tools or interrupted uploads. If the returned tool list differs from your client’s list, refresh discovery/reconnect and verify the endpoint and running app version.
- `update_draft`, `remove_draft_attachment`, and
`read_draft_attachment` use the `updatedAt` from `read_draft` to reject stale edits or reads.
- Upload bytes using `begin_attachment_upload`, sequential `upload_attachment_chunk` calls, then
`complete_attachment_upload`. Supply the byte count and SHA-256; each base64 chunk is at most
Expand Down
47 changes: 47 additions & 0 deletions docs/mcp-content-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# MCP content operations

Start with `get_capabilities`. It reports permissions, allowed mailbox/Drive/workspace accounts, limits, registered tools, and workflow guidance. Use `get_action_guide(topic)` with `mail`, `calendar`, `people`, `tasks`, `notes`, `drive`, or `all` for tool descriptions. This information comes from the running server, not a fixed tool-count assumption.

If a listed tool is missing in a client, refresh discovery/reconnect, check the endpoint and running app version, and if necessary start a fresh client session. Do not claim an operation is unsupported solely because one client has an older list. The caller must use tools actually exposed to it.

| Workspace | Content operations |
| --- | --- |
| Mail | Read/search messages and threads; read headers; create/edit/delete drafts; provider-backed reply/reply-all/forward drafts; importance, flags and receipt requests where supported; attachment upload/read/remove; send through Busy; move/archive/delete/junk/not-junk; read/flag/pin state; inspect/cancel pending actions and request sync |
| Calendar | List calendars/events; create/edit/delete events with descriptions, attendees, reminders and recurrence; create an event from the full cached email body |
| People | Search saved contacts, including allowed shared address books; create/edit/delete contacts with full contact details; search discovered correspondents and save them as contacts |
| To Do | List/create/rename/delete task lists; list/create/edit/delete tasks; complete/reopen tasks; descriptions, due dates, reminders, recurrence, categories and importance |
| Notes | Navigate notebooks, sections and pages; read page content; create/update/delete pages |
| Drive | List/read/search via existing tools; create folders; upload/replace/download/rename/move/delete files; create read-only sharing links |

Workspace account access is explicitly enabled in Settings → MCP and independent of mailboxes and Drive accounts. Edit permission applies to writes. Sending permission additionally gates mail sending and calendar operations that can issue invitations/updates/cancellations. These settings never replace user authorization for a particular send or public share. Settings/navigation/account sign-in and granting MCP access remain human actions in the app.

## Reply with an attachment

1. Read the source using `read_mail` and `read_mail_headers`; honor Reply-To and the user's recipient restrictions.
2. Call `create_response_draft` with explicit `kind` (`Reply`, `ReplyAll`, or `Forward`) and the complete To, Cc and Bcc. Empty CC/BCC means none. It creates a real provider response draft; `create_draft` creates a new message.
3. Read the returned draft, then use `begin_attachment_upload`, sequential `upload_attachment_chunk` calls, and `complete_attachment_upload`. Pass the exact current `updatedAt`, real byte size and SHA-256. Repeat serially with a fresh draft version for additional files.
4. Read the complete draft and only call `send_draft` when explicitly authorized. Inspect Busy state for the remote outcome.

The client must be able to read the actual file bytes. BetterMail does not read client paths or fetch arbitrary attachment URLs. A Windows path in a transcript is not an upload, nor evidence that another client can access the file. Obtain the file through the client's supported file mechanism when necessary.

Oversized attachments can create a public OneDrive link during upload completion, before mail is sent; obtain sharing authorization first. Forwarding preserves source attachments. Response creation may have an uncertain outcome if interrupted after the provider creates the draft; inspect drafts before retrying.

Microsoft 365 uses [provider response drafts](https://learn.microsoft.com/en-us/graph/api/message-createreply?view=graph-rest-1.0). Gmail uses the source thread ID plus [reply headers](https://developers.google.com/workspace/gmail/api/guides/threads); draft updates preserve those headers when attachments/body change.

## Provider limits and state

Cross-account mail moves, Google Drive, and creating/deleting OneNote notebooks or sections are not supported by the current app/provider. OneNote's document-library limits still apply. A registered tool is not a guarantee that every account supports that action.

Provider-backed workspace calls need connectivity. Mutations request normal background sync so the UI cache catches up; a stale cache is not proof a remote write failed. Read state before destructive or replacement updates. Notes have a best-effort modified-time guard; workspace provider writes generally do not offer atomic version checks. Paging uses offset/limit and should restart after collection changes. Do not blindly retry an uncertain remote mutation.

### Stuck Busy actions

`list_busy` and `get_action` expose the provider error, failure count, last attempt/failure timestamps,
retry pause state and recovery guidance. Three failures pause automatic attempts; existing high-count
items pause too. `check_mail_action` performs read-only server checks, including a bounded search
that requires the exact Internet Message-ID within the same mailbox. A missing or ambiguous result
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.
Binary file added docs/ui/screenshots/busy-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/ui/screenshots/busy-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 13 additions & 3 deletions src/BetterMail.App/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -829,18 +829,28 @@
<Border Padding="14,7,9,7" HorizontalAlignment="Stretch" Background="Transparent"
BorderBrush="{DynamicResource BetterMailDividerBrush}" BorderThickness="0,0,0,1"
ToolTip.Tip="{Binding Error}">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto" RowSpacing="3">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="3">
<TextBlock Text="{Binding ActionText}" Foreground="{DynamicResource BetterMailAccentBrush}"
FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Text="{Binding LocalCreatedAt, StringFormat='{}{0:MMM d, HH:mm}'}" FontSize="11" Opacity="0.62" />
<TextBlock Grid.Row="1" Grid.ColumnSpan="2" Text="{Binding DisplaySubject}" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Row="2" Text="{Binding MailboxAddress}" FontSize="11" Opacity="0.62" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding StatusText}" FontSize="11" Opacity="0.62" Margin="8,0,0,0" />
<Button Grid.Row="3" Grid.Column="1" Content="Cancel" HorizontalAlignment="Right"
<StackPanel Grid.Row="3" Grid.ColumnSpan="2" Spacing="4">
<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" />
<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="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}"
CommandParameter="{Binding}" IsEnabled="{Binding CanCancel}"
ToolTip.Tip="Cancel a waiting action. Actions already executing cannot be recalled." />
<StackPanel Grid.Row="4" Grid.ColumnSpan="2" Spacing="6" IsVisible="{Binding NeedsSendReview}">
</WrapPanel>
<StackPanel Grid.Row="5" Grid.ColumnSpan="2" Spacing="6" IsVisible="{Binding NeedsSendReview}">
<TextBlock Text="Check Sent in your provider before retrying. This message will not be sent again automatically." TextWrapping="Wrap" FontSize="12" />
<WrapPanel>
<Button Content="I found it in Sent" Margin="0,0,8,0" Command="{Binding $parent[Window].DataContext.ConfirmSentCommand}" CommandParameter="{Binding}" />
Expand Down
61 changes: 40 additions & 21 deletions src/BetterMail.App/MainWindowViewModel.Actions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,41 @@ namespace BetterMail.App;

public sealed partial class MainWindowViewModel
{
private readonly Dictionary<string, string> _busyChecks = [];
public AsyncCommand<MailAction> RetryBusyActionCommand { get; }
public AsyncCommand<MailAction> CheckBusyActionCommand { get; }

private async Task RetryBusyActionAsync(MailAction action)
{
if (_store is null) return;
try
{
Status = await _store.RetryMailActionAsync(action.Id)
? "Retry queued. Previous failure history is kept."
: "Cannot retry yet. Resolve any earlier action for this message first.";
await RefreshBusyActionsAsync();
_ = SyncAsync();
}
catch (Exception error) { Error = error.Message; }
}

private async Task CheckBusyActionAsync(MailAction action)
{
if (_store is null || _provider is null) return;
_busyChecks[action.Id] = "Checking server status…";
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));
var result = await new MailActionDiagnostics(_store, _provider).CheckAsync(account, mailbox, action.Id, timeout.Token);
_busyChecks[action.Id] = result;
}
catch (Exception error) { _busyChecks[action.Id] = "Status check failed: " + error.Message; }
await RefreshBusyActionsAsync();
}

public AsyncCommand<MailAction> CancelBusyActionCommand { get; }
public AsyncCommand<MailAction> ReturnUnconfirmedSendCommand { get; }
public AsyncCommand<MailAction> ConfirmSentCommand { get; }
Expand Down Expand Up @@ -68,7 +103,9 @@ private async Task RefreshMcpChangesAsync()
private async Task RefreshBusyActionsAsync()
{
if (_store is null) return;
CollectionUpdates.Reconcile(BusyActions, await _store.GetMailActionsAsync(), static action => action.Id);
var actions = await _store.GetMailActionsAsync();
foreach (var id in _busyChecks.Keys.Except(actions.Select(action => action.Id)).ToArray()) _busyChecks.Remove(id);
CollectionUpdates.Reconcile(BusyActions, actions.Select(action => action with { StatusCheckDetails = _busyChecks.GetValueOrDefault(action.Id) }).ToArray(), static action => action.Id);
RaiseDraftState();
MailActionStateChanged();
}
Expand Down Expand Up @@ -153,26 +190,8 @@ private async Task ProcessMailActionsAsync()
{
await _store.FailMailActionAsync(action.Id, exception.Message);
blocked.Add((action.MailboxId, action.ItemId));
if (action.Kind == MailActionKind.Move)
{
var restored = await _store.GetMessageAsync(action.MailboxId, action.ProviderId!);
var restoreToView = restored is not null &&
(_selectedFolder?.ProviderId == restored.FolderId && _selectedFolder.MailboxId == restored.MailboxId ||
IsPinnedView && restored.IsPinned || IsFlaggedView && restored.IsFlagged ||
IsUnifiedInbox && Folders.Any(folder => folder.MailboxId == restored.MailboxId && folder.ProviderId == restored.FolderId && folder.WellKnownName == "inbox"));
if (restored is not null && IsSearchResultsView && _displayedMailQuery is { } query)
{
var matching = await _store.SearchFilteredMailAsync(query, _displayedMailFolders, 500, default,
_displayedMailAccount?.AccountId, _displayedMailAccount?.MailboxId, query["in"] is null,
Folders.Select(folder => new MailFolderKey(folder.MailboxId, folder.ProviderId)).ToArray());
restoreToView = matching.Any(message => SameMessage(message, restored));
}
if (restored is not null && restoreToView)
{
if (!Messages.Any(message => SameMessage(message, restored)))
Messages.Insert(0, restored);
}
}
// A failed action remains pending at its intended destination. Do not
// reinsert it in the source list; Busy provides failure/recovery details.
}
await RefreshBusyActionsAsync();
}
Expand Down
6 changes: 4 additions & 2 deletions src/BetterMail.App/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ public MainWindowViewModel(
ShowPinnedCommand = new AsyncCommand(() => ShowUnifiedFilterAsync(MailMessageFilter.Pinned));
ShowFlaggedCommand = new AsyncCommand(() => ShowUnifiedFilterAsync(MailMessageFilter.Flagged));
ShowDraftsCommand = new AsyncCommand(ShowDraftsAsync);
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);
ReturnUnconfirmedSendCommand = new AsyncCommand<MailAction>(ReturnUnconfirmedSendAsync, static action => action.NeedsSendReview);
ConfirmSentCommand = new AsyncCommand<MailAction>(ConfirmSentAsync, static action => action.NeedsSendReview);
Expand Down Expand Up @@ -317,7 +319,7 @@ public MainWindowViewModel(
var evidence = _store is null ? null : new EvidenceService(_store, () => _provider, new AttachmentTextExtractor(EvidenceOcr.RecognizeAsync));
Mcp = new(_store,
async () => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(RefreshMcpChangesAsync),
async (sender, id, message) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => QueueSendAsync(sender, id, message)), evidence, () => _workspaceProvider);
async (sender, id, message) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => QueueSendAsync(sender, id, message)), evidence, () => _workspaceProvider, () => _provider);
_selectedSettingsTab = SettingsTabs[0];
Drafts.CollectionChanged += (_, _) => RaiseDraftState();
BusyActions.CollectionChanged += (_, _) => { RaiseDraftState(); MailActionStateChanged(); };
Expand Down Expand Up @@ -4084,7 +4086,7 @@ private async Task MoveMessagesAsync(
var destination = Folders.FirstOrDefault(folder => folder.MailboxId == message.MailboxId &&
(folder.ProviderId == destinationFolderId || folder.WellKnownName == destinationFolderId))?.ProviderId ?? destinationFolderId;
var pending = BusyActions.LastOrDefault(action => action.Kind == MailActionKind.Move && ActionMatches(action, message));
return pending is not null ? pending.DestinationId != destination || pending.Error is not null : message.FolderId != destination;
return pending is not null ? pending.DestinationId != destination : message.FolderId != destination;
}).ToArray();
if (messages.Count == 0) { Status = "Messages are already in this folder or queued for it"; return; }
BeginMessageFeedback(messages);
Expand Down
Loading
Loading