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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/BetterMail.Core/EncryptedMailStore.ActionRecovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,7 +27,7 @@ public Task RecordAutomaticRecoveryAsync(string id, string details, Cancellation
}, token);

public Task<bool> 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);
Expand All @@ -40,7 +40,8 @@ public Task<bool> 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
{
Expand All @@ -49,14 +50,15 @@ 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);
var desired = related.LastOrDefault(action => action.Kind == MailActionKind.Move && !action.Accepted &&
!(action.Id == current.Id && alreadyMoved));
await UpsertMessageAsync(connection, transaction, verified with
{
FolderId = desired?.DestinationId ?? verified.FolderId,
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))
Expand Down
13 changes: 13 additions & 0 deletions src/BetterMail.Core/IMailRecoveryProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace BetterMail.Core;

/// <summary>Provider lookups for recovering a queued action without relying on subject search.</summary>
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<IReadOnlyList<MailMessage>?> FindMessagesByIdentityAsync(
MailAccount account, Mailbox mailbox, string internetMessageId, CancellationToken token = default);

Task<string> ResolveFolderIdAsync(
MailAccount account, Mailbox mailbox, string folderId, CancellationToken token = default);
}
7 changes: 5 additions & 2 deletions src/BetterMail.Core/MailAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 35 additions & 16 deletions src/BetterMail.Core/MailActionDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,25 @@ public async Task<string> 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<bool> TryAutomaticRecoveryAsync(MailAccount account, Mailbox mailbox, string actionId, CancellationToken token = default)
Expand Down Expand Up @@ -73,27 +74,45 @@ public async Task<string> 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<MailMessage> 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<string?> 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<string> 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."
Expand Down
9 changes: 9 additions & 0 deletions src/BetterMail.Core/MailProviderRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace BetterMail.Core;

public sealed class MailProviderRouter(IEnumerable<(string ProviderId, IMailProvider Provider)> providers) :
IMailProvider,
IMailRecoveryProvider,
ISharedMailboxProvider
{
private readonly IReadOnlyDictionary<string, IMailProvider> _providers = providers.ToDictionary(
Expand All @@ -27,6 +28,14 @@ public Task<MailMessage> GetMessageAsync(MailAccount account, Mailbox mailbox, s
For(account).GetMessageAsync(account, mailbox, messageId, cancellationToken);
public Task<IReadOnlyList<MailMessage>> SearchMessagesAsync(MailAccount account, Mailbox mailbox, string query, int limit = 250, CancellationToken cancellationToken = default) =>
For(account).SearchMessagesAsync(account, mailbox, query, limit, cancellationToken);
public Task<IReadOnlyList<MailMessage>?> FindMessagesByIdentityAsync(MailAccount account, Mailbox mailbox, string internetMessageId, CancellationToken token = default) =>
For(account) is IMailRecoveryProvider recovery
? recovery.FindMessagesByIdentityAsync(account, mailbox, internetMessageId, token)
: Task.FromResult<IReadOnlyList<MailMessage>?>(null);
public Task<string> 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<IReadOnlyList<MailHeader>> 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) =>
Expand Down
Loading
Loading