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
96 changes: 54 additions & 42 deletions src/BetterMail.App/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly Func<TimeSpan, CancellationToken, Task> _waitForMarkRead;
private readonly NewMailNotificationCoordinator _newMailNotifications;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _draftSyncLocks = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _providerInitializationLocks = new(StringComparer.Ordinal);
private readonly Dictionary<string, IAccountProvider> _accountProviders = new(StringComparer.Ordinal);
private readonly Dictionary<string, IMailProvider> _mailProviders = new(StringComparer.Ordinal);
private Microsoft365AuthService? _microsoftAuthentication;
Expand Down Expand Up @@ -1505,34 +1506,40 @@ await RunBusyAsync("Loading mailbox…", async () =>
await LoadFoldersAsync();
await PrimeNewMailNotificationsAsync();
await LoadMessagesAsync();

try
{
if (_provider is null)
{
foreach (var providerId in Accounts.Select(static account => account.ProviderId).Distinct(StringComparer.Ordinal))
{
await EnsureProviderAsync(providerId);
}
RefreshMailProviderRouter();
}
((AsyncCommand)SyncCommand).Refresh();
((AsyncCommand)ToggleReadCommand).Refresh();
RefreshWorkspaceCommands();
await LoadAttachmentsAsync(SelectedMessage);
_ = RepairMissingSubjectsAsync(Messages);
}
catch (InvalidOperationException exception)
{
Error = exception.Message;
}
});
RaisePropertyChanged(nameof(SettingsAccounts));
RaisePropertyChanged(nameof(SettingsMailboxes));
_initializationComplete = true;
RaisePropertyChanged(nameof(HasAccounts));
RaisePropertyChanged(nameof(ShowOnboarding));
RaisePropertyChanged(nameof(ShowFullScreenLoader));

// Local mail is ready. Account/keyring setup must not keep the full-window loader open.
try
{
if (_provider is null)
{
foreach (var providerId in Accounts.Select(static account => account.ProviderId).Distinct(StringComparer.Ordinal).ToArray())
{
try { await EnsureProviderAsync(providerId); }
catch (Exception exception) when (exception is not OperationCanceledException)
{
Error = exception.Message;
Status = "Cached mail ready — account setup needs attention";
}
}
RefreshMailProviderRouter();
}
((AsyncCommand)SyncCommand).Refresh();
((AsyncCommand)ToggleReadCommand).Refresh();
RefreshWorkspaceCommands();
await LoadAttachmentsAsync(SelectedMessage);
_ = RepairMissingSubjectsAsync(Messages);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
Error = exception.Message;
}
await RefreshNextCalendarEventAsync();
StartAutoSync();
_ = ReconcileAllDraftsInBackgroundAsync();
Expand Down Expand Up @@ -1613,29 +1620,34 @@ private async Task AddConnectedAccountAsync(MailAccount account)

