diff --git a/README.md b/README.md
index f9609f6..c627fdf 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/mcp-content-actions.md b/docs/mcp-content-actions.md
new file mode 100644
index 0000000..64dbbe6
--- /dev/null
+++ b/docs/mcp-content-actions.md
@@ -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.
diff --git a/docs/ui/screenshots/busy-dark.png b/docs/ui/screenshots/busy-dark.png
new file mode 100644
index 0000000..ccc4857
Binary files /dev/null and b/docs/ui/screenshots/busy-dark.png differ
diff --git a/docs/ui/screenshots/busy-light.png b/docs/ui/screenshots/busy-light.png
new file mode 100644
index 0000000..517c7df
Binary files /dev/null and b/docs/ui/screenshots/busy-light.png differ
diff --git a/src/BetterMail.App/MainWindow.axaml b/src/BetterMail.App/MainWindow.axaml
index f3b3848..928c1a4 100644
--- a/src/BetterMail.App/MainWindow.axaml
+++ b/src/BetterMail.App/MainWindow.axaml
@@ -829,18 +829,28 @@
-
+
-
diff --git a/src/BetterMail.Core/EncryptedMailStore.Actions.cs b/src/BetterMail.Core/EncryptedMailStore.Actions.cs
index 7a670e8..1431c17 100644
--- a/src/BetterMail.Core/EncryptedMailStore.Actions.cs
+++ b/src/BetterMail.Core/EncryptedMailStore.Actions.cs
@@ -18,6 +18,19 @@ CREATE TABLE IF NOT EXISTS mail_actions (
foreach (var action in actions.Where(static action => action.Running))
await WriteActionAsync(connection, null, action with { Running = false }, cancellationToken).ConfigureAwait(false);
+ // Reproject older failed actions which previous versions restored to their source.
+ foreach (var move in actions.Where(action => action.Kind == MailActionKind.Move)
+ .GroupBy(action => (action.MailboxId, action.ItemId))
+ .Where(group => group.Any(action => !action.Accepted)).Select(group => group.Last()))
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = "UPDATE messages SET folder_id = $folder WHERE mailbox_id = $mailbox AND provider_id = $provider;";
+ command.Parameters.AddWithValue("$folder", move.DestinationId!);
+ command.Parameters.AddWithValue("$mailbox", move.MailboxId);
+ command.Parameters.AddWithValue("$provider", move.ProviderId!);
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
// Existing outbox payloads and acceptance markers remain in local_drafts.
var sends = new List();
await using (var command = connection.CreateCommand())
@@ -96,9 +109,9 @@ public Task QueueMoveAsync(MailAccount account, MailMessage message,
var actions = await ReadActionsAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
var related = actions.Where(action => action.Kind == MailActionKind.Move && MatchesMessage(action, message)).ToArray();
var last = related.LastOrDefault();
- if (last is { Error: null } && last.DestinationId == destination.ProviderId) return last;
+ if (last is not null && last.DestinationId == destination.ProviderId) return last;
var action = last is { Running: false, Accepted: false }
- ? last with { DestinationId = destination.ProviderId, DestinationName = destination.DisplayName, Error = null }
+ ? last with { DestinationId = destination.ProviderId, DestinationName = destination.DisplayName, Error = null, RetryAuthorizedAtFailureCount = last.FailureCount }
: new MailAction(Guid.NewGuid().ToString("N"), account.AccountId, message.MailboxId,
last?.ItemId ?? message.ProviderId, MailActionKind.Move, message.Subject, DateTimeOffset.UtcNow,
last?.ProviderId ?? message.ProviderId, destination.ProviderId, destination.DisplayName,
@@ -124,8 +137,7 @@ public Task CancelMailActionAsync(string id, CancellationToken cancellatio
if (action.Kind == MailActionKind.Move)
{
var previous = related.Length > 1 ? related[^2] : null;
- var failed = related.FirstOrDefault(candidate => candidate.Id != action.Id && !candidate.Accepted && candidate.Error is not null);
- var folder = failed?.SourceFolderId ?? previous?.DestinationId ?? action.SourceFolderId;
+ var folder = previous?.DestinationId ?? action.SourceFolderId;
if (folder is null) return false;
command.CommandText = "UPDATE messages SET folder_id = $folder, is_read = $read WHERE mailbox_id = $mailbox AND provider_id = $provider;";
command.Parameters.AddWithValue("$folder", folder);
@@ -156,12 +168,26 @@ public Task CancelMailActionAsync(string id, CancellationToken cancellatio
{
var actions = await ReadActionsAsync(connection, null, cancellationToken).ConfigureAwait(false);
var action = actions.FirstOrDefault(action => action.Id == id && !action.Accepted && !action.Running && !action.SendAttempted);
- if (action is null) return null;
- action = action with { Running = true, FailureCount = Math.Max(action.FailureCount, action.Error is null ? 0 : 1), Error = null };
+ if (action is null || action.IsRetryPaused) return null;
+ if (actions.TakeWhile(candidate => candidate.Id != id).Any(candidate =>
+ candidate.MailboxId == action.MailboxId && candidate.ItemId == action.ItemId && !candidate.Accepted)) return null;
+ action = action with { Running = true, LastAttemptAt = DateTimeOffset.UtcNow, LastError = action.Error ?? action.LastError, FailureCount = Math.Max(action.FailureCount, action.Error is null ? 0 : 1), Error = null };
await WriteActionAsync(connection, null, action, cancellationToken).ConfigureAwait(false);
return action;
}, cancellationToken);
+ public Task RetryMailActionAsync(string id, CancellationToken cancellationToken = default) =>
+ WithLockAsync(async connection =>
+ {
+ var actions = await ReadActionsAsync(connection, null, cancellationToken).ConfigureAwait(false);
+ var action = actions.FirstOrDefault(candidate => candidate.Id == id);
+ if (action is not { CanRetry: true }) return false;
+ if (actions.TakeWhile(candidate => candidate.Id != id).Any(candidate =>
+ candidate.MailboxId == action.MailboxId && candidate.ItemId == action.ItemId && !candidate.Accepted)) return false;
+ await WriteActionAsync(connection, null, action with { RetryAuthorizedAtFailureCount = action.FailureCount }, cancellationToken).ConfigureAwait(false);
+ return true;
+ }, cancellationToken);
+
public Task FailMailActionAsync(string id, string error, CancellationToken cancellationToken = default) =>
WithLockAsync(async connection =>
{
@@ -170,19 +196,9 @@ public Task FailMailActionAsync(string id, string error, CancellationToken cance
.FirstOrDefault(action => action.Id == id && !action.Accepted);
if (action is not null)
{
- await WriteActionAsync(connection, transaction, action with { Running = false, Error = error, FailureCount = action.FailureCount + 1 }, cancellationToken).ConfigureAwait(false);
- if (action.Kind == MailActionKind.Move && action.SourceFolderId is not null)
- {
- // Follow-up moves cannot run until this failure is recovered. Restore
- // the actual source even if a newer destination was optimistically shown.
- await using var command = connection.CreateCommand();
- command.Transaction = transaction;
- command.CommandText = "UPDATE messages SET folder_id = $folder WHERE mailbox_id = $mailbox AND provider_id = $provider;";
- command.Parameters.AddWithValue("$folder", action.SourceFolderId);
- command.Parameters.AddWithValue("$mailbox", action.MailboxId);
- command.Parameters.AddWithValue("$provider", action.ProviderId!);
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
+ await WriteActionAsync(connection, transaction, action with { Running = false, Error = error, LastError = error, LastFailureAt = DateTimeOffset.UtcNow, FailureCount = action.FailureCount + 1 }, cancellationToken).ConfigureAwait(false);
+ // Pending moves retain their destination projection even after failure.
+ // SourceFolderId remains the cancellation/remote execution provenance.
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}, cancellationToken);
@@ -200,7 +216,7 @@ public Task MarkSendRejectedAsync(string draftId, string error, CancellationToke
{
var action = (await ReadActionsAsync(connection, null, cancellationToken).ConfigureAwait(false))
.Single(action => action.Id == "send:" + draftId && !action.Accepted);
- await WriteActionAsync(connection, null, action with { SendAttempted = false, Running = false, Error = error, FailureCount = action.FailureCount + 1 }, cancellationToken).ConfigureAwait(false);
+ await WriteActionAsync(connection, null, action with { SendAttempted = false, Running = false, Error = error, LastError = error, LastFailureAt = DateTimeOffset.UtcNow, FailureCount = action.FailureCount + 1 }, cancellationToken).ConfigureAwait(false);
}, cancellationToken);
public Task ReturnUnconfirmedSendToDraftAsync(string actionId, bool clearProviderMapping = false, CancellationToken cancellationToken = default) =>
diff --git a/src/BetterMail.Core/EncryptedMailStore.Mcp.cs b/src/BetterMail.Core/EncryptedMailStore.Mcp.cs
index 0f8ae4a..3135580 100644
--- a/src/BetterMail.Core/EncryptedMailStore.Mcp.cs
+++ b/src/BetterMail.Core/EncryptedMailStore.Mcp.cs
@@ -10,7 +10,8 @@ public sealed record McpConfiguration(
bool AllowWrites = false,
bool AllowSending = false,
string[]? MailboxIds = null,
- string[]? DriveAccountIds = null);
+ string[]? DriveAccountIds = null,
+ string[]? WorkspaceAccountIds = null);
public sealed record BetterTunnelsConfiguration(bool Enabled = false, string Token = "", string Email = "");
diff --git a/src/BetterMail.Core/EncryptedMailStore.MessageStateActions.cs b/src/BetterMail.Core/EncryptedMailStore.MessageStateActions.cs
index 7f3ee5f..561683f 100644
--- a/src/BetterMail.Core/EncryptedMailStore.MessageStateActions.cs
+++ b/src/BetterMail.Core/EncryptedMailStore.MessageStateActions.cs
@@ -16,8 +16,12 @@ public Task QueueMessageStateAsync(MailAccount account, MailMessage
: new MailAction(Guid.NewGuid().ToString("N"), account.AccountId, message.MailboxId, related.FirstOrDefault()?.ItemId ?? message.ProviderId,
MailActionKind.UpdateState, message.Subject, DateTimeOffset.UtcNow, related.LastOrDefault()?.ProviderId ?? message.ProviderId,
PreviousRead: message.IsRead, PreviousFlagged: message.IsFlagged, PreviousPinned: message.IsPinned);
+ var changed = isRead is not null && isRead != action.ReadValue ||
+ isFlagged is not null && isFlagged != action.FlagValue ||
+ isPinned is not null && isPinned != action.PinValue;
action = action with { ReadValue = isRead ?? action.ReadValue, FlagValue = isFlagged ?? action.FlagValue,
- PinValue = isPinned ?? action.PinValue, Error = null };
+ PinValue = isPinned ?? action.PinValue, Error = changed ? null : action.Error,
+ RetryAuthorizedAtFailureCount = changed ? action.FailureCount : action.RetryAuthorizedAtFailureCount };
await WriteActionAsync(connection, transaction, action, cancellationToken).ConfigureAwait(false);
await SetActionStateAsync(connection, transaction, action, false, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
diff --git a/src/BetterMail.Core/EncryptedMailStore.cs b/src/BetterMail.Core/EncryptedMailStore.cs
index 6de0ebd..71b28be 100644
--- a/src/BetterMail.Core/EncryptedMailStore.cs
+++ b/src/BetterMail.Core/EncryptedMailStore.cs
@@ -395,10 +395,9 @@ await WithLockAsync(async connection =>
continue;
if (moves.Any(static action => !action.Accepted))
{
- var failed = moves.FirstOrDefault(static action => !action.Accepted && action.Error is not null);
if (!message.IsDeleted)
await UpsertMessageAsync(connection, transaction, message with
- { FolderId = failed?.SourceFolderId ?? latest.DestinationId!,
+ { FolderId = latest.DestinationId!,
IsRead = pendingStates.LastOrDefault(state => state.ReadValue is not null)?.ReadValue ?? true }, cancellationToken).ConfigureAwait(false);
continue;
}
diff --git a/src/BetterMail.Core/MailAction.cs b/src/BetterMail.Core/MailAction.cs
index 46c9d64..93f8cf6 100644
--- a/src/BetterMail.Core/MailAction.cs
+++ b/src/BetterMail.Core/MailAction.cs
@@ -22,8 +22,25 @@ public sealed record MailAction(
bool SourceWasUnread = false,
bool SendAttempted = false,
bool? ReadValue = null, bool? FlagValue = null, bool? PinValue = null,
- bool? PreviousRead = null, bool? PreviousFlagged = null, bool? PreviousPinned = null, int FailureCount = 0)
+ bool? PreviousRead = null, bool? PreviousFlagged = null, bool? PreviousPinned = null, int FailureCount = 0,
+ int? RetryAuthorizedAtFailureCount = null, DateTimeOffset? LastAttemptAt = null,
+ DateTimeOffset? LastFailureAt = null, string? LastError = 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 CanRetry => !Running && !Accepted && !SendAttempted && FailureCount > 0;
+ public string? FailureDetails => Error ?? LastError;
+ public bool HasFailure => FailureCount > 0 || FailureDetails is not null;
+ public string RetryHistory => $"{FailureCount} failed attempt(s)" +
+ (LastFailureAt is { } failed ? $" · Last failure {failed.ToLocalTime():g}" : "") +
+ (LastAttemptAt is { } attempted ? $" · Last attempt {attempted.ToLocalTime():g}" : "");
+ public string RecoveryGuidance => NeedsSendReview ? "Check Sent before taking any further action; delivery was not confirmed." :
+ FailureDetails is { } error && (error.Contains("not found", StringComparison.OrdinalIgnoreCase) || error.Contains("unavailable", StringComparison.OrdinalIgnoreCase))
+ ? "The message or destination may have moved or been deleted. Check status to look for the message on the server. Missing does not mean it was sent or deleted." :
+ "Check connectivity and account access, then retry. If the provider reports a permission or folder error, fix that first. Your pending action is kept.";
public bool CanCancel => !Running && !Accepted && !SendAttempted;
public bool NeedsSendReview => Kind == MailActionKind.Send && SendAttempted && !Running && !Accepted;
public string DisplaySubject => string.IsNullOrWhiteSpace(Subject) ? "(no subject)" : Subject;
@@ -40,7 +57,7 @@ public sealed record MailAction(
MailActionKind.Send => "Sending…",
MailActionKind.DeleteDraft => "Deleting…",
_ => "Moving…"
- } : Error is null ? "Waiting for sync" : "Retrying next sync";
+ } : IsRetryPaused ? "Paused — needs attention" : Error is null ? "Waiting for sync" : "Retrying next sync";
public DateTimeOffset LocalCreatedAt => CreatedAt.ToLocalTime();
public string MailboxAddress => MailboxId[(MailboxId.LastIndexOf(':') + 1)..];
}
diff --git a/src/BetterMail.Core/MailActionDiagnostics.cs b/src/BetterMail.Core/MailActionDiagnostics.cs
new file mode 100644
index 0000000..04f8719
--- /dev/null
+++ b/src/BetterMail.Core/MailActionDiagnostics.cs
@@ -0,0 +1,48 @@
+using System.Net;
+
+namespace BetterMail.Core;
+
+/// Read-only investigation; 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)
+ {
+ var action = await store.GetMailActionAsync(actionId, token)
+ ?? throw new InvalidOperationException("Action is no longer pending.");
+ if (action.AccountId != account.AccountId || action.MailboxId != mailbox.Id || mailbox.AccountId != account.AccountId)
+ throw new InvalidOperationException("Action belongs to a different mailbox.");
+ if (action.Running) return "This action is currently executing. Check again after it finishes.";
+ if (action.NeedsSendReview) return "Delivery is unconfirmed. Check Sent in your provider before returning this to drafts or confirming delivery. No retry has been authorized.";
+ if (action.Kind is not (MailActionKind.Move or MailActionKind.UpdateState))
+ return "Check Drafts and Sent in your provider. This check does not send, delete, or recreate drafts. " + action.RecoveryGuidance;
+ if (action.ProviderId is null) return "No server message ID is saved. Reconnect and sync the account before attempting recovery.";
+ var cached = await store.GetMessageAsync(mailbox.Id, action.ProviderId, token);
+ foreach (var id in new[] { action.ProviderId }.Concat(action.PreviousProviderIds ?? []).Distinct().Take(10))
+ {
+ try
+ {
+ var message = await provider.GetMessageAsync(account, mailbox, id, token);
+ if (message.MailboxId != mailbox.Id) throw new InvalidOperationException("Provider returned a different mailbox.");
+ if (id == action.ProviderId || (cached?.InternetMessageId is { Length: > 0 } identity && message.InternetMessageId == identity))
+ return Describe(action, message);
+ }
+ catch (HttpRequestException error) when (error.StatusCode == HttpStatusCode.NotFound) { }
+ }
+ if (string.IsNullOrWhiteSpace(cached?.InternetMessageId))
+ return "The saved server IDs were not found. No stable message identity is cached, so a subject match cannot safely identify it. Check the provider mailbox and destination. The pending action has been kept.";
+ var results = await provider.SearchMessagesAsync(account, mailbox, cached.Subject, 100, token);
+ var matches = results.Where(message => message.MailboxId == mailbox.Id && message.InternetMessageId == cached.InternetMessageId)
+ .DistinctBy(message => message.ProviderId).ToArray();
+ if (results.Count < 100 && matches.Length == 1) return Describe(action, matches[0]);
+ return matches.Length > 1 || results.Count >= 100
+ ? "Server search is ambiguous or incomplete. No action was changed. Inspect the message and destination in your provider before deciding what to do."
+ : "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.";
+ }
+
+ 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."
+ : 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 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/src/BetterMail.Core/MailProviderRouter.cs b/src/BetterMail.Core/MailProviderRouter.cs
index b363bc6..1f8081c 100644
--- a/src/BetterMail.Core/MailProviderRouter.cs
+++ b/src/BetterMail.Core/MailProviderRouter.cs
@@ -50,6 +50,9 @@ public Task IsDraftSentAsync(MailAccount account, Mailbox mailbox, string
public Task GetDraftAsync(MailAccount account, Mailbox mailbox, string draftId, CancellationToken cancellationToken = default) =>
For(account).GetDraftAsync(account, mailbox, draftId, cancellationToken);
+ public Task CreateResponseDraftAsync(MailAccount account, Mailbox mailbox, string messageId,
+ MailResponseKind kind, DraftMessage draft, CancellationToken cancellationToken = default) =>
+ For(account).CreateResponseDraftAsync(account, mailbox, messageId, kind, draft, cancellationToken);
public Task CreateDraftAsync(MailAccount account, Mailbox mailbox, DraftMessage draft, CancellationToken cancellationToken = default) =>
For(account).CreateDraftAsync(account, mailbox, draft, cancellationToken);
public Task UpdateDraftAsync(MailAccount account, Mailbox mailbox, string draftId, DraftMessage draft, CancellationToken cancellationToken = default) =>
diff --git a/src/BetterMail.Core/Models.cs b/src/BetterMail.Core/Models.cs
index 32ddad8..1c13933 100644
--- a/src/BetterMail.Core/Models.cs
+++ b/src/BetterMail.Core/Models.cs
@@ -169,6 +169,8 @@ public sealed record DraftMessage(
bool RequestReadReceipt = false,
bool RequestDeliveryReceipt = false);
+public enum MailResponseKind { Reply, ReplyAll, Forward }
+
public sealed record CloudDraft(
string ProviderId,
string AccountId,
diff --git a/src/BetterMail.Core/ProviderContracts.cs b/src/BetterMail.Core/ProviderContracts.cs
index e732fab..8fef558 100644
--- a/src/BetterMail.Core/ProviderContracts.cs
+++ b/src/BetterMail.Core/ProviderContracts.cs
@@ -148,6 +148,10 @@ Task GetDraftAsync(
Task.FromException(
new NotSupportedException("This provider does not support cloud drafts."));
+ Task CreateResponseDraftAsync(MailAccount account, Mailbox mailbox, string messageId,
+ MailResponseKind kind, DraftMessage draft, CancellationToken cancellationToken = default) =>
+ Task.FromException(new NotSupportedException("This provider does not support response drafts."));
+
Task CreateDraftAsync(
MailAccount account,
Mailbox mailbox,
diff --git a/src/BetterMail.Google/GoogleGmailProvider.cs b/src/BetterMail.Google/GoogleGmailProvider.cs
index 0295fd3..fa4898f 100644
--- a/src/BetterMail.Google/GoogleGmailProvider.cs
+++ b/src/BetterMail.Google/GoogleGmailProvider.cs
@@ -317,6 +317,45 @@ public async Task GetDraftAsync(
return await MapDraftAsync(account, mailbox, document.RootElement, cancellationToken).ConfigureAwait(false);
}
+ internal static async Task> CopyForwardAttachmentsAsync(
+ IReadOnlyList attachments, Func> hydrate)
+ {
+ var copied = new List();
+ // The quoted HTML already embeds CID images. Outgoing rendering creates their MIME parts.
+ foreach (var attachment in attachments.Where(attachment => !attachment.IsInline))
+ {
+ var hydrated = attachment.ContentBytes is not null ? attachment : await hydrate(attachment).ConfigureAwait(false);
+ copied.Add(new(attachment.Name, attachment.ContentType,
+ hydrated?.ContentBytes ?? throw new InvalidOperationException("Forward attachment content unavailable.")));
+ }
+ return copied;
+ }
+
+ public async Task CreateResponseDraftAsync(MailAccount account, Mailbox mailbox, string messageId,
+ MailResponseKind kind, DraftMessage draft, CancellationToken cancellationToken = default)
+ {
+ Validate(account, mailbox);
+ if (!Enum.IsDefined(kind)) throw new ArgumentOutOfRangeException(nameof(kind));
+ using var source = await GetJsonAsync(account, $"/messages/{Escape(messageId)}?format=full", cancellationToken).ConfigureAwait(false);
+ var sourceHeaders = Headers(source.RootElement.GetProperty("payload"));
+ var replyId = Header(sourceHeaders, "Message-ID");
+ var references = Header(sourceHeaders, "References");
+ if (kind != MailResponseKind.Forward && string.IsNullOrWhiteSpace(replyId))
+ throw new InvalidOperationException("The source email has no Message-ID for a threaded reply.");
+ if (kind == MailResponseKind.Forward)
+ {
+ var attachments = await GetAttachmentsAsync(account, mailbox, messageId, cancellationToken).ConfigureAwait(false);
+ draft = draft with { Attachments = await CopyForwardAttachmentsAsync(attachments,
+ attachment => GetAttachmentAsync(account, mailbox, messageId, attachment.ProviderId, cancellationToken)).ConfigureAwait(false) };
+ }
+ var raw = BuildMime(mailbox, draft, kind == MailResponseKind.Forward ? null : replyId,
+ kind == MailResponseKind.Forward ? null : (references + " " + replyId).Trim());
+ using var response = await SendJsonAsync(account, HttpMethod.Post, "/drafts",
+ new { message = new { raw = Encode(raw), threadId = kind == MailResponseKind.Forward ? null : OptionalString(source.RootElement, "threadId") } }, cancellationToken).ConfigureAwait(false);
+ using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), cancellationToken: cancellationToken).ConfigureAwait(false);
+ return await GetDraftAsync(account, mailbox, RequiredString(document.RootElement, "id"), cancellationToken).ConfigureAwait(false);
+ }
+
public async Task CreateDraftAsync(
MailAccount account,
Mailbox mailbox,
@@ -344,15 +383,27 @@ public async Task UpdateDraftAsync(
CancellationToken cancellationToken = default)
{
Validate(account, mailbox);
+ // Gmail replaces MIME on draft edits. Carry response headers and thread ID
+ // forward from the existing draft so attachment edits cannot detach a reply.
+ using var existing = await GetJsonAsync(account, $"/drafts/{Escape(draftId)}?format=full", cancellationToken).ConfigureAwait(false);
+ var payload = BuildDraftUpdatePayload(mailbox, draft, existing.RootElement);
using var response = await SendJsonAsync(
account,
HttpMethod.Put,
$"/drafts/{Escape(draftId)}",
- new { message = new { raw = Encode(BuildMime(mailbox, draft)) } },
+ payload,
cancellationToken).ConfigureAwait(false);
return await GetDraftAsync(account, mailbox, draftId, cancellationToken).ConfigureAwait(false);
}
+ internal static object BuildDraftUpdatePayload(Mailbox mailbox, DraftMessage draft, JsonElement existingDraft)
+ {
+ var message = existingDraft.GetProperty("message");
+ var headers = Headers(message.GetProperty("payload"));
+ var raw = BuildMime(mailbox, draft, Header(headers, "In-Reply-To"), Header(headers, "References"));
+ return new { message = new { raw = Encode(raw), threadId = OptionalString(message, "threadId") } };
+ }
+
public async Task DeleteDraftAsync(
MailAccount account,
Mailbox mailbox,
@@ -415,7 +466,7 @@ internal static MailMessage MapMessage(
ParseAddresses(Header(headers, "Cc")));
}
- internal static string BuildMime(Mailbox mailbox, DraftMessage draft)
+ internal static string BuildMime(Mailbox mailbox, DraftMessage draft, string? inReplyTo = null, string? references = null)
{
if (draft.RequestReadReceipt || draft.RequestDeliveryReceipt)
throw new NotSupportedException("Receipt requests are not supported by BetterMail for Gmail accounts.");
@@ -436,6 +487,13 @@ internal static string BuildMime(Mailbox mailbox, DraftMessage draft)
HeaderLine(builder, "X-Priority", draft.Importance switch { MailImportance.High => "1", MailImportance.Low => "5", _ => "3" });
HeaderLine(builder, "Date", DateTimeOffset.Now.ToString("r", CultureInfo.InvariantCulture));
HeaderLine(builder, "MIME-Version", "1.0");
+ foreach (var (name, value) in new[] { ("In-Reply-To", inReplyTo), ("References", references) })
+ {
+ if (string.IsNullOrWhiteSpace(value)) continue;
+ if (value.Length > 16000 || value.Any(character => char.IsControl(character)))
+ throw new InvalidOperationException("Invalid response header.");
+ HeaderLine(builder, name, value);
+ }
if (draft.Attachments?.Count > 0)
{
diff --git a/src/BetterMail.Microsoft365/Microsoft365MailProvider.cs b/src/BetterMail.Microsoft365/Microsoft365MailProvider.cs
index 26a1a4b..225084d 100644
--- a/src/BetterMail.Microsoft365/Microsoft365MailProvider.cs
+++ b/src/BetterMail.Microsoft365/Microsoft365MailProvider.cs
@@ -401,6 +401,19 @@ public async Task GetDraftAsync(
return MapDraft(account, mailbox, document.RootElement, attachments);
}
+ public async Task CreateResponseDraftAsync(MailAccount account, Mailbox mailbox, string messageId,
+ MailResponseKind kind, DraftMessage draft, CancellationToken cancellationToken = default)
+ {
+ ValidateDraft(draft);
+ var operation = kind switch { MailResponseKind.Reply => "createReply", MailResponseKind.ReplyAll => "createReplyAll",
+ MailResponseKind.Forward => "createForward", _ => throw new ArgumentOutOfRangeException(nameof(kind)) };
+ using var document = await SendJsonForResponseAsync(account, HttpMethod.Post,
+ $"{MailboxPath(account, mailbox)}/messages/{Uri.EscapeDataString(messageId)}/{operation}",
+ new { message = BuildMessagePayload(mailbox, draft) }, cancellationToken).ConfigureAwait(false);
+ var id = RequiredString(document.RootElement, "id");
+ return await GetDraftAsync(account, mailbox, id, cancellationToken).ConfigureAwait(false);
+ }
+
public async Task CreateDraftAsync(
MailAccount account,
Mailbox mailbox,
diff --git a/tests/BetterMail.Tests/EncryptedMailStoreTests.cs b/tests/BetterMail.Tests/EncryptedMailStoreTests.cs
index 820f17a..7e183ce 100644
--- a/tests/BetterMail.Tests/EncryptedMailStoreTests.cs
+++ b/tests/BetterMail.Tests/EncryptedMailStoreTests.cs
@@ -6,6 +6,166 @@ namespace BetterMail.Tests;
public sealed class EncryptedMailStoreTests
{
+ private static async Task WithStoreAsync(Func test)
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-busy-tests-" + Guid.NewGuid());
+ try
+ {
+ await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('A', 64));
+ await store.InitializeAsync(TestContext.Current.CancellationToken);
+ await test(store, TestContext.Current.CancellationToken);
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task BusyCheckRequiresExactIdentityAndNeverMutatesQueue(bool identityMatches)
+ {
+ 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, "message", "Same subject", "Body") with { InternetMessageId = "" };
+ await store.ApplySyncPageAsync("seed", new([message], null, false), token);
+ var action = await store.QueueMoveAsync(account, message, new(message.MailboxId, "archive", "Archive", 0, 0), token);
+ var provider = System.Reflection.DispatchProxy.Create();
+ ((BusyDiagnosticProvider)(object)provider).Result = message with { ProviderId = "new-id", InternetMessageId = identityMatches ? message.InternetMessageId : "" };
+ var service = new MailActionDiagnostics(store, provider);
+ var result = await service.CheckAsync(account, mailbox, action.Id, token);
+ Assert.Contains(identityMatches ? "different server ID" : "No exact identity", result);
+ Assert.Equal(action, await store.GetMailActionAsync(action.Id, token));
+ await Assert.ThrowsAsync(() => service.CheckAsync(account, new Mailbox("other", "other@example.test", "Other"), action.Id, token));
+ });
+ }
+
+ public class BusyDiagnosticProvider : System.Reflection.DispatchProxy
+ {
+ public MailMessage Result = null!;
+ protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args) => method!.Name switch
+ {
+ "GetMessageAsync" => Task.FromException(new HttpRequestException("Missing", null, System.Net.HttpStatusCode.NotFound)),
+ "SearchMessagesAsync" => Task.FromResult>([Result]),
+ _ => throw new InvalidOperationException("Status check must not mutate the provider: " + method.Name)
+ };
+ }
+
+ [Theory]
+ [InlineData("read")]
+ [InlineData("flag")]
+ [InlineData("pin")]
+ public async Task ChangedPausedStateAuthorizesOneAttemptButDuplicateDoesNot(string field)
+ {
+ await WithStoreAsync(async (store, token) =>
+ {
+ var account = new MailAccount("microsoft365", "account", "tenant", "alex@example.test", "Alex", ProviderCapabilities.Mail);
+ var message = Message("account:alex@example.test", "message", "Subject", "Body");
+ await store.ApplySyncPageAsync("seed", new([message], null, false), token);
+ Task Queue(bool value) => store.QueueMessageStateAsync(account, message,
+ isRead: field == "read" ? value : null, isFlagged: field == "flag" ? value : null,
+ isPinned: field == "pin" ? value : null, cancellationToken: token);
+ var action = await Queue(true);
+ for (var i = 0; i < 3; i++)
+ {
+ Assert.NotNull(await store.StartMailActionAsync(action.Id, token));
+ await store.FailMailActionAsync(action.Id, "Offline", token);
+ }
+ Assert.True((await Queue(true)).IsRetryPaused);
+ Assert.Null(await store.StartMailActionAsync(action.Id, token));
+ var changed = await Queue(false);
+ Assert.Equal(action.Id, changed.Id);
+ Assert.Equal(3, changed.FailureCount);
+ Assert.NotNull(await store.StartMailActionAsync(action.Id, token));
+ await store.FailMailActionAsync(action.Id, "Offline again", token);
+ Assert.Null(await store.StartMailActionAsync(action.Id, token));
+ });
+ }
+
+ [Fact]
+ public async Task PausedMoveLivesInDestinationAcrossSyncRestartAndCancellation()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-destination-" + Guid.NewGuid());
+ var path = Path.Combine(directory, "mail.db");
+ var token = TestContext.Current.CancellationToken;
+ var account = new MailAccount("microsoft365", "account", "tenant", "alex@example.test", "Alex", ProviderCapabilities.Mail);
+ var message = Message("account:alex@example.test", "message", "Subject", "Body");
+ string id;
+ try
+ {
+ await using (var store = new EncryptedMailStore(path, new string('A', 64)))
+ {
+ await store.InitializeAsync(token);
+ await store.ApplySyncPageAsync("seed", new([message], null, false), token);
+ id = (await store.QueueMoveAsync(account, message, new(message.MailboxId, "archive", "Archive", 0, 0), token)).Id;
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ Assert.NotNull(await store.StartMailActionAsync(id, token));
+ await store.FailMailActionAsync(id, "Offline", token);
+ await store.ApplySyncPageAsync("source-sync", new([message], null, false), token);
+ Assert.Empty((await store.GetMessagesPageAsync([new(message.MailboxId, message.FolderId)], cancellationToken: token)).Messages);
+ Assert.Single((await store.GetMessagesPageAsync([new(message.MailboxId, "archive")], cancellationToken: token)).Messages);
+ }
+ }
+ await using var reopened = new EncryptedMailStore(path, new string('A', 64));
+ await reopened.InitializeAsync(token);
+ Assert.True((await reopened.GetMailActionAsync(id, token))!.IsRetryPaused);
+ var same = await reopened.QueueMoveAsync(account, message, new(message.MailboxId, "archive", "Archive", 0, 0), token);
+ Assert.Equal(id, same.Id);
+ Assert.True(same.IsRetryPaused);
+ Assert.Equal("Offline", same.Error);
+ Assert.Equal("archive", (await reopened.GetMessageAsync(message.MailboxId, message.ProviderId, token))!.FolderId);
+ Assert.True(await reopened.CancelMailActionAsync(id, token));
+ Assert.Equal(message.FolderId, (await reopened.GetMessageAsync(message.MailboxId, message.ProviderId, token))!.FolderId);
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
+ [Fact]
+ public async Task RepeatedFailuresPauseAndManualRetryPreservesHistory()
+ {
+ await WithStoreAsync(async (store, token) =>
+ {
+ var account = new MailAccount("microsoft365", "account", "tenant", "alex@example.test", "Alex", ProviderCapabilities.Mail);
+ var message = Message("account:alex@example.test", "message", "Subject", "Body");
+ await store.ApplySyncPageAsync("seed", new([message], null, false), token);
+ var action = await store.QueueMoveAsync(account, message, new(message.MailboxId, "archive", "Archive", 0, 0), token);
+ await store.StartMailActionAsync(action.Id, token);
+ var later = await store.QueueMoveAsync(account, message, new(message.MailboxId, "done", "Done", 0, 0), token);
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ if (attempt > 0) Assert.NotNull(await store.StartMailActionAsync(action.Id, token));
+ await store.FailMailActionAsync(action.Id, "Object not found", token);
+ }
+ var paused = (await store.GetMailActionAsync(action.Id, token))!;
+ Assert.True(paused.IsRetryPaused);
+ Assert.NotNull(paused.LastAttemptAt);
+ Assert.NotNull(paused.LastFailureAt);
+ Assert.Null(await store.StartMailActionAsync(action.Id, token));
+ Assert.Null(await store.StartMailActionAsync(later.Id, token));
+ Assert.False(await store.RetryMailActionAsync(later.Id, token));
+ Assert.True(await store.RetryMailActionAsync(action.Id, token));
+ var retry = await store.StartMailActionAsync(action.Id, token);
+ Assert.Equal(3, retry!.FailureCount);
+ Assert.Equal("Object not found", retry.LastError);
+ Assert.False(await store.RetryMailActionAsync(action.Id, token));
+ await store.FailMailActionAsync(action.Id, "Still missing", token);
+ Assert.True((await store.GetMailActionAsync(action.Id, token))!.IsRetryPaused);
+ Assert.Null(await store.StartMailActionAsync(action.Id, token));
+ });
+ }
+
+ [Fact]
+ public void LegacyFailuresPauseAndUnconfirmedSendsCannotRetry()
+ {
+ var legacy = System.Text.Json.JsonSerializer.Deserialize("""
+ {"Id":"old","AccountId":"account","MailboxId":"mailbox","ItemId":"item","Kind":0,"Subject":"Example","CreatedAt":"2026-09-13T00:00:00Z","FailureCount":22,"Error":"Missing"}
+ """)!;
+ Assert.True(legacy.IsRetryPaused);
+ Assert.Equal("Missing", legacy.FailureDetails);
+ Assert.False((legacy with { Kind = MailActionKind.Send, SendAttempted = true }).CanRetry);
+ }
+
[Fact]
public async Task ActionFailureCountSurvivesRetryAndStoreReload()
{
@@ -31,10 +191,14 @@ public async Task ActionFailureCountSurvivesRetryAndStoreReload()
Assert.Null(retry!.Error);
Assert.Equal(1, retry.FailureCount);
await store.FailMailActionAsync(id, "Offline again", token);
+ Assert.NotNull(await store.StartMailActionAsync(id, token));
+ await store.FailMailActionAsync(id, "Offline a third time", token);
}
await using var reopened = new EncryptedMailStore(path, key);
await reopened.InitializeAsync(token);
- Assert.Equal(2, Assert.Single(await reopened.GetMailActionsAsync(token)).FailureCount);
+ Assert.Equal(3, Assert.Single(await reopened.GetMailActionsAsync(token)).FailureCount);
+ Assert.True(Assert.Single(await reopened.GetMailActionsAsync(token)).IsRetryPaused);
+ Assert.Null(await reopened.StartMailActionAsync(id, token));
Assert.True(await reopened.CancelMailActionAsync(id, token));
Assert.Empty(await reopened.GetMailActionsAsync(token));
}
@@ -44,7 +208,7 @@ public async Task ActionFailureCountSurvivesRetryAndStoreReload()
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task FailedMoveWithFollowUpRestoresSourceAcrossSyncAndRecovery(bool cancelFollowUp)
+ public async Task FailedMoveWithFollowUpKeepsDestinationAcrossSyncAndRecovery(bool cancelFollowUp)
{
var token = TestContext.Current.CancellationToken;
var directory = Path.Combine(Path.GetTempPath(), "bettermail-failed-move-chain-" + Guid.NewGuid());
@@ -60,14 +224,14 @@ public async Task FailedMoveWithFollowUpRestoresSourceAcrossSyncAndRecovery(bool
first = (await store.StartMailActionAsync(first.Id, token))!;
var followUp = await store.QueueMoveAsync(account, message with { FolderId = "archive" }, archive with { ProviderId = "done" }, token);
await store.FailMailActionAsync(first.Id, "Offline", token);
- Assert.Equal(message.FolderId, (await store.GetMessageAsync(message.MailboxId, message.ProviderId, token))!.FolderId);
+ Assert.Equal("done", (await store.GetMessageAsync(message.MailboxId, message.ProviderId, token))!.FolderId);
Assert.Equal(2, (await store.GetMailActionsAsync(token)).Count);
await store.ApplySyncPageAsync("refresh", new([message], null, false), token);
- Assert.Equal(message.FolderId, (await store.GetMessageAsync(message.MailboxId, message.ProviderId, token))!.FolderId);
+ Assert.Equal("done", (await store.GetMessageAsync(message.MailboxId, message.ProviderId, token))!.FolderId);
if (cancelFollowUp)
{
Assert.True(await store.CancelMailActionAsync(followUp.Id, token));
- Assert.Equal(message.FolderId, (await store.GetMessageAsync(message.MailboxId, message.ProviderId, token))!.FolderId);
+ Assert.Equal("archive", (await store.GetMessageAsync(message.MailboxId, message.ProviderId, token))!.FolderId);
}
else
{
@@ -120,7 +284,7 @@ public async Task PendingActionsCoalesceAndFollowMovedProviderIdsWithoutLosingNe
var failed = await store.QueueMoveAsync(account, current, archive, token);
await store.StartMailActionAsync(failed.Id, token);
await store.FailMailActionAsync(failed.Id, "Offline", token);
- Assert.Equal("done", (await store.GetMessageAsync(message.MailboxId, "moved", token))!.FolderId);
+ Assert.Equal("archive", (await store.GetMessageAsync(message.MailboxId, "moved", token))!.FolderId);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
diff --git a/tests/BetterMail.Tests/GoogleProviderTests.cs b/tests/BetterMail.Tests/GoogleProviderTests.cs
index f636c76..cd3322a 100644
--- a/tests/BetterMail.Tests/GoogleProviderTests.cs
+++ b/tests/BetterMail.Tests/GoogleProviderTests.cs
@@ -1,3 +1,4 @@
+using BetterMail.App;
using System.Text;
using System.Text.Json;
using BetterMail.Core;
@@ -7,6 +8,53 @@ namespace BetterMail.Tests;
public sealed class GoogleProviderTests
{
+ [Fact]
+ public async Task ForwardedEmbeddedImageProducesOnlyOneMimePartAndKeepsFiles()
+ {
+ var files = new MailAttachment[] {
+ new("inline", "logo.png", "image/png", 3, true, "logo", [1, 2, 3]),
+ new("file", "report.zip", "application/zip", 3, false, null, null) };
+ var copied = await GoogleGmailProvider.CopyForwardAttachmentsAsync(files, attachment =>
+ {
+ Assert.Equal("file", attachment.ProviderId);
+ return Task.FromResult(attachment with { ContentBytes = [4, 5, 6] });
+ });
+ var outgoing = new MailContentRenderer().PrepareOutgoingHtml("
", copied);
+ Assert.Single(outgoing.Attachments, attachment => attachment.IsInline);
+ Assert.Equal(new byte[] { 4, 5, 6 }, Assert.Single(outgoing.Attachments, attachment => !attachment.IsInline).ContentBytes);
+ Assert.Equal(2, outgoing.Attachments.Count);
+ }
+
+ [Fact]
+ public void GmailDraftAttachmentUpdateRetainsReplyHeadersAndThread()
+ {
+ using var existing = JsonDocument.Parse("""{"message":{"threadId":"source-thread","payload":{"headers":[{"name":"In-Reply-To","value":""},{"name":"References","value":" "}]}}}""");
+ var draft = new DraftMessage("Re: Subject", [new("Accounts", "accounts@example.com")], "New body", false,
+ Attachments: [new("proof.zip", "application/zip", [1, 2, 3])]);
+ var payload = JsonSerializer.SerializeToElement(GoogleGmailProvider.BuildDraftUpdatePayload(new("account", "me@example.com", "Me"), draft, existing.RootElement));
+ Assert.Equal("source-thread", payload.GetProperty("message").GetProperty("threadId").GetString());
+ var encoded = payload.GetProperty("message").GetProperty("raw").GetString()!.Replace('-', '+').Replace('_', '/');
+ var mime = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded.PadRight((encoded.Length + 3) / 4 * 4, '=')));
+ Assert.Contains("In-Reply-To: ", mime);
+ Assert.Contains("References: ", mime);
+ Assert.Contains("proof.zip", mime);
+ Assert.DoesNotContain("Cc:", mime);
+ Assert.DoesNotContain("Bcc:", mime);
+ }
+
+ [Fact]
+ public void ResponseMimePreservesThreadHeadersAndRejectsHeaderInjection()
+ {
+ var mailbox = new Mailbox("account", "me@example.com", "Me");
+ var draft = new DraftMessage("Re: Subject", [new("Accounts", "accounts@example.com")], "Body", false);
+ var mime = GoogleGmailProvider.BuildMime(mailbox, draft, "", " ");
+ Assert.Contains("In-Reply-To: ", mime);
+ Assert.Contains("References: ", mime);
+ Assert.DoesNotContain("Cc:", mime);
+ Assert.DoesNotContain("Bcc:", mime);
+ Assert.Throws(() => GoogleGmailProvider.BuildMime(mailbox, draft, "\r\nBcc: attacker@example.com"));
+ }
+
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
diff --git a/tests/BetterMail.Tests/MainWindowViewModelTests.cs b/tests/BetterMail.Tests/MainWindowViewModelTests.cs
index d90bd04..9440ddf 100644
--- a/tests/BetterMail.Tests/MainWindowViewModelTests.cs
+++ b/tests/BetterMail.Tests/MainWindowViewModelTests.cs
@@ -1019,6 +1019,47 @@ public async Task MoveFeedbackIsImmediateAndDoesNotResetANewerSelectionOrQueueSa
finally { provider.MoveRelease.TrySetResult(); if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
+ [Fact]
+ public async Task ArchivingFromUnifiedInboxShowsPendingMessageOnlyInArchive()
+ {
+ var token = TestContext.Current.CancellationToken;
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-move-unified-" + Guid.NewGuid());
+ var provider = new RecordingProvider { MoveRelease = new(TaskCreationOptions.RunContinuationsAsynchronously) };
+ try
+ {
+ await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)));
+ await store.InitializeAsync(token);
+ var account = new MailAccount("microsoft365", "account", "tenant", "alex@work.example", "Alex", ProviderCapabilities.Mail);
+ var mailbox = new Mailbox(account.AccountId, account.EmailAddress, "Alex");
+ await store.SaveAccountAsync(account, token);
+ await store.SaveMailboxAsync(mailbox, token);
+ var inbox = new MailFolder(mailbox.Id, "actual-inbox-id", "Inbox", 0, 0, "inbox");
+ var archive = new MailFolder(mailbox.Id, "archive", "Archive", 0, 0, "archive");
+ await store.SaveFoldersAsync(mailbox.Id, [inbox, archive], token);
+ provider.FolderResults = [inbox, archive];
+ var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null, provider);
+ vm.Accounts.Add(account); vm.Mailboxes.Add(mailbox);
+ vm.Folders.Add(new(inbox, "Alex")); vm.Folders.Add(new(archive, "Alex"));
+ var message = Message(mailbox.Id, inbox.ProviderId, "Archive this", "Full body") with { IsRead = true };
+ await store.ApplySyncPageAsync("seed", new([message], null, false), token);
+ await ((AsyncCommand)vm.ShowUnifiedInboxCommand).ExecuteAsync();
+ vm.SetSelectedMessages([Assert.Single(vm.Messages)], vm.Messages[0]);
+ await vm.MoveSelectionToFolderAsync(vm.Folders[1]);
+ Assert.Empty(vm.Messages);
+ await store.ApplySyncPageAsync("stale-inbox", new([message], null, false), token);
+ await ((AsyncCommand)vm.ShowUnifiedInboxCommand).ExecuteAsync();
+ Assert.Empty(vm.Messages);
+ await ((AsyncCommand)vm.SelectFolderCommand).ExecuteAsync(vm.Folders[1]);
+ var pending = Assert.Single(vm.Messages);
+ Assert.Equal(message.ProviderId, pending.ProviderId);
+ Assert.Equal("archive", pending.FolderId);
+ Assert.True(vm.IsMessageActionPending(pending));
+ provider.MoveRelease.TrySetResult();
+ await WaitUntilAsync(() => !vm.IsSyncing, token);
+ }
+ finally { provider.MoveRelease.TrySetResult(); if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
[Fact]
public async Task MovingToInboxPreservesRowWhenUnifiedInboxOpensDuringFeedback()
{
diff --git a/tests/BetterMail.Tests/McpContentTests.cs b/tests/BetterMail.Tests/McpContentTests.cs
new file mode 100644
index 0000000..6ae6a9c
--- /dev/null
+++ b/tests/BetterMail.Tests/McpContentTests.cs
@@ -0,0 +1,176 @@
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text.Json;
+using BetterMail.App;
+using BetterMail.Core;
+using ModelContextProtocol;
+
+namespace BetterMail.Tests;
+
+public sealed class McpContentTests
+{
+ [Fact]
+ public async Task BusyRetryRequiresMailboxWriteAccessAndPreservesPauseHistory()
+ {
+ await WithTools(async (store, tools, fake, account, mailbox, setSettings) =>
+ {
+ var message = new MailMessage(mailbox.Id, "source", "thread", "", "inbox", "Subject",
+ new("Sender", "sender@example.test"), [], DateTimeOffset.UtcNow, "", "Body", false, true, false, MailImportance.Normal, [], null);
+ await store.ApplySyncPageAsync("seed", new([message], null, false));
+ var action = await store.QueueMoveAsync(account, message, new(mailbox.Id, "archive", "Archive", 0, 0));
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ await store.StartMailActionAsync(action.Id);
+ await store.FailMailActionAsync(action.Id, "Missing");
+ }
+ await Assert.ThrowsAsync(() => tools.RetryMailAction(mailbox.Id, action.Id));
+ setSettings(new(Enabled: true, AllowWrites: true, MailboxIds: [mailbox.Id]));
+ await Assert.ThrowsAsync(() => tools.RetryMailAction("other", action.Id));
+ Assert.True(await tools.RetryMailAction(mailbox.Id, action.Id));
+ var pending = (await store.GetMailActionAsync(action.Id))!;
+ Assert.Equal(3, pending.FailureCount);
+ Assert.False(pending.IsRetryPaused);
+ Assert.Empty(fake.Calls);
+ });
+ }
+
+ [Fact]
+ public async Task WorkspaceAccessIsIndependentAndContentOperationsUseScopedAccounts()
+ {
+ await WithTools(async (store, tools, fake, account, mailbox, setSettings) =>
+ {
+ await Assert.ThrowsAsync(() => tools.ListCalendars("microsoft365:account"));
+ Assert.Empty(fake.Calls);
+ setSettings(new(Enabled: true, AllowWrites: false, WorkspaceAccountIds: ["microsoft365:account"]));
+ Assert.Contains("calendar", JsonSerializer.Serialize(await tools.ListCalendars("microsoft365:account")));
+ await Assert.ThrowsAsync(() => tools.CreateContact("microsoft365:account", "Person", []));
+ await Assert.ThrowsAsync(() => tools.ListTaskLists("microsoft365:other"));
+ setSettings(new(Enabled: true, AllowWrites: true, WorkspaceAccountIds: ["microsoft365:account"]));
+ var start = DateTimeOffset.UtcNow;
+ var draft = new CalendarEventDraft("calendar", "Review", start, start.AddHours(1), Body: "Details");
+ await tools.CreateEvent("microsoft365:account", draft);
+ await Assert.ThrowsAsync(() => tools.CreateEvent("microsoft365:account", draft with { Attendees = [new(new("Guest", "guest@example.com"))] }));
+ await Assert.ThrowsAsync(() => tools.UpdateEvent("microsoft365:account", "event", draft));
+ await tools.CreateContact("microsoft365:account", "Ada", ["ada@example.com"], new(CompanyName: "Studio"));
+ await tools.UpdateContact("microsoft365:account", "contact", "Ada Updated", []);
+ await tools.DeleteContact("microsoft365:account", "contact");
+ await tools.ListTaskLists("microsoft365:account");
+ await tools.ListTasks("microsoft365:account", "list");
+ await tools.CreateTaskList("microsoft365:account", "Work");
+ await tools.RenameTaskList("microsoft365:account", "list", "Renamed");
+ await tools.CreateTask("microsoft365:account", new("untrusted-account", "list", "Task"));
+ await tools.UpdateTask("microsoft365:account", "task", new("untrusted-account", "list", "Edited"));
+ await tools.SetTaskCompleted("microsoft365:account", "list", "task", true);
+ await tools.DeleteTask("microsoft365:account", "list", "task");
+ await tools.DeleteTaskList("microsoft365:account", "list");
+ await tools.ListNotebooks("microsoft365:account");
+ await tools.ListNoteSections("microsoft365:account", "notebook");
+ await tools.ListNotePages("microsoft365:account", "notebook", "section");
+ await tools.ReadNotePage("microsoft365:account", "notebook", "section", "page");
+ await tools.CreateNotePage("microsoft365:account", "notebook", "section", "Title", "Details
");
+ await Assert.ThrowsAsync(() => tools.UpdateNotePage("microsoft365:account", "notebook", "section", "page", fake.Modified.AddSeconds(-1), [new("body", NotePatchAction.Append, "Text")]));
+ await tools.UpdateNotePage("microsoft365:account", "notebook", "section", "page", fake.Modified, [new("body", NotePatchAction.Append, "Text")]);
+ await tools.DeleteNotePage("microsoft365:account", "notebook", "section", "page", fake.Modified);
+ Assert.All(fake.Calls, call => Assert.Equal(account.AccountId, ((MailAccount)call.Args[0]!).AccountId));
+ Assert.All(fake.Calls.SelectMany(call => call.Args).OfType(), task => Assert.Equal(account.AccountId, task.AccountId));
+ setSettings(new(Enabled: true, AllowWrites: true, AllowSending: true, WorkspaceAccountIds: ["microsoft365:account"]));
+ await tools.UpdateEvent("microsoft365:account", "event", draft);
+ await tools.DeleteEvent("microsoft365:account", "calendar", "event");
+ Assert.Contains("create_response_draft", JsonSerializer.Serialize(tools.GetActionGuide("mail")));
+ Assert.Contains("create_note_page", JsonSerializer.Serialize(tools.GetActionGuide("notes")));
+ });
+ }
+
+ [Fact]
+ public async Task ResponseDraftUsesProviderThreadAndExactRecipientsThenAcceptsAttachment()
+ {
+ await WithTools(async (store, tools, fake, account, mailbox, setSettings) =>
+ {
+ setSettings(new(Enabled: true, AllowWrites: true, MailboxIds: [mailbox.Id]));
+ var message = new MailMessage(mailbox.Id, "source", "thread", "", "inbox", "Source subject",
+ new("Sender", "sender@example.com"), [], DateTimeOffset.UtcNow, "Snippet", "Full source content", false, true, false, MailImportance.Normal, [], null);
+ await store.ApplySyncPageAsync("seed", new([message], null, false));
+ var result = JsonSerializer.SerializeToElement(await tools.CreateResponseDraft(mailbox.Id, "source", MailResponseKind.Reply,
+ "accounts@example.com", "Please review"));
+ var id = result.GetProperty("Id").GetString()!;
+ var saved = (await store.GetLocalDraftAsync(id))!;
+ Assert.Equal("remote-response", saved.ProviderDraftId);
+ Assert.Equal("accounts@example.com", saved.To);
+ Assert.Equal("", saved.Cc); Assert.Equal("", saved.Bcc);
+ Assert.Contains("Full source content", saved.Body);
+ Assert.Equal(BetterMail.Core.ConversationThread.ThreadIdentity(message), saved.ConversationIdentity);
+ Assert.False(saved.IsQueued);
+ Assert.Single(fake.Calls, call => call.Name == "CreateResponseDraftAsync");
+ var bytes = new byte[] { 1, 2, 3 };
+ var upload = await tools.BeginAttachmentUpload(mailbox.Id, id, saved.UpdatedAt, "proof.zip", "application/zip", bytes.Length, Convert.ToHexString(SHA256.HashData(bytes)));
+ await tools.UploadAttachmentChunk(mailbox.Id, upload.Id, 0, Convert.ToBase64String(bytes));
+ await tools.CompleteAttachmentUpload(mailbox.Id, upload.Id);
+ saved = (await store.GetLocalDraftAsync(id))!;
+ Assert.Equal("remote-response", saved.ProviderDraftId);
+ Assert.Equal(bytes, Assert.Single(saved.Attachments).ContentBytes);
+ Assert.False(saved.IsQueued);
+ await tools.SetMailState(mailbox.Id, "source", isRead: false, isFlagged: true, isPinned: true);
+ Assert.Single(await store.GetMailActionsAsync());
+ Assert.True((await store.GetMessageAsync(mailbox.Id, "source"))!.IsPinned);
+ });
+ }
+
+ private static async Task WithTools(Func, Task> test)
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-mcp-content-" + Guid.NewGuid());
+ try
+ {
+ await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), Convert.ToHexString(RandomNumberGenerator.GetBytes(32)));
+ await store.InitializeAsync();
+ var account = new MailAccount("microsoft365", "account", "tenant", "me@example.com", "Me", ProviderCapabilities.Mail | ProviderCapabilities.Calendar | ProviderCapabilities.Contacts | ProviderCapabilities.Tasks | ProviderCapabilities.Notes);
+ var mailbox = new Mailbox(account.AccountId, account.EmailAddress, account.DisplayName);
+ await store.SaveAccountAsync(account); await store.SaveMailboxAsync(mailbox);
+ var workspace = DispatchProxy.Create();
+ var fake = (ContentProvider)(object)workspace;
+ var mail = DispatchProxy.Create();
+ ((ContentProvider)(object)mail).Calls = fake.Calls;
+ var settings = new McpConfiguration(Enabled: true, MailboxIds: [mailbox.Id], DriveAccountIds: ["microsoft365:account"]);
+ var tools = new McpMailTools(store, () => settings, () => Task.CompletedTask, (_, _, _) => throw new InvalidOperationException("Must not send"),
+ filesProvider: () => workspace, mailProvider: () => mail);
+ await test(store, tools, fake, account, mailbox, value => settings = value);
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
+ public class ContentProvider : DispatchProxy
+ {
+ public List<(string Name, object?[] Args)> Calls = [];
+ public DateTimeOffset Modified { get; } = new(2026, 9, 13, 12, 0, 0, TimeSpan.Zero);
+ protected override object? Invoke(MethodInfo? method, object?[]? args)
+ {
+ args ??= [];
+ Calls.Add((method!.Name, args));
+ var account = (MailAccount)args[0]!;
+ var now = Modified;
+ var list = new TaskListInfo("list", "Work", account.AccountId);
+ var page = new NotePage("page", "section", "Page", now, 0, 0, account.AccountId, account.ProviderId);
+ object? result = method.Name switch
+ {
+ "GetCalendarsAsync" => new CalendarInfo[] { new("calendar", "Calendar", null, true, account.AccountId) },
+ "CreateEventAsync" or "UpdateEventAsync" => new CalendarEvent("event", "calendar", "Event", now, now.AddHours(1), null, AccountId: account.AccountId),
+ "SearchContactsAsync" or "SearchSharedContactsAsync" => new ContactInfo[] { new("contact", "Ada", ["ada@example.com"], account.AccountId) },
+ "CreateContactAsync" or "UpdateContactAsync" => new ContactInfo("contact", "Ada", [], account.AccountId),
+ "GetTaskListsAsync" => new TaskListInfo[] { list },
+ "CreateTaskListAsync" or "RenameTaskListAsync" => list,
+ "GetTasksAsync" => new TaskInfo[] { new("task", "list", "Task", null, false, account.AccountId) },
+ "CreateTaskAsync" or "UpdateTaskAsync" or "SetTaskCompletedAsync" => new TaskInfo("task", "list", "Task", null, true, account.AccountId),
+ "GetNotebooksAsync" => new NoteNotebook[] { new("notebook", "Notes", account.AccountId, account.ProviderId) },
+ "GetSectionsAsync" => new NoteSection[] { new("section", "notebook", "Section", account.AccountId, account.ProviderId) },
+ "GetPagesAsync" => new NotePage[] { page },
+ "GetPageContentAsync" => new NotePageContent("page", "section", account.AccountId, account.ProviderId, "Notes
"),
+ "CreatePageAsync" => page,
+ "CreateResponseDraftAsync" => new CloudDraft("remote-response", account.AccountId, ((Mailbox)args[1]!).Id,
+ ((DraftMessage)args[4]!) with { Cc = [new("Unexpected", "other@example.com")] }, now, ConversationId: "thread"),
+ _ when method.ReturnType == typeof(Task) => null,
+ _ => throw new InvalidOperationException("Unexpected provider call: " + method.Name)
+ };
+ if (method.ReturnType == typeof(Task)) return Task.CompletedTask;
+ return typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(method.ReturnType.GetGenericArguments()[0]).Invoke(null, [result]);
+ }
+ }
+}
diff --git a/tests/BetterMail.Tests/McpTests.cs b/tests/BetterMail.Tests/McpTests.cs
index 377d8d7..7221308 100644
--- a/tests/BetterMail.Tests/McpTests.cs
+++ b/tests/BetterMail.Tests/McpTests.cs
@@ -320,6 +320,11 @@ 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" })
+ 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);
+ Assert.Contains("create_response_draft", Assert.IsType(Assert.Single(guide.Content)).Text);
var result = await client.CallToolAsync("list_mailboxes", cancellationToken: TestContext.Current.CancellationToken);
Assert.False(result.IsError == true);
Assert.Contains(mailbox.Id, Assert.IsType(Assert.Single(result.Content)).Text);
diff --git a/tests/BetterMail.Tests/McpUploadTests.cs b/tests/BetterMail.Tests/McpUploadTests.cs
index 87693f0..2e2acf1 100644
--- a/tests/BetterMail.Tests/McpUploadTests.cs
+++ b/tests/BetterMail.Tests/McpUploadTests.cs
@@ -8,6 +8,33 @@ namespace BetterMail.Tests;
public sealed class McpUploadTests
{
+ [Fact]
+ public async Task CapabilitiesExplainAttachmentWorkflowAndReportRegisteredTools()
+ {
+ await WithStore((store, tools, draft, files) =>
+ {
+ var capabilities = JsonSerializer.SerializeToElement(tools.GetCapabilities());
+ Assert.True(capabilities.GetProperty("AllowWrites").GetBoolean());
+ var names = capabilities.GetProperty("registeredTools").EnumerateArray().Select(item => item.GetString()).ToArray();
+ Assert.Contains("begin_attachment_upload", names);
+ Assert.Contains("upload_attachment_chunk", names);
+ Assert.Contains("complete_attachment_upload", names);
+ Assert.Contains("get_capabilities", names);
+ Assert.Equal(names.Length, names.Distinct().Count());
+ var registered = typeof(McpMailTools).GetMethods().Select(method =>
+ Attribute.GetCustomAttribute(method, typeof(ModelContextProtocol.Server.McpServerToolAttribute)))
+ .OfType().Select(attribute => attribute.Name).Order(StringComparer.Ordinal);
+ Assert.Equal(registered, names);
+ var usage = capabilities.GetProperty("usage");
+ Assert.Contains("same endpoint", usage.GetProperty("discovery").GetString());
+ var guide = usage.GetProperty("mailAttachments");
+ Assert.Contains("no attachment parameter", guide.GetProperty("summary").GetString());
+ Assert.Contains("expectedUpdatedAt", guide.GetProperty("steps")[1].GetString());
+ Assert.Contains("public-sharing authorization", guide.GetProperty("oversized").GetString());
+ return Task.CompletedTask;
+ });
+ }
+
[Fact]
public async Task ChunksRejectGapsDifferentRetriesAndWrongOwnersThenCompleteExactlyOnce()
{
diff --git a/tests/BetterMail.Tests/OutboxServiceTests.cs b/tests/BetterMail.Tests/OutboxServiceTests.cs
index 8cf1917..6d8b38b 100644
--- a/tests/BetterMail.Tests/OutboxServiceTests.cs
+++ b/tests/BetterMail.Tests/OutboxServiceTests.cs
@@ -19,6 +19,7 @@ await WithStore(async (store, reopen, account, mailbox) =>
var action = Assert.Single(await store.GetMailActionsAsync());
Assert.True(action.NeedsSendReview);
Assert.False(action.CanCancel);
+ Assert.False(await store.RetryMailActionAsync(action.Id));
Assert.False(await store.CancelMailActionAsync(action.Id));
await using var restarted = reopen();
await restarted.InitializeAsync();
diff --git a/tools/BetterMail.UiPreview/Program.Busy.cs b/tools/BetterMail.UiPreview/Program.Busy.cs
new file mode 100644
index 0000000..4bdc776
--- /dev/null
+++ b/tools/BetterMail.UiPreview/Program.Busy.cs
@@ -0,0 +1,44 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Styling;
+using Avalonia.VisualTree;
+using BetterMail.App;
+using BetterMail.Core;
+using System.Diagnostics;
+
+internal static partial class Program
+{
+ private static async Task CaptureBusyAsync(string output)
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-busy-preview-" + Guid.NewGuid());
+ Directory.CreateDirectory(directory);
+ var vm = new MainWindowViewModel(null, directory, _ => { }, _ => { }, null);
+ vm.Accounts.Add(PreviewProvider.Account);
+ await vm.InitializeAsync();
+ var now = DateTimeOffset.Now;
+ vm.BusyActions.Add(new("move", "account", "account:alex@example.test", "message", MailActionKind.Move,
+ "Project review", now.AddDays(-1), DestinationName: "Archive", Error: "The specified object was not found in the store.",
+ FailureCount: 22, LastAttemptAt: now.AddMinutes(-2), LastFailureAt: now.AddMinutes(-2)));
+ vm.BusyActions.Add(new("send", "account", "account:alex@example.test", "draft", MailActionKind.Send,
+ "Planning notes", now, Error: "The connection was interrupted before delivery was confirmed.", SendAttempted: true, FailureCount: 1));
+ vm.ShowOutboxCommand.Execute(null);
+ var window = new MainWindow { DataContext = vm, Width = 1200, Height = 900, WindowDecorations = WindowDecorations.None, Position = new PixelPoint(0, 0) };
+ try
+ {
+ window.Show();
+ window.Position = new PixelPoint(0, 0);
+ foreach (var theme in new[] { ThemeVariant.Light, ThemeVariant.Dark })
+ {
+ Application.Current!.RequestedThemeVariant = theme;
+ await Task.Delay(700);
+ if (!window.GetVisualDescendants().OfType().Any(text => text.Text == "The specified object was not found in the store."))
+ throw new InvalidOperationException("Busy failure details were not rendered.");
+ using var capture = Process.Start(new ProcessStartInfo("python3") { ArgumentList = {
+ "tools/BetterMail.UiPreview/capture.py", Path.Combine(output, "busy-" + theme.ToString().ToLowerInvariant() + ".png"), "1200", "900" } })!;
+ await capture.WaitForExitAsync();
+ if (capture.ExitCode != 0) throw new InvalidOperationException("Busy capture failed.");
+ }
+ }
+ finally { window.Close(); Directory.Delete(directory, true); }
+ }
+}
diff --git a/tools/BetterMail.UiPreview/Program.cs b/tools/BetterMail.UiPreview/Program.cs
index aa2d40a..8be9240 100644
--- a/tools/BetterMail.UiPreview/Program.cs
+++ b/tools/BetterMail.UiPreview/Program.cs
@@ -23,7 +23,7 @@ public static void Main(string[] args)
using var stop = new CancellationTokenSource();
Dispatcher.UIThread.Post(async () =>
{
- try { if (args.Contains("--workspaces")) await CaptureWorkspaceFixesAsync(output); else if (args.Contains("--search")) await CaptureSearchAsync(output); else await CaptureAsync(output); }
+ try { if (args.Contains("--busy")) await CaptureBusyAsync(output); else if (args.Contains("--workspaces")) await CaptureWorkspaceFixesAsync(output); else if (args.Contains("--search")) await CaptureSearchAsync(output); else await CaptureAsync(output); }
catch (Exception ex) { Console.Error.WriteLine(ex); Environment.ExitCode = 1; }
finally { stop.Cancel(); }
});