diff --git a/src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs b/src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs index 715a6bc..354cdc3 100644 --- a/src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs +++ b/src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs @@ -12,7 +12,7 @@ public sealed partial class EncryptedMailStore 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, + action = action with { AutomaticRecoveryAttempted = true, AutomaticRecoveryVersion = MailAction.CurrentRecoveryVersion, AutomaticRecoveryDetails = "Automatic recovery was attempted. Check status if it did not complete." }; await WriteActionAsync(connection, null, action, token).ConfigureAwait(false); return action; @@ -27,7 +27,7 @@ public Task RecordAutomaticRecoveryAsync(string id, string details, Cancellation }, token); public Task RecoverMailActionAsync(MailAction expected, MailMessage verified, string expectedIdentity, - CancellationToken token = default) => WithLockAsync(async connection => + CancellationToken token = default, string? resolvedDestinationId = null) => WithLockAsync(async connection => { await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(token).ConfigureAwait(false); var actions = await ReadActionsAsync(connection, transaction, token).ConfigureAwait(false); @@ -40,7 +40,8 @@ public Task RecoverMailActionAsync(MailAction expected, MailMessage verifi 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; + var destinationId = resolvedDestinationId ?? current.DestinationId; + var alreadyMoved = current.Kind == MailActionKind.Move && verified.FolderId == destinationId; foreach (var action in related) await WriteActionAsync(connection, transaction, action with { @@ -49,6 +50,7 @@ await WriteActionAsync(connection, transaction, action with 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, + DestinationId = action.Id == current.Id ? destinationId : action.DestinationId, RecoveredAtFailureCount = action.Id == current.Id ? action.FailureCount : action.RecoveredAtFailureCount, RetryAuthorizedAtFailureCount = action.Id == current.Id ? action.FailureCount : action.RetryAuthorizedAtFailureCount }, token).ConfigureAwait(false); @@ -56,7 +58,7 @@ await WriteActionAsync(connection, transaction, action with !(action.Id == current.Id && alreadyMoved)); await UpsertMessageAsync(connection, transaction, verified with { - FolderId = desired?.DestinationId ?? verified.FolderId, + FolderId = desired?.Id == current.Id ? destinationId! : 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)) diff --git a/src/BetterMail.Core/IMailRecoveryProvider.cs b/src/BetterMail.Core/IMailRecoveryProvider.cs new file mode 100644 index 0000000..d8ff320 --- /dev/null +++ b/src/BetterMail.Core/IMailRecoveryProvider.cs @@ -0,0 +1,13 @@ +namespace BetterMail.Core; + +/// Provider lookups for recovering a queued action without relying on subject search. +public interface IMailRecoveryProvider +{ + // Null means this provider has no exact lookup. Empty means the lookup completed without + // a match; it does not establish permanent deletion. Return two matches to expose ambiguity. + Task?> FindMessagesByIdentityAsync( + MailAccount account, Mailbox mailbox, string internetMessageId, CancellationToken token = default); + + Task ResolveFolderIdAsync( + MailAccount account, Mailbox mailbox, string folderId, CancellationToken token = default); +} diff --git a/src/BetterMail.Core/MailAction.cs b/src/BetterMail.Core/MailAction.cs index 1cc355f..498888a 100644 --- a/src/BetterMail.Core/MailAction.cs +++ b/src/BetterMail.Core/MailAction.cs @@ -25,15 +25,18 @@ public sealed record MailAction( bool? PreviousRead = null, bool? PreviousFlagged = null, bool? PreviousPinned = null, int FailureCount = 0, int? RetryAuthorizedAtFailureCount = null, DateTimeOffset? LastAttemptAt = null, DateTimeOffset? LastFailureAt = null, string? LastError = null, - bool AutomaticRecoveryAttempted = false, string? AutomaticRecoveryDetails = null, int? RecoveredAtFailureCount = null) + bool AutomaticRecoveryAttempted = false, string? AutomaticRecoveryDetails = null, int? RecoveredAtFailureCount = null, + int AutomaticRecoveryVersion = 0) { + public const int CurrentRecoveryVersion = 1; [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 || (AutomaticRecoveryAttempted || RecoveredAtFailureCount is not null) && FailureCount > (RecoveredAtFailureCount ?? -1)) && RetryAuthorizedAtFailureCount != FailureCount; public bool HasAutomaticRecoveryDetails => !string.IsNullOrEmpty(AutomaticRecoveryDetails); - public bool CanAutomaticallyRecover => CanRecover && !AutomaticRecoveryAttempted && + public bool CanAutomaticallyRecover => CanRecover && + (!AutomaticRecoveryAttempted || AutomaticRecoveryVersion < CurrentRecoveryVersion && RecoveredAtFailureCount is null) && 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; diff --git a/src/BetterMail.Core/MailActionDiagnostics.cs b/src/BetterMail.Core/MailActionDiagnostics.cs index 2d6b8fa..a3ce4ef 100644 --- a/src/BetterMail.Core/MailActionDiagnostics.cs +++ b/src/BetterMail.Core/MailActionDiagnostics.cs @@ -19,24 +19,25 @@ public async Task CheckAsync(MailAccount account, Mailbox mailbox, strin var cached = await store.GetMessageAsync(mailbox.Id, action.ProviderId, token); foreach (var id in new[] { action.ProviderId }.Concat(action.PreviousProviderIds ?? []).Distinct().Take(10)) { + MailMessage message; 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); + message = await provider.GetMessageAsync(account, mailbox, id, token); } - catch (HttpRequestException error) when (error.StatusCode == HttpStatusCode.NotFound) { } + catch (HttpRequestException error) when (error.StatusCode == HttpStatusCode.NotFound) { continue; } + 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 await DescribeAsync(account, mailbox, action, message, token); } 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 (results, complete) = await FindIdentityAsync(account, mailbox, cached, 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 + if (complete && matches.Length == 1) return await DescribeAsync(account, mailbox, action, matches[0], token); + return matches.Length > 1 || !complete ? "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."; + : "No exact identity match was found in the server lookup. That does not prove deletion or delivery. Check the provider mailbox and destination; the pending action is kept."; } public async Task TryAutomaticRecoveryAsync(MailAccount account, Mailbox mailbox, string actionId, CancellationToken token = default) @@ -73,27 +74,45 @@ public async Task RecoverAsync(MailAccount account, Mailbox mailbox, str catch (HttpRequestException error) when (error.StatusCode == HttpStatusCode.NotFound) { } if (verified is null) { - var results = await provider.SearchMessagesAsync(account, mailbox, cached.Subject, 100, token); + var (results, complete) = await FindIdentityAsync(account, mailbox, cached, token); var matches = results.Where(message => message.MailboxId == mailbox.Id && !message.IsDeleted && message.InternetMessageId == cached.InternetMessageId) .DistinctBy(message => message.ProviderId).ToArray(); - if (results.Count >= 100 || matches.Length != 1) - throw new InvalidOperationException("No unique exact identity match in the bounded search. Nothing was changed."); + if (!complete || matches.Length != 1) + throw new InvalidOperationException("No unique exact identity match in the server lookup. Nothing was changed."); verified = await provider.GetMessageAsync(account, mailbox, matches[0].ProviderId, token); if (verified.ProviderId != matches[0].ProviderId) throw new InvalidOperationException("The message changed during lookup. Check status again."); } if (verified.MailboxId != mailbox.Id || verified.IsDeleted || verified.InternetMessageId != cached.InternetMessageId) throw new InvalidOperationException("The server message does not match the cached identity. Nothing was changed."); + var destinationId = await ResolveDestinationAsync(account, mailbox, expected, token); authorize?.Invoke(); - if (!await store.RecoverMailActionAsync(expected, verified with { Body = verified.Body ?? cached.Body }, cached.InternetMessageId, token)) + if (!await store.RecoverMailActionAsync(expected, verified with { Body = verified.Body ?? cached.Body }, cached.InternetMessageId, token, destinationId)) throw new InvalidOperationException("The queued action changed or an earlier action must finish. Check status again."); - return expected.Kind == MailActionKind.Move && verified.FolderId == expected.DestinationId + return expected.Kind == MailActionKind.Move && verified.FolderId == destinationId ? "The message is already at the destination. The move was confirmed without moving it again." : "The server ID was verified and repaired. Retry queued with the original destination and failure history kept."; } - private static string Describe(MailAction action, MailMessage message) => - message.FolderId == action.DestinationId && action.Kind == MailActionKind.Move + private async Task<(IReadOnlyList Results, bool Complete)> FindIdentityAsync( + MailAccount account, Mailbox mailbox, MailMessage cached, CancellationToken token) + { + if (provider is IMailRecoveryProvider recovery && + await recovery.FindMessagesByIdentityAsync(account, mailbox, cached.InternetMessageId!, token) is { } exact) + return (exact, true); + var results = await provider.SearchMessagesAsync(account, mailbox, cached.Subject, 100, token); + return (results, results.Count < 100); + } + + private async Task ResolveDestinationAsync(MailAccount account, Mailbox mailbox, MailAction action, CancellationToken token) + { + if (action.Kind == MailActionKind.Move && provider is IMailRecoveryProvider recovery) + return await recovery.ResolveFolderIdAsync(account, mailbox, action.DestinationId!, token); + return action.DestinationId; + } + + private async Task DescribeAsync(MailAccount account, Mailbox mailbox, MailAction action, MailMessage message, CancellationToken token) => + message.FolderId == await ResolveDestinationAsync(account, mailbox, action, token) && action.Kind == MailActionKind.Move ? "The message was found in the requested destination. No further move appears necessary. Use Recover and retry to verify and confirm this move without moving it again." : message.ProviderId != action.ProviderId ? "The same message was found under a different server ID. Retrying the old ID will still fail. Use Recover and retry to verify its identity again and repair the queued action. No mail was deleted or changed by this check." diff --git a/src/BetterMail.Core/MailProviderRouter.cs b/src/BetterMail.Core/MailProviderRouter.cs index 1f8081c..bfd0865 100644 --- a/src/BetterMail.Core/MailProviderRouter.cs +++ b/src/BetterMail.Core/MailProviderRouter.cs @@ -2,6 +2,7 @@ namespace BetterMail.Core; public sealed class MailProviderRouter(IEnumerable<(string ProviderId, IMailProvider Provider)> providers) : IMailProvider, + IMailRecoveryProvider, ISharedMailboxProvider { private readonly IReadOnlyDictionary _providers = providers.ToDictionary( @@ -27,6 +28,14 @@ public Task GetMessageAsync(MailAccount account, Mailbox mailbox, s For(account).GetMessageAsync(account, mailbox, messageId, cancellationToken); public Task> SearchMessagesAsync(MailAccount account, Mailbox mailbox, string query, int limit = 250, CancellationToken cancellationToken = default) => For(account).SearchMessagesAsync(account, mailbox, query, limit, cancellationToken); + public Task?> FindMessagesByIdentityAsync(MailAccount account, Mailbox mailbox, string internetMessageId, CancellationToken token = default) => + For(account) is IMailRecoveryProvider recovery + ? recovery.FindMessagesByIdentityAsync(account, mailbox, internetMessageId, token) + : Task.FromResult?>(null); + public Task ResolveFolderIdAsync(MailAccount account, Mailbox mailbox, string folderId, CancellationToken token = default) => + For(account) is IMailRecoveryProvider recovery + ? recovery.ResolveFolderIdAsync(account, mailbox, folderId, token) + : Task.FromResult(folderId); public Task> GetMessageHeadersAsync(MailAccount account, Mailbox mailbox, string messageId, CancellationToken cancellationToken = default) => For(account).GetMessageHeadersAsync(account, mailbox, messageId, cancellationToken); public Task MoveMessageAsync(MailAccount account, Mailbox mailbox, string messageId, string destinationFolderId, CancellationToken cancellationToken = default) => diff --git a/src/BetterMail.Microsoft365/Microsoft365MailProvider.cs b/src/BetterMail.Microsoft365/Microsoft365MailProvider.cs index 225084d..cdff3a8 100644 --- a/src/BetterMail.Microsoft365/Microsoft365MailProvider.cs +++ b/src/BetterMail.Microsoft365/Microsoft365MailProvider.cs @@ -6,10 +6,21 @@ namespace BetterMail.Microsoft365; -public sealed class Microsoft365MailProvider( - Microsoft365AuthService authentication, - HttpClient? httpClient = null) : IMailProvider, ISharedMailboxProvider +public sealed class Microsoft365MailProvider : IMailProvider, ISharedMailboxProvider, IMailRecoveryProvider { + private readonly Func> _getAccessToken; + private readonly HttpClient _httpClient; + + public Microsoft365MailProvider(Microsoft365AuthService authentication, HttpClient? httpClient = null) + : this(authentication.GetAccessTokenAsync, httpClient ?? new HttpClient + { BaseAddress = new Uri("https://graph.microsoft.com/v1.0/") }) { } + + internal Microsoft365MailProvider(Func> getAccessToken, HttpClient httpClient) + { + _getAccessToken = getAccessToken; + _httpClient = httpClient; + } + public bool SupportsCloudDrafts => true; internal const long SimpleAttachmentLimitBytes = 3L * 1024 * 1024; @@ -21,11 +32,6 @@ public sealed class Microsoft365MailProvider( internal const string FolderSelect = "id,displayName,unreadItemCount,totalItemCount,childFolderCount"; - private readonly HttpClient _httpClient = httpClient ?? new HttpClient - { - BaseAddress = new Uri("https://graph.microsoft.com/v1.0/") - }; - public async Task> GetFoldersAsync( MailAccount account, Mailbox mailbox, @@ -62,7 +68,7 @@ public async Task> GetFoldersAsync( } var wellKnownIds = new Dictionary(StringComparer.Ordinal); - foreach (var name in new[] { "inbox", "sentitems" }) + foreach (var name in new[] { "inbox", "sentitems", "deleteditems", "archive", "junkemail" }) { try { @@ -215,6 +221,41 @@ internal static string SearchEndpoint(MailAccount account, Mailbox mailbox, stri return $"{MailboxPath(account, mailbox)}/messages?$search={escapedQuery}&$select={MessageSelect}&$top={limit}"; } + public async Task?> FindMessagesByIdentityAsync( + MailAccount account, Mailbox mailbox, string internetMessageId, CancellationToken token = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(internetMessageId); + var filter = Uri.EscapeDataString($"internetMessageId eq '{internetMessageId.Replace("'", "''")}'"); + string? endpoint = $"{MailboxPath(account, mailbox)}/messages?$filter={filter}&$select={MessageSelect}&$top=2"; + var matches = new Dictionary(StringComparer.Ordinal); + var pages = new HashSet(StringComparer.Ordinal); + while (endpoint is not null) + { + if (pages.Count >= 20 || !pages.Add(endpoint)) + throw new InvalidOperationException("The exact message lookup was incomplete. Nothing was changed."); + using var document = await GetJsonAsync(account, endpoint, token).ConfigureAwait(false); + foreach (var element in document.RootElement.GetProperty("value").EnumerateArray()) + { + var message = MapMessage(mailbox, element); + if (message.InternetMessageId != internetMessageId || message.IsDeleted) + throw new InvalidOperationException("Microsoft returned a different identity during message recovery."); + matches[message.ProviderId] = message; + if (matches.Count == 2) return matches.Values.ToArray(); + } + endpoint = document.RootElement.TryGetProperty("@odata.nextLink", out var next) ? next.GetString() : null; + } + return matches.Values.ToArray(); + } + + public async Task ResolveFolderIdAsync( + MailAccount account, Mailbox mailbox, string folderId, CancellationToken token = default) + { + var path = MailboxPath(account, mailbox); + if (folderId is not ("inbox" or "sentitems" or "deleteditems" or "archive" or "junkemail" or "drafts")) return folderId; + using var document = await GetJsonAsync(account, $"{path}/mailFolders/{folderId}?$select=id", token).ConfigureAwait(false); + return RequiredString(document.RootElement, "id"); + } + internal static bool NeedsHydration(JsonElement message) => !message.TryGetProperty("@removed", out _) && (!message.TryGetProperty("subject", out _) || @@ -874,7 +915,7 @@ private async Task CreateRequestAsync(MailAccount account, H var request = new HttpRequestMessage(method, endpoint); request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", - await authentication.GetAccessTokenAsync(account.AccountId, cancellationToken).ConfigureAwait(false)); + await _getAccessToken(account.AccountId, cancellationToken).ConfigureAwait(false)); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Add("Prefer", "outlook.body-content-type=html"); if (endpoint.Contains("/messages/delta", StringComparison.OrdinalIgnoreCase)) diff --git a/tests/BetterMail.Tests/DeletedMailRecoveryTests.cs b/tests/BetterMail.Tests/DeletedMailRecoveryTests.cs new file mode 100644 index 0000000..7848baa --- /dev/null +++ b/tests/BetterMail.Tests/DeletedMailRecoveryTests.cs @@ -0,0 +1,205 @@ +using System.Net; +using System.Text.Json; +using BetterMail.App; +using BetterMail.Core; +using BetterMail.Microsoft365; +using Microsoft.Data.Sqlite; + +namespace BetterMail.Tests; + +public sealed class DeletedMailRecoveryTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task AlreadyDeletedMessageClearsFreshAndLegacyPausedActionsWithoutAnotherMove(bool shared, bool legacy) + { + await WithPendingDelete(async (store, path, account, mailbox, action, token) => + { + if (legacy) await WriteLegacyRecoveryAsync(path, action.Id, token); + var handler = new RecoveryHandler(); + using var http = Client(handler); + IMailProvider provider = new MailProviderRouter([(account.ProviderId, Provider(http))]); + var diagnostics = new MailActionDiagnostics(store, provider); + Assert.Contains("found in the requested destination", await diagnostics.CheckAsync(account, mailbox, action.Id, token)); + var vm = new MainWindowViewModel(store, Path.GetDirectoryName(path)!, _ => { }, _ => { }, null, provider); + vm.Accounts.Add(account); + vm.Mailboxes.Add(mailbox); + await (Task)typeof(MainWindowViewModel).GetMethod("ProcessMailActionsAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.Invoke(vm, null)!; + Assert.Empty(vm.BusyActions); + Assert.Empty(await store.GetMailActionsAsync(token)); + var recovered = (await store.GetMailActionAsync(action.Id, token))!; + Assert.True(recovered.Accepted); + Assert.Equal("trash-real-id", recovered.DestinationId); + Assert.Equal("new-id", recovered.ProviderId); + Assert.Equal(1, recovered.FailureCount); + Assert.Null(await store.GetMessageAsync(mailbox.Id, "old-id", token)); + Assert.Equal("trash-real-id", (await store.GetMessageAsync(mailbox.Id, "new-id", token))!.FolderId); + Assert.All(handler.Requests, request => Assert.Equal(HttpMethod.Get, request.Method)); + Assert.DoesNotContain(handler.Requests, request => request.Uri.Query.Contains("$search")); + var lookup = handler.Requests.First(request => request.Uri.Query.Contains("$filter")); + Assert.Contains("internetMessageId eq ''", Uri.UnescapeDataString(lookup.Uri.Query)); + Assert.Contains(shared ? "/users/" : "/me/", lookup.Uri.AbsolutePath); + Assert.False(await diagnostics.TryAutomaticRecoveryAsync(account, mailbox, action.Id, token)); + }, shared); + } + + [Theory] + [InlineData("missing")] + [InlineData("duplicate")] + [InlineData("wrong-identity")] + [InlineData("changed-refetch")] + [InlineData("looping-page")] + public async Task MissingAmbiguousOrChangedMessagesKeepThePendingActionAndDoNotMutateMail(string mode) + { + await WithPendingDelete(async (store, path, account, mailbox, action, token) => + { + using (var http = Client(new RecoveryHandler { Mode = mode })) + Assert.False(await new MailActionDiagnostics(store, Provider(http)).TryAutomaticRecoveryAsync(account, mailbox, action.Id, token)); + var pending = Assert.Single(await store.GetMailActionsAsync(token)); + Assert.Equal("old-id", pending.ProviderId); + Assert.Equal("deleteditems", pending.DestinationId); + Assert.True(pending.IsRetryPaused); + Assert.Equal(MailAction.CurrentRecoveryVersion, pending.AutomaticRecoveryVersion); + Assert.NotNull(await store.GetMessageAsync(mailbox.Id, "old-id", token)); + // Persist the recovery claim: another startup must not repeatedly execute it. + await using var reopened = new EncryptedMailStore(path, new string('A', 64)); + await reopened.InitializeAsync(token); + Assert.Null(await reopened.ClaimAutomaticRecoveryAsync(action.Id, token)); + }); + } + + [Fact] + public async Task ExactIdentityFoundOutsideDestinationRepairsAndAuthorizesOneRetry() + { + await WithPendingDelete(async (store, _, account, mailbox, action, token) => + { + var handler = new RecoveryHandler { Mode = "source" }; + using var http = Client(handler); + var diagnostics = new MailActionDiagnostics(store, Provider(http)); + Assert.True(await diagnostics.TryAutomaticRecoveryAsync(account, mailbox, action.Id, token)); + var retry = await store.StartMailActionAsync(action.Id, token); + Assert.NotNull(retry); + Assert.Equal("new-id", retry.ProviderId); + Assert.Equal("trash-real-id", retry.DestinationId); + Assert.Equal("inbox-real-id", retry.SourceFolderId); + await store.FailMailActionAsync(action.Id, "Still not found", token); + Assert.False(await diagnostics.TryAutomaticRecoveryAsync(account, mailbox, action.Id, token)); + Assert.Null(await store.StartMailActionAsync(action.Id, token)); + Assert.All(handler.Requests, request => Assert.Equal(HttpMethod.Get, request.Method)); + }); + } + + [Fact] + public async Task FolderDiscoveryResolvesDeleteArchiveAndJunkAliases() + { + var account = Account; + var mailbox = new Mailbox(account.AccountId, account.EmailAddress, "Author"); + using var http = Client(new RecoveryHandler()); + var folders = await Provider(http).GetFoldersAsync(account, mailbox, TestContext.Current.CancellationToken); + foreach (var alias in new[] { "inbox", "sentitems", "deleteditems", "archive", "junkemail" }) + Assert.Equal(alias == "deleteditems" ? "trash-real-id" : alias + "-real-id", Assert.Single(folders, folder => folder.WellKnownName == alias).ProviderId); + } + + [Fact] + public async Task StatusCheckReportsMissingDestinationWithoutTreatingTheMessageAsMissing() + { + await WithPendingDelete(async (store, _, account, mailbox, action, token) => + { + var handler = new RecoveryHandler { Mode = "missing-folder-current" }; + using var http = Client(handler); + var error = await Assert.ThrowsAsync(() => new MailActionDiagnostics(store, Provider(http)).CheckAsync(account, mailbox, action.Id, token)); + Assert.Equal(HttpStatusCode.NotFound, error.StatusCode); + Assert.DoesNotContain(handler.Requests, request => request.Uri.Query.Contains("$filter")); + Assert.False(Assert.Single(await store.GetMailActionsAsync(token)).AutomaticRecoveryAttempted); + }); + } + + private static MailAccount Account => new("microsoft365", "account", "tenant", "author@example.test", "Author", ProviderCapabilities.Mail); + private static HttpClient Client(RecoveryHandler handler) => new(handler) { BaseAddress = new Uri("https://graph.microsoft.com/v1.0/") }; + private static Microsoft365MailProvider Provider(HttpClient client) => new((_, _) => Task.FromResult("fictional-token"), client); + + private static async Task WithPendingDelete(Func test, bool shared = false) + { + var directory = Path.Combine(Path.GetTempPath(), "bettermail-deleted-recovery-" + Guid.NewGuid()); + var path = Path.Combine(directory, "mail.db"); + var token = TestContext.Current.CancellationToken; + try + { + await using var store = new EncryptedMailStore(path, new string('A', 64)); + await store.InitializeAsync(token); + var account = Account; + var mailbox = new Mailbox(account.AccountId, shared ? "shared+ops@example.test" : account.EmailAddress, "Author", IsShared: shared); + using var json = JsonDocument.Parse(RecoveryHandler.Message("old-id", "inbox-real-id")); + var message = Microsoft365MailProvider.MapMessage(mailbox, json.RootElement) with { Subject = "Old subject" }; + await store.ApplySyncPageAsync("seed", new([message], null, false), token); + var action = await store.QueueMoveAsync(account, message, new(mailbox.Id, "deleteditems", "Deleted Items", 0, 0), token); + await store.StartMailActionAsync(action.Id, token); + await store.FailMailActionAsync(action.Id, "The specified object was not found in the store.", token); + await test(store, path, account, mailbox, action, token); + } + finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } + } + + private static async Task WriteLegacyRecoveryAsync(string path, string id, CancellationToken token) + { + await using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString()); + await connection.OpenAsync(token); + await using var key = connection.CreateCommand(); + key.CommandText = $"PRAGMA key = '{new string('A', 64)}';"; + await key.ExecuteNonQueryAsync(token); + await using var command = connection.CreateCommand(); + command.CommandText = "UPDATE mail_actions SET payload_json = json_remove(json_set(payload_json, '$.AutomaticRecoveryAttempted', json('true')), '$.AutomaticRecoveryVersion') WHERE id = $id;"; + command.Parameters.AddWithValue("$id", id); + await command.ExecuteNonQueryAsync(token); + } + + private sealed class RecoveryHandler : HttpMessageHandler + { + public string Mode { get; init; } = "destination"; + public List<(HttpMethod Method, Uri Uri)> Requests { get; } = []; + public static string Message(string id, string folder, string identity = "") => JsonSerializer.Serialize(new + { id, parentFolderId = folder, internetMessageId = identity, subject = "Changed subject", body = new { content = "Full body", contentType = "text" }, isRead = true }); + protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + Requests.Add((request.Method, request.RequestUri!)); + var uri = request.RequestUri!; + var path = uri.AbsolutePath; + if (path.EndsWith("/messages/old-id")) + return Mode == "missing-folder-current" ? Json(Message("old-id", "inbox-real-id")) : Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + var folder = Mode == "source" ? "inbox-real-id" : "trash-real-id"; + if (path.EndsWith("/messages/new-id")) + return Json(Message("new-id", folder, Mode == "changed-refetch" ? "" : "")); + if (uri.Query.Contains("$filter")) + { + Assert.DoesNotContain("$search", uri.Query); + var next = uri.GetLeftPart(UriPartial.Path) + "?$filter=continued&$skiptoken=next"; + var continued = uri.Query.Contains("$skiptoken"); + return Mode switch + { + "missing" => Json("{\"value\":[]}"), + "wrong-identity" => Json("{\"value\":[" + Message("new-id", folder, "") + "]}"), + "looping-page" => Json(JsonSerializer.Serialize(new Dictionary { ["value"] = Array.Empty(), ["@odata.nextLink"] = next })), + "duplicate" when continued => Json("{\"value\":[" + Message("copy-id", folder) + "]}"), + "duplicate" => Json("{\"value\":[" + Message("new-id", folder) + "],\"@odata.nextLink\":" + JsonSerializer.Serialize(next) + "}"), + _ when !continued => Json("{\"value\":[],\"@odata.nextLink\":" + JsonSerializer.Serialize(next) + "}"), + _ => Json("{\"value\":[" + Message("new-id", folder) + "]}") + }; + } + if (path.EndsWith("/mailFolders")) + return Json(JsonSerializer.Serialize(new { value = new[] { "inbox", "sentitems", "deleteditems", "archive", "junkemail" } + .Select(alias => new { id = alias == "deleteditems" ? "trash-real-id" : alias + "-real-id", displayName = alias, unreadItemCount = 0, totalItemCount = 1 }) })); + if (path.Contains("/mailFolders/")) + { + if (Mode == "missing-folder-current") return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + var alias = path[(path.LastIndexOf('/') + 1)..]; + return Json(JsonSerializer.Serialize(new { id = alias == "deleteditems" ? "trash-real-id" : alias + "-real-id" })); + } + throw new InvalidOperationException("Unexpected provider operation: " + request.Method + " " + uri); + } + private static Task Json(string content) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(content) }); + } +}