private async Task EnsureProviderAsync(string providerId, CancellationToken cancellationToken = default)
{
if (_store is null || _accountProviders.ContainsKey(providerId))
{
return;
}
switch (providerId)
if (_store is null) return;
// Cached mail is usable during startup, so a concurrent retry must share provider setup.
var gate = _providerInitializationLocks.GetOrAdd(providerId, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
try
{
case Microsoft365AuthService.Id:
_microsoftAuthentication = await Microsoft365AuthService.CreateAsync(
Microsoft365Options.Create(_dataDirectory), cancellationToken);
_accountProviders[providerId] = _microsoftAuthentication;
_mailProviders[providerId] = new Microsoft365MailProvider(_microsoftAuthentication);
_workspaceProvider ??= new Microsoft365WorkspaceProvider(_microsoftAuthentication);
break;
case GoogleAuthService.Id:
cancellationToken.ThrowIfCancellationRequested();
_googleAuthentication = new GoogleAuthService(GoogleOptions.Create(), _store);
_accountProviders[providerId] = _googleAuthentication;
_mailProviders[providerId] = new GoogleGmailProvider(_googleAuthentication);
break;
default:
throw new InvalidOperationException($"The account provider '{providerId}' is not installed.");
if (_accountProviders.ContainsKey(providerId)) return;
switch (providerId)
{
case Microsoft365AuthService.Id:
_microsoftAuthentication = await Microsoft365AuthService.CreateAsync(
Microsoft365Options.Create(_dataDirectory), cancellationToken);
_accountProviders[providerId] = _microsoftAuthentication;
_mailProviders[providerId] = new Microsoft365MailProvider(_microsoftAuthentication);
_workspaceProvider ??= new Microsoft365WorkspaceProvider(_microsoftAuthentication);
break;
case GoogleAuthService.Id:
cancellationToken.ThrowIfCancellationRequested();
_googleAuthentication = new GoogleAuthService(GoogleOptions.Create(), _store);
_accountProviders[providerId] = _googleAuthentication;
_mailProviders[providerId] = new GoogleGmailProvider(_googleAuthentication);
break;
default:
throw new InvalidOperationException($"The account provider '{providerId}' is not installed.");
}
RefreshMailProviderRouter();
}
RefreshMailProviderRouter();
finally { gate.Release(); }
}

private void RefreshMailProviderRouter()
Expand Down
18 changes: 10 additions & 8 deletions src/BetterMail.Core/EncryptedMailStore.Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,7 @@ public Task<IReadOnlyList<MailMessage>> SearchFilteredMailAsync(SearchQuery quer
string Add(object value) { var name = "$p" + parameters.Count; parameters.Add((name, value)); return name; }
if (allowedMailboxIds is not null)
clauses.Add($"mailbox_id IN (SELECT value FROM json_each({Add(JsonSerializer.Serialize(allowedMailboxIds))}))");
if (query.Terms.Count > 0)
{
var parameter = Add(query.FtsText);
clauses.Add(_optimizedSearch
? $"rowid IN (SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH {parameter})"
: $"rowid IN (SELECT rowid FROM message_search WHERE message_search MATCH {parameter} UNION SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH {parameter})");
}
var ftsParameter = query.Terms.Count > 0 ? Add(query.FtsText) : null;
foreach (var key in new[] { "from", "to", "cc", "subject" })
{
if (query[key] is not { } value) continue;
Expand Down Expand Up @@ -98,6 +92,14 @@ public Task<IReadOnlyList<MailMessage>> SearchFilteredMailAsync(SearchQuery quer
else clauses.Add($"julianday(received_at) {date.Operator} {Julian(instant)}");
}
}
return QueryMessagesAsync("WHERE " + string.Join(" AND ", clauses), limit, false, cancellationToken, parameters.ToArray());
return QuerySearchMessagesAsync(optimized =>
{
var where = "WHERE " + string.Join(" AND ", clauses);
if (ftsParameter is not null)
where += optimized
? $" AND rowid IN (SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH {ftsParameter})"
: $" AND rowid IN (SELECT rowid FROM message_search WHERE message_search MATCH {ftsParameter} UNION SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH {ftsParameter})";
return where;
}, limit, false, cancellationToken, parameters.ToArray());
}
}
75 changes: 75 additions & 0 deletions src/BetterMail.Core/EncryptedMailStore.SearchReads.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Microsoft.Data.Sqlite;

namespace BetterMail.Core;

public sealed partial class EncryptedMailStore
{
private readonly SemaphoreSlim _searchReadGate = new(1, 1);
private SqliteConnection? _searchReadConnection;
private bool _searchReaderDisposed;

// Searches must neither wait for sync nor block writes or other interactive readers.
private async Task<T> WithSearchReadAsync<T>(Func<SqliteConnection, bool, Task<T>> action, CancellationToken token)
{
await _searchReadGate.WaitAsync(token).ConfigureAwait(false);
try
{
return await Task.Run(async () =>
{
ObjectDisposedException.ThrowIf(_searchReaderDisposed, this);
_ = GetConnection();
if (_searchReadConnection is null)
{
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = databasePath, Mode = SqliteOpenMode.ReadOnly,
Cache = SqliteCacheMode.Private, Pooling = false, Password = ValidateHexKey(key)
}.ToString());
try
{
await connection.OpenAsync(token).ConfigureAwait(false);
await ExecuteAsync(connection, "PRAGMA query_only = ON; PRAGMA busy_timeout = 1000;", token).ConfigureAwait(false);
_searchReadConnection = connection;
}
catch { await connection.DisposeAsync().ConfigureAwait(false); throw; }
}
var reader = _searchReadConnection;
// SqliteCommand.Cancel is a no-op. Interrupt this dedicated connection instead.
// Dispose the registration before releasing the gate so cancellation cannot hit a later search.
using var cancellation = token.Register(() => SQLitePCL.raw.sqlite3_interrupt(reader.Handle));
try
{
token.ThrowIfCancellationRequested();
using var transaction = reader.BeginTransaction(deferred: true);
// Reading the marker pins a WAL snapshot: index selection and the query see
// the same schema even if maintenance drops the legacy index concurrently.
var optimized = await MigrationCompleteAsync(reader, "message-search-v2", token).ConfigureAwait(false);
var result = await action(reader, optimized).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
transaction.Commit();
return result;
}
catch (SqliteException) when (token.IsCancellationRequested)
{
throw new OperationCanceledException(token);
}
}, token).ConfigureAwait(false);
}
finally { _searchReadGate.Release(); }
}

private async Task DisposeSearchReaderAsync()
{
await _searchReadGate.WaitAsync().ConfigureAwait(false);
try
{
_searchReaderDisposed = true;
if (_searchReadConnection is not null)
{
await _searchReadConnection.DisposeAsync().ConfigureAwait(false);
_searchReadConnection = null;
}
}
finally { _searchReadGate.Release(); }
}
}
14 changes: 11 additions & 3 deletions src/BetterMail.Core/EncryptedMailStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -691,13 +691,14 @@ public Task<IReadOnlyList<MailMessage>> SearchMailboxAsync(string? mailboxId, st
{
if (string.IsNullOrWhiteSpace(query))
{
return GetMessagesAsync(mailboxId: mailboxId, limit: limit, cancellationToken: cancellationToken);
return QuerySearchMessagesAsync(_ => "WHERE ($mailbox IS NULL OR mailbox_id = $mailbox)",
limit, true, cancellationToken, ("$mailbox", (object?)mailboxId ?? DBNull.Value));
}

var ftsQuery = string.Join(' ', query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static term => $"\"{term.Replace("\"", "\"\"")}\"*"));
return QueryMessagesAsync(
(_optimizedSearch
return QuerySearchMessagesAsync(
optimized => (optimized
? "WHERE rowid IN (SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH $query)"
: "WHERE rowid IN (SELECT rowid FROM message_search WHERE message_search MATCH $query UNION SELECT rowid FROM message_search_v2 WHERE message_search_v2 MATCH $query)") +
" AND ($mailbox IS NULL OR mailbox_id = $mailbox)",
Expand Down Expand Up @@ -1413,6 +1414,7 @@ public async ValueTask DisposeAsync()
await _gate.WaitAsync().ConfigureAwait(false);
try
{
await DisposeSearchReaderAsync().ConfigureAwait(false);
await DisposeWorkspaceReaderAsync().ConfigureAwait(false);
await DisposeFolderReaderAsync().ConfigureAwait(false);
await DisposeMessageReaderAsync().ConfigureAwait(false);
Expand All @@ -1436,6 +1438,12 @@ private Task<IReadOnlyList<MailMessage>> QueryMessagesAsync(
params (string Name, object Value)[] parameters) =>
WithLockAsync(connection => QueryMessagesOnConnectionAsync(connection, where, limit, includeBody, cancellationToken, parameters), cancellationToken);

private Task<IReadOnlyList<MailMessage>> QuerySearchMessagesAsync(
Func<bool, string> where, int limit, bool includeBody, CancellationToken cancellationToken,
params (string Name, object Value)[] parameters) =>
WithSearchReadAsync((connection, optimized) => QueryMessagesOnConnectionAsync(
connection, where(optimized), limit, includeBody, cancellationToken, parameters), cancellationToken);

private Task<IReadOnlyList<MailMessage>> QueryReadingPaneMessagesAsync(
string where, int limit, bool includeBody, CancellationToken cancellationToken,
params (string Name, object Value)[] parameters) =>
Expand Down
40 changes: 36 additions & 4 deletions src/BetterMail.Microsoft365/Microsoft365AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,41 @@ private Microsoft365AuthService(IPublicClientApplication client, MsalCacheHelper
ProviderCapabilities.Files |
ProviderCapabilities.Notes;

public static async Task<Microsoft365AuthService> CreateAsync(
public static Task<Microsoft365AuthService> CreateAsync(
Microsoft365Options options,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default) =>
CreateAsync(options, storage => MsalCacheHelper.CreateAsync(storage), TimeSpan.FromSeconds(15), cancellationToken);

internal static async Task<Microsoft365AuthService> CreateAsync(
Microsoft365Options options,
Func<StorageCreationProperties, Task<MsalCacheHelper>> createCache,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var initialization = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
initialization.CancelAfter(timeout);
var token = initialization.Token;
try
{
// MSAL cache creation and verification can block on native keyrings or file locks.
// ConfigureAwait alone does not move their synchronous work off the calling UI thread.
return await Task.Run(() => CreateCoreAsync(options, createCache, token), token)
.WaitAsync(token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (token.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
throw new InvalidOperationException(
"Microsoft account setup timed out waiting for secure storage. Your cached mail is available. " +
"Unlock your system keyring or Keychain, then retry from Settings > Accounts > Re-authenticate.");
}
}

private static async Task<Microsoft365AuthService> CreateCoreAsync(
Microsoft365Options options,
Func<StorageCreationProperties, Task<MsalCacheHelper>> createCache,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Directory.CreateDirectory(options.DataDirectory);
var client = PublicClientApplicationBuilder
.Create(options.ClientId)
Expand All @@ -74,10 +105,11 @@ public static async Task<Microsoft365AuthService> CreateAsync(
.WithMacKeyChain("com.bettermail.tokens", "BetterMail")
.Build();

var cache = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false);
var cache = await createCache(storage).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
cache.VerifyPersistence();
cache.RegisterCache(client.UserTokenCache);
cancellationToken.ThrowIfCancellationRequested();
cache.RegisterCache(client.UserTokenCache);
return new Microsoft365AuthService(client, cache);
}

Expand Down
Loading
Loading