diff --git a/docs/ui/README.md b/docs/ui/README.md
index 7c98452..0f48da0 100644
--- a/docs/ui/README.md
+++ b/docs/ui/README.md
@@ -92,7 +92,7 @@ People opens from the local contact cache and mail history, then refreshes in th
## Mail navigation, search and mailbox layout
-Search uses a labelled Filters button, a compact results header, and rows that stretch across the results panel. Opening the filter form preserves plain terms such as `bob`; focusing a nonempty search field or pressing Enter reruns the search, even while an older provider search is pending. References reviewed in Mobbin: [Front advanced search](https://mobbin.com/screens/43b2e9ff-ac6c-4240-9d4a-201fc24fd597), [Skiff search](https://mobbin.com/screens/cce393ee-75b8-48b4-934d-346f1b1583ab), and [Notion Mail filters](https://mobbin.com/screens/37573e33-3175-4605-9082-f0fc04d436be).
+Search uses a compact results header with an Advanced filter text button, a full-width loading line above the results, and rows that stretch across the results panel. The main search input has no separate Search/Filters buttons; results dismiss on outside click. Opening the filter form preserves plain terms such as `bob`; focusing a nonempty search field or pressing Enter reruns the search, even while an older provider search is pending. References reviewed in Mobbin: [Front advanced search](https://mobbin.com/screens/43b2e9ff-ac6c-4240-9d4a-201fc24fd597), [Skiff search](https://mobbin.com/screens/cce393ee-75b8-48b4-934d-346f1b1583ab), and [Notion Mail filters](https://mobbin.com/screens/37573e33-3175-4605-9082-f0fc04d436be).
Double-click opens the clicked message immediately and hydrates its cached conversation afterward. Thread headers retain selectable text without an extra down-arrow button. Mail-list refreshes restore selected row identities and preserve scroll unless the user navigates or scrolls meanwhile. Sync-issue drafts have a direct × delete action with per-row progress; deleting another issue does not wait for the previous network deletion.
@@ -123,3 +123,70 @@ The next-event chip opens today's cached agenda with a live Now marker, past/cur
| Event preview | [Light](screenshots/event-details-light.png) · [Dark](screenshots/event-details-dark.png) |
All examples use fictional data from the offline native preview. Regression tests cover persisted retry counts, badge recovery, default contact ownership, safe partial updates, richer contact search, cached agenda boundaries, selection-dependent updates and keyboard-accessible thread headers.
+
+### Separate mail window actions
+
+**Settings → Mail & notifications → Separate mail windows** provides independent **Stay open** / **Close** choices for Reply, Reply all, Forward, Archive, Delete, Move, junk status, read status, flag and pin. Existing profiles default to Stay open. Close happens after a move/state action is durably queued, before the main-window feedback animation or provider sync finishes. Reply/forward closes the source preview after the composer opens. Cancelling a folder picker or rejecting an action keeps the preview open. Pending work and retry failures remain available in the main window.
+
+The pending-action line is anchored to the bottom of the entire conversation header in both the reading pane and separate previews.
+
+| Change | Preview |
+| --- | --- |
+| Window action settings (light) | [Screenshot](screenshots/preview-action-settings-light.png) |
+| Window action settings (dark) | [Screenshot](screenshots/preview-action-settings-dark.png) |
+| Thread header pending action | [Screenshot](screenshots/thread-action-bottom-light.png) |
+
+
+### Advanced search and smart badges
+
+The unfocused global search input renders structured filters as green badges when valid and red badges for invalid syntax or unresolved account/mail-folder references. For example, `type:Mails` is invalid while `type:mail` is valid. Focusing the input restores the exact query text. Settings now places Close to the left of its title.
+
+Advanced filter automatically generates the query from grouped controls. Types, accounts/shared mailboxes, included/excluded folders, states and categories support multiple choices. Date conditions use date/time pickers and comparison operators; Attachments offers Yes, No and Not set. Mail-specific controls are hidden and omitted from the query when Mail is not among the selected types. Hidden values remain available if Mail is selected again. The editable source is the main search box; the form's generated-query preview is read-only.
+
+Repeat `type:`, `account:`, `in:` or `category:` for alternatives. Repeated `is:` conditions are combined (unread and flagged, for example); read plus unread is rejected. `notin:` excludes a folder and its descendants. The folder browser is filtered by selected accounts, supports multiple checked folders, and retains removable selected-path entries. Picker-qualified `ownerID::path` values avoid matching the same path in another mailbox/account. Unqualified paths match across selected accounts.
+
+With **Drive as the only selected type**, the path browser loads Drive folders on expansion. Drive folder searches include descendants and filter the synced file index before applying the result limit; the results panel explicitly identifies this cached scope. This does not add a new Drive provider. Other mail-only filters affect the Mail portion of multi-type searches.
+
+| Search UI | Preview |
+| --- | --- |
+| Smart filters (light / dark) | [Light](screenshots/search-badges-light.png) · [Dark](screenshots/search-badges-dark.png) |
+| Advanced filter | [Light](screenshots/search-options-light.png) · [Dark](screenshots/search-options-dark.png) |
+| Multi-select types | [Screenshot](screenshots/search-type-multiselect-light.png) |
+| Date/time conditions | [Light](screenshots/search-date-filters-light.png) · [Dark](screenshots/search-date-filters-dark.png) |
+| Folder exclusions | [Screenshot](screenshots/search-folder-exclusions-dark.png) |
+| Drive folder browser | [Screenshot](screenshots/search-drive-folders-dark.png) |
+
+Native checks exercise badge/text focus transitions, outside-click dismissal, full-width progress, mail-group visibility, form submission, folder browsing and the left Settings close button. All screenshots use fictional offline data.
+
+
+### Workspace follow-up (PR #20)
+
+People opens cached contacts immediately and starts background loading, including when the mailbox tree is not populated yet. New contact and Table/Cards live in one toolbar, with no Refresh action. The table uses aligned Name, Email, Company / role and Actions columns; cards separate identity, contact details and actions.
+
+To Do uses the same account/folder styles as mail and calendar. Double-click or Enter opens the selected task. The due-date picker binds to a local `DateTime` adapter while provider dates remain `DateTimeOffset`; editing a saved due date no longer produces a cast error. Due date and time have separate labels. Refresh is handled by synchronization rather than a toolbar button.
+
+Notes expansion preserves tree containers and handles the native expand event. Errors use a short per-node summary with full details in a flyout; cached notes remain available. Microsoft's OneNote library limit (10008) still applies; this change does not bypass it. See [Microsoft's error documentation](https://learn.microsoft.com/en-us/graph/onenote-error-codes).
+
+Settings → Mail & notifications now defaults the primary reply action to Reply all; turning the setting off chooses Reply to sender. Explicit choices remain in the menu, and open previews follow preference changes. Signature editing and template previews open in independent windows, keeping native browser surfaces out of the scrolling settings page. The next-event header is opaque and mail actions collapse into More earlier on narrow windows.
+
+Mobbin references reviewed: [Qatalog cards](https://mobbin.com/screens/e22383a1-0ec8-44c0-92a6-43a4a756dd7c), [Pipedrive contacts table](https://mobbin.com/screens/2eb73474-8d4d-431f-9a7c-3d5e8c6d5921), [Asana task details](https://mobbin.com/screens/e7810f1e-ed82-419f-9041-bae74f62d627).
+
+Run `dotnet run --project tools/BetterMail.UiPreview -c Release -- /tmp/bettermail-workspaces --workspaces` under Xvfb to verify first-load contacts, task double-click/date binding, Notes expansion and signature surface separation. Screenshots use fictional offline data only.
+
+
+
+
+
+
+
+
+
+
+Task refresh follow-up: F5 remains available for an explicit refresh. On workspace sync completion, To Do reads the updated local cache and reconciles rows without clearing navigation or changing editor fields. Reopening the module with unchanged accounts also consumes the cache. Regression coverage verifies remote additions, updates and removals while preserving selection and unsaved edits.
+
+
+Reading-pane performance: full-body and thread queries now use a dedicated, persistent encrypted read-only WAL connection, separate from both sync writes and folder navigation. Thread membership is selected through the thread index before message lookup. The regression holds the writer gate, an uncommitted write transaction and the folder-reader gate while checking that the full compressed body renders in the reading pane. It also checks committed-snapshot isolation and cancellation of a superseded read.
+
+Event descriptions are now editable. Unchanged HTML is preserved; edited descriptions save as plain text. Mail menus and thread More menus offer Create event, opening an unsaved calendar event populated from the full cached email body and subject. The account's editable calendar is preferred, and an existing open editor is protected. Older API callers that omit a body leave existing descriptions untouched. Regression coverage exercises description edits, clearing, HTML preservation, and missing email bodies. The offline native preview exercises email-to-event creation with fictional data.
+
+
diff --git a/docs/ui/screenshots/default-reply-settings-dark.png b/docs/ui/screenshots/default-reply-settings-dark.png
new file mode 100644
index 0000000..8924748
Binary files /dev/null and b/docs/ui/screenshots/default-reply-settings-dark.png differ
diff --git a/docs/ui/screenshots/event-from-email-dark.png b/docs/ui/screenshots/event-from-email-dark.png
new file mode 100644
index 0000000..18d94e9
Binary files /dev/null and b/docs/ui/screenshots/event-from-email-dark.png differ
diff --git a/docs/ui/screenshots/mail-header-narrow-dark.png b/docs/ui/screenshots/mail-header-narrow-dark.png
new file mode 100644
index 0000000..bfc8e6f
Binary files /dev/null and b/docs/ui/screenshots/mail-header-narrow-dark.png differ
diff --git a/docs/ui/screenshots/notes-expanded-dark.png b/docs/ui/screenshots/notes-expanded-dark.png
new file mode 100644
index 0000000..86a2b05
Binary files /dev/null and b/docs/ui/screenshots/notes-expanded-dark.png differ
diff --git a/docs/ui/screenshots/notes-limit-dark.png b/docs/ui/screenshots/notes-limit-dark.png
new file mode 100644
index 0000000..20bfce4
Binary files /dev/null and b/docs/ui/screenshots/notes-limit-dark.png differ
diff --git a/docs/ui/screenshots/people-cards-dark.png b/docs/ui/screenshots/people-cards-dark.png
index d8b2f19..18c4711 100644
Binary files a/docs/ui/screenshots/people-cards-dark.png and b/docs/ui/screenshots/people-cards-dark.png differ
diff --git a/docs/ui/screenshots/people-cards-light.png b/docs/ui/screenshots/people-cards-light.png
index 79e8f81..bc04de8 100644
Binary files a/docs/ui/screenshots/people-cards-light.png and b/docs/ui/screenshots/people-cards-light.png differ
diff --git a/docs/ui/screenshots/people-table-dark.png b/docs/ui/screenshots/people-table-dark.png
new file mode 100644
index 0000000..4f08d07
Binary files /dev/null and b/docs/ui/screenshots/people-table-dark.png differ
diff --git a/docs/ui/screenshots/people-table-light.png b/docs/ui/screenshots/people-table-light.png
index 7709f7e..9610a91 100644
Binary files a/docs/ui/screenshots/people-table-light.png and b/docs/ui/screenshots/people-table-light.png differ
diff --git a/docs/ui/screenshots/preview-action-settings-dark.png b/docs/ui/screenshots/preview-action-settings-dark.png
new file mode 100644
index 0000000..b5544e9
Binary files /dev/null and b/docs/ui/screenshots/preview-action-settings-dark.png differ
diff --git a/docs/ui/screenshots/preview-action-settings-light.png b/docs/ui/screenshots/preview-action-settings-light.png
new file mode 100644
index 0000000..980bded
Binary files /dev/null and b/docs/ui/screenshots/preview-action-settings-light.png differ
diff --git a/docs/ui/screenshots/search-badges-dark.png b/docs/ui/screenshots/search-badges-dark.png
new file mode 100644
index 0000000..e020fa8
Binary files /dev/null and b/docs/ui/screenshots/search-badges-dark.png differ
diff --git a/docs/ui/screenshots/search-badges-light.png b/docs/ui/screenshots/search-badges-light.png
new file mode 100644
index 0000000..6e7effa
Binary files /dev/null and b/docs/ui/screenshots/search-badges-light.png differ
diff --git a/docs/ui/screenshots/search-date-filters-dark.png b/docs/ui/screenshots/search-date-filters-dark.png
new file mode 100644
index 0000000..ea8f4a3
Binary files /dev/null and b/docs/ui/screenshots/search-date-filters-dark.png differ
diff --git a/docs/ui/screenshots/search-date-filters-light.png b/docs/ui/screenshots/search-date-filters-light.png
new file mode 100644
index 0000000..91ffd2c
Binary files /dev/null and b/docs/ui/screenshots/search-date-filters-light.png differ
diff --git a/docs/ui/screenshots/search-drive-folders-dark.png b/docs/ui/screenshots/search-drive-folders-dark.png
new file mode 100644
index 0000000..f3c9063
Binary files /dev/null and b/docs/ui/screenshots/search-drive-folders-dark.png differ
diff --git a/docs/ui/screenshots/search-folder-exclusions-dark.png b/docs/ui/screenshots/search-folder-exclusions-dark.png
new file mode 100644
index 0000000..0ea94a3
Binary files /dev/null and b/docs/ui/screenshots/search-folder-exclusions-dark.png differ
diff --git a/docs/ui/screenshots/search-options-dark.png b/docs/ui/screenshots/search-options-dark.png
index baa42d1..142084b 100644
Binary files a/docs/ui/screenshots/search-options-dark.png and b/docs/ui/screenshots/search-options-dark.png differ
diff --git a/docs/ui/screenshots/search-options-light.png b/docs/ui/screenshots/search-options-light.png
index fba357c..5b73aca 100644
Binary files a/docs/ui/screenshots/search-options-light.png and b/docs/ui/screenshots/search-options-light.png differ
diff --git a/docs/ui/screenshots/search-results-dark.png b/docs/ui/screenshots/search-results-dark.png
index 5fd3a3e..c1d9d2c 100644
Binary files a/docs/ui/screenshots/search-results-dark.png and b/docs/ui/screenshots/search-results-dark.png differ
diff --git a/docs/ui/screenshots/search-results-light.png b/docs/ui/screenshots/search-results-light.png
index c4e88c4..50f17b0 100644
Binary files a/docs/ui/screenshots/search-results-light.png and b/docs/ui/screenshots/search-results-light.png differ
diff --git a/docs/ui/screenshots/search-syntax-dark.png b/docs/ui/screenshots/search-syntax-dark.png
index 3480e3d..3f93fa8 100644
Binary files a/docs/ui/screenshots/search-syntax-dark.png and b/docs/ui/screenshots/search-syntax-dark.png differ
diff --git a/docs/ui/screenshots/search-type-multiselect-light.png b/docs/ui/screenshots/search-type-multiselect-light.png
new file mode 100644
index 0000000..635cb3f
Binary files /dev/null and b/docs/ui/screenshots/search-type-multiselect-light.png differ
diff --git a/docs/ui/screenshots/signature-settings-dark.png b/docs/ui/screenshots/signature-settings-dark.png
new file mode 100644
index 0000000..9cdd342
Binary files /dev/null and b/docs/ui/screenshots/signature-settings-dark.png differ
diff --git a/docs/ui/screenshots/task-editor-dark.png b/docs/ui/screenshots/task-editor-dark.png
new file mode 100644
index 0000000..21bf81a
Binary files /dev/null and b/docs/ui/screenshots/task-editor-dark.png differ
diff --git a/docs/ui/screenshots/tasks-dark.png b/docs/ui/screenshots/tasks-dark.png
new file mode 100644
index 0000000..3e810fd
Binary files /dev/null and b/docs/ui/screenshots/tasks-dark.png differ
diff --git a/docs/ui/screenshots/thread-action-bottom-light.png b/docs/ui/screenshots/thread-action-bottom-light.png
new file mode 100644
index 0000000..a41bb2a
Binary files /dev/null and b/docs/ui/screenshots/thread-action-bottom-light.png differ
diff --git a/src/BetterMail.App/App.axaml.cs b/src/BetterMail.App/App.axaml.cs
index 8aa415e..280198d 100644
--- a/src/BetterMail.App/App.axaml.cs
+++ b/src/BetterMail.App/App.axaml.cs
@@ -130,6 +130,8 @@ private async Task StartMainWindowAsync(
viewModel.DefaultMailPromptShown = preferences.DefaultMailPromptShown;
viewModel.MailSyncRange = preferences.MailSyncRange;
viewModel.ConfigureMailQuickActions(preferences.MailQuickActions);
+ viewModel.ConfigurePreviewActions(preferences.ClosePreviewAfterActions);
+ viewModel.DefaultReplyAll = preferences.DefaultReplyAll;
viewModel.ConfigureAccountOrder(preferences.AccountOrder);
viewModel.DefaultContactOwnerId = preferences.DefaultContactOwnerId;
viewModel.ConfigureMailboxLayout(preferences.MailboxOrder, preferences.CollapsedMailboxes);
@@ -145,6 +147,8 @@ private async Task StartMainWindowAsync(
nameof(MainWindowViewModel.DefaultMailPromptShown) or
nameof(MainWindowViewModel.MailSyncRange) or
nameof(MainWindowViewModel.MailQuickActionsVersion) or
+ nameof(MainWindowViewModel.PreviewActionsVersion) or
+ nameof(MainWindowViewModel.DefaultReplyAll) or
nameof(MainWindowViewModel.SenderPreferencesVersion) or
nameof(MainWindowViewModel.AccountOrderVersion) or
nameof(MainWindowViewModel.ContactImagesEnabled) or
@@ -168,7 +172,8 @@ private async Task StartMainWindowAsync(
PeopleCardView: viewModel.PeopleCardView,
MailboxOrder: viewModel.GetMailboxOrderPreferences(),
CollapsedMailboxes: viewModel.GetCollapsedMailboxPreferences(),
- DefaultContactOwnerId: viewModel.DefaultContactOwnerId));
+ DefaultContactOwnerId: viewModel.DefaultContactOwnerId,
+ ClosePreviewAfterActions: viewModel.GetClosePreviewActions(), DefaultReplyAll: viewModel.DefaultReplyAll));
}
};
viewModel.ConfigureSenderPreferences(
diff --git a/src/BetterMail.App/AppPreferences.cs b/src/BetterMail.App/AppPreferences.cs
index 167404a..9d3b546 100644
--- a/src/BetterMail.App/AppPreferences.cs
+++ b/src/BetterMail.App/AppPreferences.cs
@@ -20,7 +20,7 @@ public sealed record AppPreferences(
bool MailSenderImagesEnabled = false,
bool PeopleCardView = false,
List? MailboxOrder = null,
- List? CollapsedMailboxes = null, string? DefaultContactOwnerId = null);
+ List? CollapsedMailboxes = null, string? DefaultContactOwnerId = null, List? ClosePreviewAfterActions = null, bool DefaultReplyAll = true);
public static class AppPreferencesStore
{
diff --git a/src/BetterMail.App/CalendarWorkspaceView.axaml b/src/BetterMail.App/CalendarWorkspaceView.axaml
index c27011b..358a5c3 100644
--- a/src/BetterMail.App/CalendarWorkspaceView.axaml
+++ b/src/BetterMail.App/CalendarWorkspaceView.axaml
@@ -226,6 +226,11 @@
DisplayMemberBinding="{Binding DisplayName}" AutomationProperties.Name="Event calendar" TabIndex="141" />
+
+
+
_editorSubject; set => SetProperty(ref _editorSubject, value); }
+ public string EditorDescription { get => _editorDescription; set => SetProperty(ref _editorDescription, value); }
public string EditorLocation { get => _editorLocation; set => SetProperty(ref _editorLocation, value); }
public string EditorAttendees { get => _editorAttendees; set => SetProperty(ref _editorAttendees, value); }
public DateTime? EditorStartDate { get => _editorStartDate; set => SetProperty(ref _editorStartDate, value); }
@@ -590,6 +595,7 @@ private Task OpenNewEventAsync()
var start = SelectedDate.Date == _now().Date ? _now().AddMinutes(30) : StartOfDay(SelectedDate).AddHours(9);
start = new DateTimeOffset(start.Year, start.Month, start.Day, start.Hour, start.Minute / 30 * 30, 0, start.Offset);
EditorSubject = "";
+ SetEditorBody(null, false);
EditorLocation = "";
EditorAttendees = "";
SetEditorTimes(start, start.AddHours(1));
@@ -612,6 +618,7 @@ private Task OpenEditEventAsync(CalendarEventItem item)
option.Account.AccountId == item.Source.Account.AccountId &&
option.Calendar.Info.ProviderId == item.Source.Calendar.Info.ProviderId);
EditorSubject = item.Source.Event.Subject;
+ SetEditorBody(item.Source.Event.Body, item.Source.Event.BodyIsHtml);
EditorLocation = item.Source.Event.Location ?? "";
EditorAttendees = string.Join("; ", (item.Source.Event.Attendees ?? []).Select(attendee => attendee.Address.Address));
SetEditorTimes(item.Source.Event.StartsAt, item.Source.Event.EndsAt);
@@ -627,6 +634,32 @@ private Task OpenEditEventAsync(CalendarEventItem item)
return Task.CompletedTask;
}
+ internal async Task OpenFromEmailAsync(MailMessage message, string? accountId)
+ {
+ if (message.Body is null) throw new InvalidOperationException("The full email content is not available yet. Try again after it has loaded.");
+ await OpenNewEventAsync();
+ SelectedEditorCalendar = EditableCalendars.FirstOrDefault(option => option.Account.AccountId == accountId)
+ ?? EditableCalendars.FirstOrDefault();
+ EditorSubject = message.Subject;
+ SetEditorBody(message.Body, message.IsHtml);
+ }
+
+ private void SetEditorBody(string? body, bool isHtml)
+ {
+ _originalBody = body ?? "";
+ _originalBodyIsHtml = isHtml;
+ if (isHtml)
+ {
+ var document = new AngleSharp.Html.Parser.HtmlParser().ParseDocument(_originalBody);
+ foreach (var element in document.QuerySelectorAll("script, style, head")) element.Remove();
+ foreach (var element in document.QuerySelectorAll("p, div, br, li, tr, h1, h2, h3"))
+ element.AppendChild(document.CreateTextNode("\n"));
+ _originalDescription = document.Body?.TextContent.Trim() ?? "";
+ }
+ else _originalDescription = _originalBody;
+ EditorDescription = _originalDescription;
+ }
+
private void SetEditorTimes(DateTimeOffset start, DateTimeOffset end)
{
start = start.ToLocalTime();
@@ -716,7 +749,9 @@ internal CalendarEventDraft BuildDraft(string calendarId)
attendees,
EditorReminderOn,
EditorReminderMinutes,
- EditorRecurs ? BuildRecurrence(DateOnly.FromDateTime(start.Date)) : null);
+ EditorRecurs ? BuildRecurrence(DateOnly.FromDateTime(start.Date)) : null,
+ EditorDescription == _originalDescription ? _originalBody : EditorDescription,
+ EditorDescription == _originalDescription && _originalBodyIsHtml);
}
private CalendarRecurrence BuildRecurrence(DateOnly startDate)
diff --git a/src/BetterMail.App/ConversationThreadView.axaml b/src/BetterMail.App/ConversationThreadView.axaml
index 2654b86..fe0af92 100644
--- a/src/BetterMail.App/ConversationThreadView.axaml
+++ b/src/BetterMail.App/ConversationThreadView.axaml
@@ -43,8 +43,7 @@
-
-
+
@@ -53,6 +52,9 @@
+
+
+
@@ -153,14 +155,14 @@
FontSize="11"
Foreground="{DynamicResource BetterMailAccentBrush}"
TextWrapping="Wrap" />
-
+
+
+
-
-
diff --git a/src/BetterMail.App/ConversationThreadView.axaml.cs b/src/BetterMail.App/ConversationThreadView.axaml.cs
index e8abebb..9e4954c 100644
--- a/src/BetterMail.App/ConversationThreadView.axaml.cs
+++ b/src/BetterMail.App/ConversationThreadView.axaml.cs
@@ -250,7 +250,7 @@ args.Source is TextBox or SelectableTextBlock ||
var command = args.Key switch
{
Key.R when args.KeyModifiers.HasFlag(KeyModifiers.Shift) => viewModel.ReplyAllCommand,
- Key.R => viewModel.ReplyCommand,
+ Key.R => viewModel.DefaultReplyCommand,
Key.F => viewModel.ForwardCommand,
_ => null
};
diff --git a/src/BetterMail.App/ConversationThreadViewModel.cs b/src/BetterMail.App/ConversationThreadViewModel.cs
index df8e6d1..1c860e6 100644
--- a/src/BetterMail.App/ConversationThreadViewModel.cs
+++ b/src/BetterMail.App/ConversationThreadViewModel.cs
@@ -17,16 +17,25 @@ public enum ConversationAction
ToggleFlag,
TogglePin,
ViewHeaders,
- Move
+ Move,
+ CreateEvent
}
public sealed record ConversationActionRequest(
ConversationAction Action,
MailMessage Message,
- MailFolderItem? Destination = null);
+ MailFolderItem? Destination = null, Action? Accepted = null);
public sealed class ConversationThreadViewModel : ViewModelBase
{
+ private bool _defaultReplyAll = true;
+ public bool DefaultReplyAll
+ {
+ get => _defaultReplyAll;
+ set { if (SetProperty(ref _defaultReplyAll, value)) { RaisePropertyChanged(nameof(DefaultReplyLabel)); RaisePropertyChanged(nameof(DefaultReplyCommand)); } }
+ }
+ public string DefaultReplyLabel => DefaultReplyAll ? "Reply all" : "Reply";
+ public ICommand DefaultReplyCommand => DefaultReplyAll ? ReplyAllCommand : ReplyCommand;
private readonly MailContentRenderer _renderer;
private readonly Func? _action;
private readonly Action? _selectionChanged;
@@ -77,6 +86,7 @@ public ConversationThreadViewModel(
SelectMessageCommand = new AsyncCommand(SelectMessageAsync);
ReplyCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.Reply), CanRunAction, allowConcurrent: true);
ReplyAllCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.ReplyAll), CanRunAction, allowConcurrent: true);
+ CreateEventCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.CreateEvent), CanRunAction);
ForwardCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.Forward), CanRunAction, allowConcurrent: true);
ArchiveCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.Archive), CanRunAction, allowConcurrent: true);
DeleteCommand = new AsyncCommand(() => RunActionAsync(ConversationAction.Delete), CanRunAction, allowConcurrent: true);
@@ -99,6 +109,7 @@ public ConversationThreadViewModel(
public ICommand SelectMessageCommand { get; }
public ICommand ReplyCommand { get; }
public ICommand ReplyAllCommand { get; }
+ public ICommand CreateEventCommand { get; }
public ICommand ForwardCommand { get; }
public ICommand ArchiveCommand { get; }
public ICommand DeleteCommand { get; }
@@ -451,6 +462,7 @@ private void RefreshActionCommands()
{
((AsyncCommand)ReplyCommand).Refresh();
((AsyncCommand)ReplyAllCommand).Refresh();
+ ((AsyncCommand)CreateEventCommand).Refresh();
((AsyncCommand)ForwardCommand).Refresh();
((AsyncCommand)ArchiveCommand).Refresh();
((AsyncCommand)DeleteCommand).Refresh();
diff --git a/src/BetterMail.App/MainWindow.axaml b/src/BetterMail.App/MainWindow.axaml
index c02ef18..f3b3848 100644
--- a/src/BetterMail.App/MainWindow.axaml
+++ b/src/BetterMail.App/MainWindow.axaml
@@ -31,7 +31,7 @@
-
+
@@ -47,16 +47,33 @@
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
+
-
-
+
@@ -289,6 +290,7 @@
+
@@ -324,6 +326,7 @@
CornerRadius="0"
HorizontalContentAlignment="Stretch"
IsVisible="{Binding HasNextCalendarEvent}"
+ Background="{DynamicResource BetterMailSurfaceBrush}" ClipToBounds="True"
x:Name="AgendaButton" Click="DayAgendaClicked"
AutomationProperties.Name="{Binding NextCalendarEventSubject, StringFormat='Open next event: {0}'}">
@@ -597,6 +600,7 @@
+
@@ -710,7 +714,8 @@
-
+
+
@@ -917,14 +922,23 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -953,20 +967,11 @@
IsVisible="{Binding IsWorkspaceLoading}" />
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
@@ -1007,8 +1012,9 @@
+
-
+
@@ -1046,23 +1052,24 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -1152,7 +1159,7 @@
VerticalAlignment="Center"
Spacing="6">
-
+
diff --git a/src/BetterMail.App/MainWindow.axaml.cs b/src/BetterMail.App/MainWindow.axaml.cs
index 2d82da4..9231722 100644
--- a/src/BetterMail.App/MainWindow.axaml.cs
+++ b/src/BetterMail.App/MainWindow.axaml.cs
@@ -88,7 +88,7 @@ public MainWindow()
_ => ResponsiveLayoutMode.Wide
};
- internal static bool UsesInlineMailActions(double width) => width >= 840;
+ internal static bool UsesInlineMailActions(double width) => width >= 1000;
internal static ShellKeyAction ShellActionFor(
Key key,
@@ -192,8 +192,6 @@ private void ApplyResponsiveLayout(double width)
ReadingSplitter.IsVisible = showMail && !phone;
SetColumns(ModuleHeader, phone ? 1 : 1, GridLength.Auto);
- Grid.SetRow(ModuleRefresh, 0);
- Grid.SetColumn(ModuleRefresh, 1);
UpdateMailPanes();
@@ -560,7 +558,11 @@ private void MainWindowKeyDown(object? sender, KeyEventArgs args)
args.Handled = true;
}
- internal void FocusGlobalSearch() { MailSearch.Focus(); MailSearch.SelectAll(); }
+ internal void FocusGlobalSearch()
+ {
+ if (_viewModel is not null) _viewModel.IsSearchEditing = true;
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => { MailSearch.Focus(); MailSearch.SelectAll(); });
+ }
private readonly Avalonia.Threading.DispatcherTimer _agendaClock = new() { Interval = TimeSpan.FromMinutes(1) };
private async void DayAgendaClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs args)
@@ -582,11 +584,27 @@ private void AgendaEventClicked(object? sender, Avalonia.Interactivity.RoutedEve
private void GlobalSearchFocused(object? sender, Avalonia.Interactivity.RoutedEventArgs args)
{
- if (_viewModel?.HasSearchText == true) _viewModel.SearchCommand.Execute(null);
+ _viewModel?.OpenSearchInput();
+ }
+
+ private void GlobalSearchLostFocus(object? sender, Avalonia.Interactivity.RoutedEventArgs args)
+ {
+ if (_viewModel is not null) _viewModel.IsSearchEditing = false;
+ }
+ private void SearchBadgesFocused(object? sender, Avalonia.Interactivity.RoutedEventArgs args)
+ {
+ if (_viewModel is not null) _viewModel.IsSearchEditing = true;
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => MailSearch.Focus());
}
private void GlobalSearchKeyDown(object? sender, KeyEventArgs args)
{
+ if (args.Key == Key.Enter && _viewModel is { HasSearchText: false })
+ {
+ _viewModel.OpenSearchInput();
+ args.Handled = true;
+ return;
+ }
if (args.Key == Key.Enter && _viewModel?.SearchCommand.CanExecute(null) == true)
{
_viewModel.SearchCommand.Execute(null);
@@ -793,7 +811,10 @@ private void ShowPreviewWindow(PreviewWindowSession session, CachedMailPreview p
var previewViewModel = new ConversationThreadViewModel(
loadMessage: message => viewModel.GetCachedMessageAsync(message),
openDraft: viewModel.OpenLocalDraftAsync,
- action: viewModel.HandlePreviewActionAsync,
+ action: request => viewModel.HandlePreviewActionAsync(request with
+ {
+ Accepted = () => { if (request.Action == ConversationAction.CreateEvent) Activate(); if (viewModel.ShouldClosePreview(request.Action)) window?.Close(); }
+ }),
moveFolders: viewModel.MoveFoldersFor,
showActions: true,
loadAttachments: message => viewModel.GetAttachmentsAsync(message),
@@ -810,6 +831,12 @@ private void ShowPreviewWindow(PreviewWindowSession session, CachedMailPreview p
viewModel.FilesProvider, viewModel.Accounts.ToArray()));
}
});
+ previewViewModel.DefaultReplyAll = viewModel.DefaultReplyAll;
+ void ReplyPreferenceChanged(object? _, PropertyChangedEventArgs change)
+ {
+ if (change.PropertyName == nameof(MainWindowViewModel.DefaultReplyAll)) previewViewModel.DefaultReplyAll = viewModel.DefaultReplyAll;
+ }
+ viewModel.PropertyChanged += ReplyPreferenceChanged;
previewViewModel.Reconcile(preview.Messages, preview.Selected);
previewViewModel.ReconcileDrafts(preview.Drafts);
window = new Window
@@ -833,7 +860,7 @@ private void ShowPreviewWindow(PreviewWindowSession session, CachedMailPreview p
SaveWindowSessions();
}
};
- window.Closed += (_, _) => _previewWindows.Remove(session);
+ window.Closed += (_, _) => { viewModel.PropertyChanged -= ReplyPreferenceChanged; _previewWindows.Remove(session); };
IndependentWindow.Show(window);
SaveWindowSessions();
}
@@ -842,6 +869,10 @@ private void MessageReplyClicked(object? sender, Avalonia.Interactivity.RoutedEv
Execute(_viewModel?.ReplyCommand);
private void MessageReplyAllClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs args) =>
Execute(_viewModel?.ReplyAllCommand);
+ private async void MessageCreateEventClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs args)
+ {
+ if (_viewModel?.SelectedMessage is { } message) await _viewModel.CreateEventFromEmailAsync(message);
+ }
private void MessageForwardClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs args) =>
Execute(_viewModel?.ForwardCommand);
private void MessageArchiveClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs args) =>
@@ -877,7 +908,7 @@ private void SearchOptionsClicked(object? sender, Avalonia.Interactivity.RoutedE
{
viewModel.SearchText = query;
viewModel.SearchCommand.Execute(null);
- }));
+ }, viewModel));
}
private async void MoveMessagesClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs args)
@@ -1056,11 +1087,7 @@ or nameof(MainWindowViewModel.ActiveModule))
}
}
- private void FocusMailSearch()
- {
- MailSearch.Focus();
- MailSearch.SelectAll();
- }
+ private void FocusMailSearch() => FocusGlobalSearch();
private void OpenCompose(ComposeRequest request)
{
diff --git a/src/BetterMail.App/MainWindowViewModel.MessageActions.cs b/src/BetterMail.App/MainWindowViewModel.MessageActions.cs
index bf4ae0b..a24465c 100644
--- a/src/BetterMail.App/MainWindowViewModel.MessageActions.cs
+++ b/src/BetterMail.App/MainWindowViewModel.MessageActions.cs
@@ -94,7 +94,7 @@ private void ShowQueuedAction(MailAction action)
}
private async Task QueueMessageStateChangesAsync(IReadOnlyList messages,
- bool? read = null, bool? flagged = null, bool? pinned = null)
+ bool? read = null, bool? flagged = null, bool? pinned = null, Action? accepted = null)
{
if (_store is null || messages.Count == 0) return;
BeginMessageFeedback(messages);
@@ -107,6 +107,7 @@ private async Task QueueMessageStateChangesAsync(IReadOnlyList mess
var action = await _store.QueueMessageStateAsync(account, message, read, flagged, pinned);
ShowQueuedAction(action);
ApplyMessageStateUpdate(message, isRead: read, isFlagged: flagged, isPinned: pinned);
+ accepted?.Invoke();
}
Status = "Message update queued";
_ = SyncAsync();
diff --git a/src/BetterMail.App/MainWindowViewModel.PreviewActions.cs b/src/BetterMail.App/MainWindowViewModel.PreviewActions.cs
new file mode 100644
index 0000000..207a188
--- /dev/null
+++ b/src/BetterMail.App/MainWindowViewModel.PreviewActions.cs
@@ -0,0 +1,60 @@
+namespace BetterMail.App;
+
+public sealed partial class MainWindowViewModel
+{
+ private bool _defaultReplyAll = true;
+ public bool DefaultReplyAll
+ {
+ get => _defaultReplyAll;
+ set
+ {
+ if (!SetProperty(ref _defaultReplyAll, value)) return;
+ RaisePropertyChanged(nameof(DefaultReplyLabel));
+ RaisePropertyChanged(nameof(DefaultReplyCommand));
+ ConversationThread.DefaultReplyAll = value;
+ }
+ }
+ public string DefaultReplyLabel => DefaultReplyAll ? "Reply all" : "Reply";
+ public System.Windows.Input.ICommand DefaultReplyCommand => DefaultReplyAll ? ReplyAllCommand : ReplyCommand;
+
+ private IReadOnlyList? _previewActionSettings;
+ public IReadOnlyList PreviewActionSettings => _previewActionSettings ??=
+ new (ConversationAction Action, string Label)[]
+ {
+ (ConversationAction.Reply, "Reply"), (ConversationAction.ReplyAll, "Reply all"),
+ (ConversationAction.Forward, "Forward"), (ConversationAction.Archive, "Archive"),
+ (ConversationAction.Delete, "Delete"), (ConversationAction.Move, "Move"),
+ (ConversationAction.Junk, "Mark as junk"), (ConversationAction.NotJunk, "Mark as not junk"),
+ (ConversationAction.ToggleRead, "Mark read / unread"),
+ (ConversationAction.ToggleFlag, "Flag / unflag"), (ConversationAction.TogglePin, "Pin / unpin")
+ }.Select(item => new PreviewActionSetting(item.Action, item.Label, () =>
+ {
+ PreviewActionsVersion++;
+ RaisePropertyChanged(nameof(PreviewActionsVersion));
+ })).ToArray();
+
+ public int PreviewActionsVersion { get; private set; }
+ public void ConfigurePreviewActions(IEnumerable? actions)
+ {
+ var close = (actions ?? []).ToHashSet(StringComparer.Ordinal);
+ foreach (var setting in PreviewActionSettings)
+ setting.Behavior = close.Contains(setting.Action.ToString()) ? "Close" : "Stay open";
+ }
+ public List GetClosePreviewActions() => PreviewActionSettings
+ .Where(item => item.Behavior == "Close").Select(item => item.Action.ToString()).ToList();
+ internal bool ShouldClosePreview(ConversationAction action) =>
+ PreviewActionSettings.Any(item => item.Action == action && item.Behavior == "Close");
+}
+
+public sealed class PreviewActionSetting(ConversationAction action, string label, Action changed) : ViewModelBase
+{
+ public ConversationAction Action { get; } = action;
+ public string Label { get; } = label;
+ public IReadOnlyList Behaviors { get; } = ["Stay open", "Close"];
+ private string _behavior = "Stay open";
+ public string Behavior
+ {
+ get => _behavior;
+ set { if (Behaviors.Contains(value) && SetProperty(ref _behavior, value)) changed(); }
+ }
+}
diff --git a/src/BetterMail.App/MainWindowViewModel.Search.cs b/src/BetterMail.App/MainWindowViewModel.Search.cs
index 9dc47ef..a3c72a0 100644
--- a/src/BetterMail.App/MainWindowViewModel.Search.cs
+++ b/src/BetterMail.App/MainWindowViewModel.Search.cs
@@ -6,6 +6,8 @@ public sealed partial class MainWindowViewModel
{
public string GlobalSearchPlaceholder => $"Search { (ActiveModule == "Files" ? "Drive" : ActiveModule) } first, then all workspaces";
+ private IReadOnlyList? _latestMailMailboxIds;
+ private IReadOnlyList? _displayedMailMailboxIds;
private SearchAccountFilter? _latestMailAccount;
private SearchAccountFilter? _displayedMailAccount;
private SearchQuery? _latestMailQuery;
@@ -38,21 +40,131 @@ public sealed partial class MainWindowViewModel
};
}
- private IReadOnlyList ResolveQueryFolders(SearchQuery query)
+ internal string SearchAccountIdentity(string value)
+ {
+ try { var match = ResolveQueryAccount(value)!; return match.MailboxId ?? match.AccountId!; }
+ catch (FormatException) { return value; }
+ }
+ internal string SearchFolderPath(MailFolderItem folder) => NormalizeSearchPath(MailFolderPath(folder.MailboxId, folder.ProviderId));
+ internal Task> SearchCategoriesAsync() => _store?.GetMailCategoriesAsync() ?? Task.FromResult>(Messages.SelectMany(message => message.Categories).Distinct().ToArray());
+ internal void ValidateSearchReferences(SearchQuery query)
+ {
+ var accounts = query.Values("account").Select(value => ResolveQueryAccount(value)!).ToArray();
+ if (accounts.Any(item => item.MailboxId is not null) && !query.Includes("Mail"))
+ throw new FormatException("Shared mailboxes require Mail among the selected types.");
+ if (query.IsDrivePathSearch)
+ {
+ foreach (var path in query.Values("in").Concat(query.Values("notin")))
+ {
+ var parts = path.Split("::", 2);
+ if (parts.Length == 2 && !Accounts.Any(account => account.AccountId == parts[0] && account.Capabilities.HasFlag(ProviderCapabilities.Files) &&
+ (accounts.Length == 0 || accounts.Any(filter => filter.MailboxId is null && filter.AccountId == account.AccountId))))
+ throw new FormatException($"Drive folder '{path}' does not match a selected Drive account.");
+ }
+ return;
+ }
+ foreach (var path in query.Values("in").Concat(query.Values("notin")))
+ if (!Folders.Any(folder => SearchPathMatches(path, folder.MailboxId, MailFolderPath(folder.MailboxId, folder.ProviderId)) &&
+ (accounts.Length == 0 || accounts.Any(account => account.MailboxId is { } id ? id == folder.MailboxId : Mailboxes.Any(mailbox => mailbox.Id == folder.MailboxId && mailbox.AccountId == account.AccountId)))))
+ throw new FormatException($"Folder '{path}' does not match a folder in the selected accounts.");
+ }
+
+ internal void OpenSearchInput()
+ {
+ IsSearchEditing = true;
+ if (HasSearchText) SearchCommand.Execute(null);
+ else
+ {
+ SearchError = null;
+ SearchNotice = "Type to search, or choose Advanced filter.";
+ IsGlobalSearchOpen = true;
+ }
+ }
+
+ private bool _isSearchEditing;
+ public bool IsSearchEditing
+ {
+ get => _isSearchEditing;
+ set { if (SetProperty(ref _isSearchEditing, value)) { RaisePropertyChanged(nameof(ShowSearchBadges)); RaisePropertyChanged(nameof(SearchBadges)); } }
+ }
+ public bool ShowSearchBadges => HasSearchText && !IsSearchEditing;
+ public IReadOnlyList SearchBadges
{
- static string NormalizePath(string path) => string.Join('/', path.Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
- var path = query["in"] is { } rawPath ? NormalizePath(rawPath) : null;
- var mailboxes = Mailboxes.Where(MailboxMatchesSearchFilters).Select(mailbox => mailbox.Id).ToHashSet(StringComparer.Ordinal);
- var folders = Folders.Where(folder => mailboxes.Contains(folder.MailboxId));
- if (path is not null)
+ get
{
- // Exact mailbox-relative path, not a leaf-name match. An explicit path can target archive/trash/junk.
- folders = folders.Where(folder => string.Equals(NormalizePath(MailFolderPath(folder.MailboxId, folder.ProviderId)), path, StringComparison.OrdinalIgnoreCase));
+ SearchQuery? whole = null;
+ try { whole = SearchQuery.Parse(SearchText); } catch (FormatException) { }
+ var badges = new List();
+ foreach (var token in SearchQuery.Tokens(SearchText))
+ {
+ var key = token.Contains(':') ? token[..token.IndexOf(':')].ToLowerInvariant() : "";
+ var filter = key.Length > 0 && key.All(char.IsLetter) && key is not ("http" or "https");
+ string? error = null;
+ try
+ {
+ var part = SearchQuery.Parse(token);
+ if (filter)
+ {
+ if (key == "account") _ = ResolveQueryAccount(part["account"]);
+ if (key is "in" or "notin")
+ {
+ var context = (whole?.Values("type") ?? []).Select(value => "type:" + SearchQuery.Encode(value))
+ .Concat((whole?.Values("account") ?? []).Select(value => "account:" + SearchQuery.Encode(value)));
+ ValidateSearchReferences(SearchQuery.Parse(string.Join(' ', context.Append(token))));
+ }
+ if (whole is null && SearchQuery.Tokens(SearchText).All(item => { try { SearchQuery.Parse(item); return true; } catch (FormatException) { return false; } }))
+ SearchQuery.Parse(SearchText);
+ }
+ }
+ catch (FormatException exception) { error = exception.Message; }
+ badges.Add(new(token, filter, error));
+ }
+ return badges;
}
- else folders = folders.Where(folder => IsFolderIncludedInSearch(folder, folder.MailboxId));
- var result = folders.Select(folder => new MailFolderKey(folder.MailboxId, folder.ProviderId)).ToArray();
- if (path is not null && result.Length == 0)
- throw new FormatException($"Folder '{path}' was not found. Use its full path, for example in:{{Inbox/Projects}}. Add account: to choose a mailbox.");
- return result;
}
+
+ private IReadOnlyList _queryAccounts = [];
+ private bool QueryAccountMatches(string accountId) => _queryAccounts.Count == 0 || _queryAccounts.Any(item => item.AccountId == accountId);
+ private bool QueryMailboxMatches(Mailbox mailbox) => _queryAccounts.Count == 0 || _queryAccounts.Any(item =>
+ item.MailboxId is { } id ? mailbox.Id == id : mailbox.AccountId == item.AccountId);
+
+ internal static string NormalizeSearchPath(string path) => string.Join('/', path.Replace("/drive/root:", "", StringComparison.OrdinalIgnoreCase).Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
+ internal static bool SearchPathMatches(string selection, string owner, string path, bool descendants = false)
+ {
+ var parts = selection.Split("::", 2);
+ if (parts.Length == 2 && !string.Equals(parts[0], owner, StringComparison.OrdinalIgnoreCase)) return false;
+ var expected = NormalizeSearchPath(parts[^1]);
+ var actual = NormalizeSearchPath(path);
+ return actual.Equals(expected, StringComparison.OrdinalIgnoreCase) || descendants &&
+ (expected.Length == 0 || actual.StartsWith(expected + "/", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private IReadOnlyList ResolveQueryFolders(SearchQuery query)
+ {
+ var included = query.IsDrivePathSearch ? [] : query.Values("in");
+ var excluded = query.IsDrivePathSearch ? [] : query.Values("notin");
+ var folders = Folders.Where(folder => Mailboxes.Any(mailbox => mailbox.Id == folder.MailboxId && QueryMailboxMatches(mailbox))).ToArray();
+ foreach (var path in included.Concat(excluded))
+ if (!folders.Any(folder => SearchPathMatches(path, folder.MailboxId, MailFolderPath(folder.MailboxId, folder.ProviderId))))
+ throw new FormatException($"Folder '{path}' was not found in the selected accounts. Choose its full path in Advanced filter.");
+ return folders.Where(folder =>
+ (included.Count > 0 ? included.Any(path => SearchPathMatches(path, folder.MailboxId, MailFolderPath(folder.MailboxId, folder.ProviderId))) : IsFolderIncludedInSearch(folder, folder.MailboxId)) &&
+ !excluded.Any(path => SearchPathMatches(path, folder.MailboxId, MailFolderPath(folder.MailboxId, folder.ProviderId), descendants: true)))
+ .Select(folder => new MailFolderKey(folder.MailboxId, folder.ProviderId)).ToArray();
+ }
+
+ private async Task> SearchSelectedWorkspaceCacheAsync(string kind, string text, int limit, CancellationToken token)
+ {
+ var ids = _queryAccounts.Count == 0 ? null : _queryAccounts.Select(item => item.AccountId!).Distinct().ToArray();
+ return await _store!.SearchWorkspaceItemsAsync(kind, text, limit, null, token, ids);
+ }
+}
+
+public sealed record SearchFilterBadge(string Text, bool IsFilter, string? Error)
+{
+ public bool IsValid => Error is null;
+ public bool IsPlain => !IsFilter && Error is null;
+ public string Background => Error is not null ? "#FDE7E9" : IsFilter ? "#DFF6DD" : "Transparent";
+ public string Foreground => Error is not null ? "#A4262C" : IsFilter ? "#0B5A18" : "#52616F";
+ public string Help => Error ?? (IsFilter ? "Valid filter" : "Search text");
}
diff --git a/src/BetterMail.App/MainWindowViewModel.Sync.cs b/src/BetterMail.App/MainWindowViewModel.Sync.cs
index 7795f80..a41ff4c 100644
--- a/src/BetterMail.App/MainWindowViewModel.Sync.cs
+++ b/src/BetterMail.App/MainWindowViewModel.Sync.cs
@@ -351,6 +351,7 @@ await Task.Run(async () =>
}
});
await RefreshNextCalendarEventAsync();
+ if (TasksWorkspace is not null) await TasksWorkspace.ReloadFromCacheAsync();
WorkspaceSyncStep.Detail = issues.IsEmpty ? "Complete" : "Completed with issues: " + string.Join("; ", issues);
}
catch (Exception error) { WorkspaceSyncStep.Detail = "Failed: " + error.Message; }
diff --git a/src/BetterMail.App/MainWindowViewModel.cs b/src/BetterMail.App/MainWindowViewModel.cs
index 9350093..c3451b6 100644
--- a/src/BetterMail.App/MainWindowViewModel.cs
+++ b/src/BetterMail.App/MainWindowViewModel.cs
@@ -995,6 +995,8 @@ public string SearchText
{
RaisePropertyChanged(nameof(SearchResultSummary));
RaisePropertyChanged(nameof(HasSearchText));
+ RaisePropertyChanged(nameof(ShowSearchBadges));
+ RaisePropertyChanged(nameof(SearchBadges));
_ = StartGlobalSearchAsync(debounce: true);
}
}
@@ -2052,15 +2054,19 @@ private async Task StartGlobalSearchAsync(bool debounce, bool forceOpen = false)
try
{
parsed = SearchQuery.Parse(rawQuery);
+ ValidateSearchReferences(parsed);
_selectedSearchScope = parsed.Scope;
- _selectedSearchAccountFilter = ResolveQueryAccount(parsed["account"]);
- if (_selectedSearchAccountFilter?.MailboxId is not null && parsed.Scope != "Mail")
+ _queryAccounts = parsed.Values("account").Select(value => ResolveQueryAccount(value)!).ToArray();
+ _selectedSearchAccountFilter = _queryAccounts.Count == 1 ? _queryAccounts[0] : null;
+ if (_queryAccounts.Any(item => item.MailboxId is not null) && !parsed.Includes("Mail"))
throw new FormatException("Shared-mailbox searches require type:mail. Other workspace searches use the linked account's address.");
_selectedSearchFolderFilter = null;
_includeArchivedMailInSearch = parsed["archives"] == "true";
query = parsed.Text;
searchFolders = ResolveQueryFolders(parsed);
- SearchNotice = parsed.HasMailFilters
+ SearchNotice = parsed.IsDrivePathSearch && parsed.Values("in").Count + parsed.Values("notin").Count > 0
+ ? "Drive folder filters search the synced file index. Sync Drive to include more files."
+ : parsed.HasMailFilters
? "Advanced filters search synced mail. Sync first to include mail that is not cached yet."
: "";
}
@@ -2075,7 +2081,7 @@ private async Task StartGlobalSearchAsync(bool debounce, bool forceOpen = false)
IsGlobalSearchRunning = true;
IsGlobalSearchOpen = true;
- if (SelectedSearchScope is not ("Everything" or "Mail"))
+ if (!parsed.Includes("Mail"))
{
ClearLatestMailSearchResults();
}
@@ -2087,7 +2093,7 @@ private async Task StartGlobalSearchAsync(bool debounce, bool forceOpen = false)
}
var searches = new List();
- if (SelectedSearchScope is "Everything" or "Mail")
+ if (parsed.Includes("Mail"))
{
var localMailSearch = AddGlobalResultsAsync(
"Mail",
@@ -2100,13 +2106,13 @@ private async Task StartGlobalSearchAsync(bool debounce, bool forceOpen = false)
AddSearch("People", () => SearchPeopleGloballyAsync(query, source.Token));
AddSearch("Calendar", () => SearchCalendarGloballyAsync(query, source.Token));
AddSearch("To Do", () => SearchTasksGloballyAsync(query, source.Token));
- AddSearch("Drive", () => SearchDriveGloballyAsync(query, source.Token));
+ AddSearch("Drive", () => SearchDriveGloballyAsync(query, source.Token, parsed));
AddSearch("Notes", () => SearchNotesGloballyAsync(query, source.Token));
await Task.WhenAll(searches);
void AddSearch(string category, Func>> search)
{
- if (SelectedSearchScope is "Everything" || SelectedSearchScope == category)
+ if (parsed.Includes(category))
{
searches.Add(AddGlobalResultsAsync(category, search(), source));
}
@@ -2174,12 +2180,17 @@ private async Task> SearchCachedMailGloballyAs
CancellationToken cancellationToken)
{
var account = SelectedSearchAccountFilter;
+ var selectedAccounts = _queryAccounts;
+ var cachedMailboxes = Mailboxes.Count > 0 ? Mailboxes.ToArray() : await _store!.GetMailboxesAsync(cancellationToken);
+ var mailboxIds = selectedAccounts.Count == 0 ? null : cachedMailboxes.Where(mailbox => selectedAccounts.Any(item =>
+ item.MailboxId is { } id ? mailbox.Id == id : mailbox.AccountId == item.AccountId)).Select(mailbox => mailbox.Id).ToArray();
var messages = (await _store!.SearchFilteredMailAsync(query, folders, 500, cancellationToken,
- account?.AccountId, account?.MailboxId, includeUnknownFolders: query["in"] is null, knownFolders: Folders.Select(folder => new MailFolderKey(folder.MailboxId, folder.ProviderId)).ToArray()))
+ account?.AccountId, account?.MailboxId, includeUnknownFolders: query.Values("in").Count == 0 && query.Values("notin").Count == 0, knownFolders: Folders.Select(folder => new MailFolderKey(folder.MailboxId, folder.ProviderId)).ToArray(), allowedMailboxIds: mailboxIds))
.OrderByDescending(static message => message.ReceivedAt)
.ThenByDescending(static message => message.ProviderId, StringComparer.Ordinal)
.ToArray();
cancellationToken.ThrowIfCancellationRequested();
+ _latestMailMailboxIds = mailboxIds;
_latestMailAccount = account;
_latestMailQuery = query;
_latestMailFolders = folders;
@@ -2257,13 +2268,8 @@ await _store.ApplySyncPageAsync(
ReplaceGlobalMailSearchResults(merged.Take(100));
}
- private bool MailboxMatchesSearchFilters(Mailbox mailbox) =>
- (SelectedSearchAccountFilter?.MailboxId is { } mailboxId
- ? mailbox.Id == mailboxId
- : SelectedSearchAccountFilter?.AccountId is null ||
- SelectedSearchAccountFilter.AccountId == mailbox.AccountId) &&
- (!IsMailSearchScope || SelectedSearchFolderFilter?.MailboxId is null ||
- SelectedSearchFolderFilter.MailboxId == mailbox.Id);
+ private bool MailboxMatchesSearchFilters(Mailbox mailbox) => QueryMailboxMatches(mailbox) &&
+ (!IsMailSearchScope || SelectedSearchFolderFilter?.MailboxId is null || SelectedSearchFolderFilter.MailboxId == mailbox.Id);
private async Task IsMailboxSearchCoverageCompleteAsync(
Mailbox mailbox,
@@ -2374,6 +2380,10 @@ private async Task> SearchPeopleGloballyAsync(
"People", contact.DisplayName, contact.EmailText, "People", contact))
.Concat(discoveredTask.Result.Select(person => new GlobalSearchResult(
"People", person.DisplayName, person.EmailAddress, "People", person)))
+ .OrderBy(result => result.Title, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(result => result.Subtitle, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(result => (result.Value as ContactInfo)?.AccountId, StringComparer.Ordinal)
+ .ThenBy(result => (result.Value as ContactInfo)?.ProviderId, StringComparer.Ordinal)
.Take(30)
.ToArray();
}
@@ -2382,8 +2392,8 @@ private async Task> SearchCalendarGloballyAsyn
string query,
CancellationToken cancellationToken)
{
- var cached = await _store!.SearchWorkspaceItemsAsync(
- "calendar-event", query, 30, SelectedSearchAccountFilter?.AccountId, cancellationToken);
+ var cached = await SearchSelectedWorkspaceCacheAsync(
+ "calendar-event", query, 30, cancellationToken);
if (cached.Count > 0 || _workspaceProvider is null)
{
return CalendarResults(cached, query);
@@ -2398,7 +2408,7 @@ private async Task> SearchCalendarGloballyAsyn
{
if (group.Key.AccountId is not null)
{
- await _store.ReplaceCalendarEventsAsync(
+ await _store!.ReplaceCalendarEventsAsync(
group.Key.AccountId, group.Key.CalendarId,
now.AddYears(-1), now.AddYears(2), group.ToArray(), cancellationToken);
}
@@ -2420,8 +2430,8 @@ private async Task> SearchTasksGloballyAsync(
string query,
CancellationToken cancellationToken)
{
- var cached = await _store!.SearchWorkspaceItemsAsync(
- "task", query, 30, SelectedSearchAccountFilter?.AccountId, cancellationToken);
+ var cached = await SearchSelectedWorkspaceCacheAsync(
+ "task", query, 30, cancellationToken);
if (cached.Count > 0 || _workspaceProvider is null)
{
return TaskResults(cached, query);
@@ -2434,7 +2444,7 @@ private async Task> SearchTasksGloballyAsync(
{
if (group.Key.AccountId is not null)
{
- await _store.ReplaceWorkspaceItemsAsync(
+ await _store!.ReplaceWorkspaceItemsAsync(
"task", group.Key.AccountId, group.Key.ListId, group.ToArray(),
static item => item.ProviderId,
static item => $"{item.Title} {item.Notes} {string.Join(' ', item.Categories ?? [])}",
@@ -2454,10 +2464,15 @@ static IReadOnlyList TaskResults(
private async Task> SearchDriveGloballyAsync(
string query,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken, SearchQuery parsed)
{
- var cached = await _store!.SearchWorkspaceItemsAsync(
- "drive-file", query, 40, SelectedSearchAccountFilter?.AccountId, cancellationToken);
+ if (parsed.IsDrivePathSearch && parsed.Values("in").Count + parsed.Values("notin").Count > 0)
+ {
+ var ids = _queryAccounts.Count == 0 ? null : _queryAccounts.Where(item => item.MailboxId is null).Select(item => item.AccountId!).Distinct().ToArray();
+ return DriveResults(await _store!.SearchFilteredDriveFilesAsync(parsed, ids, cancellationToken: cancellationToken));
+ }
+ var cached = await SearchSelectedWorkspaceCacheAsync(
+ "drive-file", query, 40, cancellationToken);
if (cached.Count > 0 || _workspaceProvider is null)
{
return DriveResults(cached);
@@ -2470,7 +2485,7 @@ private async Task> SearchDriveGloballyAsync(
{
if (group.Key is not null)
{
- await _store.UpsertWorkspaceItemsAsync(
+ await _store!.UpsertWorkspaceItemsAsync(
"drive-file", group.Key, "index", group.ToArray(),
static item => item.ProviderId,
static item => $"{item.Name} {item.Path}", cancellationToken);
@@ -2487,8 +2502,8 @@ private async Task> SearchNotesGloballyAsync(
string query,
CancellationToken cancellationToken)
{
- var cached = await _store!.SearchWorkspaceItemsAsync(
- "note", query, 30, SelectedSearchAccountFilter?.AccountId, cancellationToken);
+ var cached = await SearchSelectedWorkspaceCacheAsync(
+ "note", query, 30, cancellationToken);
if (cached.Count > 0 || _workspaceProvider is null)
{
return NoteResults(cached, query);
@@ -2501,7 +2516,7 @@ private async Task> SearchNotesGloballyAsync(
{
if (group.Key is not null)
{
- await _store.ReplaceWorkspaceItemsAsync(
+ await _store!.ReplaceWorkspaceItemsAsync(
"note", group.Key, "all", group.ToArray(),
static item => item.ProviderId,
static item => item.Title, cancellationToken);
@@ -2525,8 +2540,7 @@ private async Task> SearchAccountsAsync(
{
var batches = await Task.WhenAll(Accounts
.Where(account => account.Capabilities.HasFlag(capability) &&
- (SelectedSearchAccountFilter?.AccountId is null ||
- SelectedSearchAccountFilter.AccountId == account.AccountId))
+ QueryAccountMatches(account.AccountId))
.Select(async account =>
{
try
@@ -2845,11 +2859,12 @@ private void ShowMailSearchResults(
ActiveModule = "Mail";
IsDraftsView = false;
SetSelectedFolder(null);
+ _displayedMailMailboxIds = _latestMailMailboxIds;
_displayedMailAccount = _latestMailAccount;
_displayedMailQuery = _latestMailQuery;
_displayedMailFolders = results is null ? _latestMailFolders
: _latestMailFolders.Where(folder => results.Any(message => message.MailboxId == folder.MailboxId)).ToArray();
- _mailSearchSummary = $"Query: {_latestMailQuery?.Serialize()}\nAccount: {_latestMailQuery?["account"] ?? "All linked accounts"}\nFolder: {_latestMailQuery?["in"] ?? "All searchable folders"}\nArchives: {(_latestMailQuery?["archives"] == "true" ? "included" : "excluded unless explicitly selected")}";
+ _mailSearchSummary = $"Query: {_latestMailQuery?.Serialize()}\nAccount: {(_latestMailQuery?.Values("account").Count > 0 ? string.Join(", ", _latestMailQuery.Values("account")) : "All linked accounts")}\nFolder: {(_latestMailQuery?.Values("in").Count > 0 ? string.Join(", ", _latestMailQuery.Values("in")) : "All searchable folders")}\nArchives: {(_latestMailQuery?["archives"] == "true" ? "included" : "excluded unless explicitly selected")}";
if (results is not null && selected is not null)
{
_mailSearchSummary += $"\nShowing group: {SearchGroupFor(selected)}";
@@ -2913,7 +2928,7 @@ private async Task LoadMessagesAsync(bool showLoading = true)
{
_messagePageCursor = null;
var found = await _store.SearchFilteredMailAsync(displayedQuery, _displayedMailFolders, 500, token,
- _displayedMailAccount?.AccountId, _displayedMailAccount?.MailboxId, includeUnknownFolders: displayedQuery["in"] is null, knownFolders: Folders.Select(folder => new MailFolderKey(folder.MailboxId, folder.ProviderId)).ToArray());
+ _displayedMailAccount?.AccountId, _displayedMailAccount?.MailboxId, includeUnknownFolders: displayedQuery.Values("in").Count == 0 && displayedQuery.Values("notin").Count == 0, knownFolders: Folders.Select(folder => new MailFolderKey(folder.MailboxId, folder.ProviderId)).ToArray(), allowedMailboxIds: _displayedMailMailboxIds);
if (loadVersion != _messageLoadVersion) return;
ReconcileMessages(found);
RaiseMessageState();
@@ -3587,7 +3602,7 @@ public bool PeopleCardView
private int _peopleCardColumns = 3;
internal void SetPeopleViewportWidth(double width)
{
- var columns = Math.Clamp((int)(Math.Max(0, width) / 380), 1, 4);
+ var columns = Math.Clamp((int)(Math.Max(0, width) / 290), 1, 4);
if (_peopleCardColumns == columns) return;
_peopleCardColumns = columns;
if (PeopleCardView) RebuildPeopleCardRows();
@@ -3602,9 +3617,16 @@ private void RebuildPeopleCardRows() => CollectionUpdates.Reconcile(PeopleCardRo
private async Task OpenPeopleAsync()
{
- if (_loadedPeopleQuery != ModuleSearchText || People.Count == 0)
- await LoadPeopleCoreAsync(ModuleSearchText, cacheOnly: true);
- PeopleBackgroundRefresh = RefreshPeopleInBackgroundAsync();
+ _peopleRefreshCount++;
+ RaisePeopleProgress();
+ try
+ {
+ RebuildContactOwners();
+ if (_loadedPeopleQuery != ModuleSearchText || People.Count == 0)
+ await LoadPeopleCoreAsync(ModuleSearchText, cacheOnly: true);
+ PeopleBackgroundRefresh = RefreshPeopleInBackgroundAsync();
+ }
+ finally { _peopleRefreshCount--; RaisePeopleProgress(); }
}
private int _peopleRefreshCount;
@@ -4028,7 +4050,7 @@ internal IReadOnlyList MoveSelectionSnapshot() =>
internal static bool CanMoveMessagesToFolder(IReadOnlyList messages, MailFolderItem folder) =>
messages.Count > 0 && messages.All(message => message.MailboxId == folder.MailboxId);
- internal async Task MoveSnapshotToFolderAsync(IReadOnlyList messages, MailFolderItem folder)
+ internal async Task MoveSnapshotToFolderAsync(IReadOnlyList messages, MailFolderItem folder, Action? accepted = null)
{
// The picker keeps the identities selected when it opened, but sync may have
// replaced their contents (or removed them) while the user chose a folder.
@@ -4040,7 +4062,7 @@ internal async Task MoveSnapshotToFolderAsync(IReadOnlyList message
}
var destination = Folders.FirstOrDefault(item => item.MailboxId == folder.MailboxId && item.ProviderId == folder.ProviderId);
if (destination is not null && CanMoveMessagesToFolder(current, destination))
- await MoveMessagesAsync(current, destination.ProviderId, $"Moving to {destination.DisplayName}...", $"Moved to {destination.DisplayName}");
+ await MoveMessagesAsync(current, destination.ProviderId, $"Moving to {destination.DisplayName}...", $"Moved to {destination.DisplayName}", accepted);
}
internal Task MoveSelectionToFolderAsync(MailFolderItem folder) =>
@@ -4054,7 +4076,7 @@ private async Task MoveMessagesAsync(
IReadOnlyList messages,
string destinationFolderId,
string actionStatus,
- string successStatus)
+ string successStatus, Action? accepted = null)
{
if (messages.Count == 0 || _provider is null || _store is null) return;
messages = messages.Where(message =>
@@ -4079,6 +4101,7 @@ private async Task MoveMessagesAsync(
?? new MailFolder(mailbox.Id, destinationFolderId, destinationFolderId, 0, 0);
var action = await _store.QueueMoveAsync(account, message, destination);
ShowQueuedAction(action);
+ accepted?.Invoke();
}
await feedback;
// Evaluate today's selection, not the selection from before the asynchronous queue writes.
@@ -4746,7 +4769,9 @@ private void RebuildContactOwners()
_recipientDirectoryTask = null;
var selectedMailboxId = SelectedContactOwner?.Mailbox.Id;
Replace(ContactOwners,
- from mailbox in Mailboxes
+ from mailbox in Mailboxes.Concat(Accounts
+ .Where(account => !Mailboxes.Any(mailbox => mailbox.AccountId == account.AccountId && !mailbox.IsShared))
+ .Select(account => new Mailbox(account.AccountId, account.EmailAddress, account.DisplayName)))
join account in Accounts on mailbox.AccountId equals account.AccountId
where account.Capabilities.HasFlag(ProviderCapabilities.Contacts) &&
(mailbox.IsShared || mailbox.Address.Equals(account.EmailAddress, StringComparison.OrdinalIgnoreCase))
@@ -5074,7 +5099,7 @@ private Task ReplyAsync()
return ReplyToAsync(message);
}
- private async Task ReplyToAsync(MailMessage message)
+ private async Task ReplyToAsync(MailMessage message, Action? accepted = null)
{
var attachments = await LoadComposeSourceAttachmentsAsync(message, includeFiles: false);
if (attachments is null)
@@ -5090,7 +5115,7 @@ await RequestComposeAsync(new ComposeRequest(
MailboxId: mailboxId,
IsHtml: true,
Intent: ComposeIntent.Reply,
- ConversationIdentity: BetterMail.Core.ConversationThread.ThreadIdentity(message)));
+ ConversationIdentity: BetterMail.Core.ConversationThread.ThreadIdentity(message)), accepted);
}
private Task ReplyAllAsync()
@@ -5099,7 +5124,7 @@ private Task ReplyAllAsync()
return ReplyAllToAsync(message);
}
- private async Task ReplyAllToAsync(MailMessage message)
+ private async Task ReplyAllToAsync(MailMessage message, Action? accepted = null)
{
var recipients = MailReplyRecipients.ReplyAll(
message,
@@ -5126,7 +5151,7 @@ await RequestComposeAsync(new ComposeRequest(
MailboxId: mailboxId,
IsHtml: true,
Intent: ComposeIntent.ReplyAll,
- ConversationIdentity: BetterMail.Core.ConversationThread.ThreadIdentity(message)));
+ ConversationIdentity: BetterMail.Core.ConversationThread.ThreadIdentity(message)), accepted);
}
private Task ForwardAsync()
@@ -5135,7 +5160,7 @@ private Task ForwardAsync()
return ForwardMessageAsync(message);
}
- private async Task ForwardMessageAsync(MailMessage message)
+ private async Task ForwardMessageAsync(MailMessage message, Action? accepted = null)
{
var attachments = await LoadComposeSourceAttachmentsAsync(message, includeFiles: true);
if (attachments is null)
@@ -5155,7 +5180,7 @@ await RequestComposeAsync(new ComposeRequest(
.ToArray(),
IsHtml: true,
Intent: ComposeIntent.Forward,
- ConversationIdentity: BetterMail.Core.ConversationThread.ThreadIdentity(message)));
+ ConversationIdentity: BetterMail.Core.ConversationThread.ThreadIdentity(message)), accepted);
}
private async Task?> LoadComposeSourceAttachmentsAsync(
@@ -5223,7 +5248,7 @@ private static string PrefixSubject(string subject, string prefix) =>
? subject
: string.IsNullOrWhiteSpace(subject) ? prefix : $"{prefix} {subject}";
- private Task RequestComposeAsync(ComposeRequest request)
+ private Task RequestComposeAsync(ComposeRequest request, Action? accepted = null)
{
if (Accounts.Count == 0)
{
@@ -5245,7 +5270,11 @@ private Task RequestComposeAsync(ComposeRequest request)
}
}
- ComposeRequested?.Invoke(request);
+ if (ComposeRequested is { } openCompose)
+ {
+ openCompose(request);
+ accepted?.Invoke();
+ }
return Task.CompletedTask;
}
@@ -5439,12 +5468,39 @@ private async Task LoadConversationAsync(
}
}
+ internal async Task CreateEventFromEmailAsync(MailMessage message, Action? accepted = null)
+ {
+ try
+ {
+ if (_workspaceProvider is null || !AccountsWith(ProviderCapabilities.Calendar).Any())
+ throw new InvalidOperationException("Connect an account with a writable calendar to create an event.");
+ var fullMessage = message.Body is null ? await GetCachedMessageAsync(message) : message;
+ if (fullMessage?.Body is null)
+ throw new InvalidOperationException("The full email content is not available yet. Try again after it has loaded.");
+ await LoadCalendarWorkspaceAsync();
+ if (CalendarWorkspace is null || CalendarWorkspace.EditableCalendars.Count == 0)
+ throw new InvalidOperationException("No writable calendars are available.");
+ if (CalendarWorkspace.IsEditorOpen)
+ throw new InvalidOperationException("Save or close the current event editor before creating another event.");
+ await CalendarWorkspace.OpenFromEmailAsync(fullMessage,
+ Mailboxes.FirstOrDefault(mailbox => mailbox.Id == message.MailboxId)?.AccountId);
+ IsSettingsOpen = false;
+ ActiveModule = "Calendar";
+ accepted?.Invoke();
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ Error = exception.Message;
+ }
+ }
+
private Task HandleConversationAction(ConversationActionRequest request) =>
request.Action switch
{
ConversationAction.Reply => ReplyToAsync(request.Message),
ConversationAction.ReplyAll => ReplyAllToAsync(request.Message),
ConversationAction.Forward => ForwardMessageAsync(request.Message),
+ ConversationAction.CreateEvent => CreateEventFromEmailAsync(request.Message),
_ => Task.CompletedTask
};
@@ -5453,19 +5509,20 @@ internal IReadOnlyList MoveFoldersFor(MailMessage message) =>
internal Task HandlePreviewActionAsync(ConversationActionRequest request) => request.Action switch
{
- ConversationAction.Reply => ReplyToAsync(request.Message),
- ConversationAction.ReplyAll => ReplyAllToAsync(request.Message),
- ConversationAction.Forward => ForwardMessageAsync(request.Message),
- ConversationAction.Archive => MoveMessagesAsync([request.Message], "archive", "Archiving...", "Archived"),
- ConversationAction.Delete => MoveMessagesAsync([request.Message], "deleteditems", "Moving to Deleted Items...", "Moved to Deleted Items"),
- ConversationAction.Junk => MoveMessagesAsync([request.Message], "junkemail", "Moving to Junk Email...", "Moved to Junk Email"),
- ConversationAction.NotJunk => MoveMessagesAsync([request.Message], "inbox", "Moving to Inbox...", "Marked as not junk"),
- ConversationAction.ToggleRead => ToggleReadMessagesAsync([request.Message]),
- ConversationAction.ToggleFlag => ToggleFlagMessagesAsync([request.Message]),
- ConversationAction.TogglePin => TogglePinMessagesAsync([request.Message]),
+ ConversationAction.Reply => ReplyToAsync(request.Message, request.Accepted),
+ ConversationAction.ReplyAll => ReplyAllToAsync(request.Message, request.Accepted),
+ ConversationAction.Forward => ForwardMessageAsync(request.Message, request.Accepted),
+ ConversationAction.CreateEvent => CreateEventFromEmailAsync(request.Message, request.Accepted),
+ ConversationAction.Archive => MoveMessagesAsync([request.Message], "archive", "Archiving...", "Archived", request.Accepted),
+ ConversationAction.Delete => MoveMessagesAsync([request.Message], "deleteditems", "Moving to Deleted Items...", "Moved to Deleted Items", request.Accepted),
+ ConversationAction.Junk => MoveMessagesAsync([request.Message], "junkemail", "Moving to Junk Email...", "Moved to Junk Email", request.Accepted),
+ ConversationAction.NotJunk => MoveMessagesAsync([request.Message], "inbox", "Moving to Inbox...", "Marked as not junk", request.Accepted),
+ ConversationAction.ToggleRead => QueueMessageStateChangesAsync([request.Message], read: !request.Message.IsRead, accepted: request.Accepted),
+ ConversationAction.ToggleFlag => QueueMessageStateChangesAsync([request.Message], flagged: !request.Message.IsFlagged, accepted: request.Accepted),
+ ConversationAction.TogglePin => QueueMessageStateChangesAsync([request.Message], pinned: !request.Message.IsPinned, accepted: request.Accepted),
ConversationAction.ViewHeaders => ViewHeadersAsync(request.Message),
ConversationAction.Move when request.Destination is not null =>
- MoveSnapshotToFolderAsync([request.Message], request.Destination),
+ MoveSnapshotToFolderAsync([request.Message], request.Destination, request.Accepted),
_ => Task.CompletedTask
};
diff --git a/src/BetterMail.App/NotesWorkspaceView.axaml b/src/BetterMail.App/NotesWorkspaceView.axaml
index 232580e..2e5f997 100644
--- a/src/BetterMail.App/NotesWorkspaceView.axaml
+++ b/src/BetterMail.App/NotesWorkspaceView.axaml
@@ -44,10 +44,9 @@
BorderBrush="#55D9493D"
BorderThickness="1"
IsVisible="{Binding HasPartialErrors}">
-
+
+ {
+ if (args.Source is TreeViewItem { DataContext: NoteTreeNode node } && DataContext is NotesWorkspaceViewModel vm)
+ {
+ node.IsExpanded = true;
+ await vm.LoadChildrenAsync(node);
+ }
+ });
DataContextChanged += (_, _) =>
{
if (_viewModel is not null)
diff --git a/src/BetterMail.App/NotesWorkspaceViewModel.cs b/src/BetterMail.App/NotesWorkspaceViewModel.cs
index d704c2f..3bdab07 100644
--- a/src/BetterMail.App/NotesWorkspaceViewModel.cs
+++ b/src/BetterMail.App/NotesWorkspaceViewModel.cs
@@ -95,7 +95,7 @@ private set
public bool HasPartialErrors => AccountRoots.Any(static root => root.HasError);
public string PartialErrorText => string.Join(
Environment.NewLine,
- AccountRoots.SelectMany(static root => root.Errors()));
+ AccountRoots.SelectMany(static root => root.Errors()).Distinct());
public bool IsLoadingPage { get => _isLoadingPage; private set => SetProperty(ref _isLoadingPage, value); }
public Uri? PageBodyUri { get => _pageBodyUri; private set => SetProperty(ref _pageBodyUri, value); }
public bool HasSelectedPage => SelectedPage is not null;
@@ -571,14 +571,7 @@ private void RaisePartialErrors()
private void ApplyFilter(string query)
{
- VisibleRoots.Clear();
- foreach (var root in AccountRoots)
- {
- if (root.ApplyFilter(query))
- {
- VisibleRoots.Add(root);
- }
- }
+ CollectionUpdates.Reconcile(VisibleRoots, AccountRoots.Where(root => root.ApplyFilter(query)).ToArray(), root => root.Account.AccountId);
}
internal static string PlainTextToNoteHtml(string text)
@@ -673,9 +666,11 @@ public string? Error
if (SetProperty(ref _error, value))
{
RaisePropertyChanged(nameof(HasError));
+ RaisePropertyChanged(nameof(ErrorSummary));
}
}
}
+ public string ErrorSummary => string.IsNullOrWhiteSpace(Error) ? "" : Error.Contains("10008", StringComparison.Ordinal) ? "Library limit · cached notes only" : "Could not sync · see details";
public bool HasError => !string.IsNullOrWhiteSpace(Error) ||
AllChildren.Any(static child => child.HasError);
@@ -704,21 +699,9 @@ public void ReplaceChildren(IEnumerable children)
public bool ApplyFilter(string query)
{
- Children.Clear();
- var selfMatches = query.Length == 0 ||
- DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
- SecondaryText.Contains(query, StringComparison.OrdinalIgnoreCase);
- foreach (var child in AllChildren)
- {
- if (child.Kind != NoteNodeKind.Placeholder && child.ApplyFilter(query))
- {
- Children.Add(child);
- }
- else if (query.Length == 0 && child.Kind == NoteNodeKind.Placeholder)
- {
- Children.Add(child);
- }
- }
+ var selfMatches = query.Length == 0 || DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) || SecondaryText.Contains(query, StringComparison.OrdinalIgnoreCase);
+ var visible = AllChildren.Where(child => child.Kind == NoteNodeKind.Placeholder ? query.Length == 0 : child.ApplyFilter(query)).ToArray();
+ CollectionUpdates.Reconcile(Children, visible, child => child);
if (query.Length > 0 && Children.Count > 0)
{
IsExpanded = true;
@@ -791,7 +774,7 @@ private static NoteTreeNode Placeholder(MailAccount account, NoteTreeNode parent
new(
NoteNodeKind.Placeholder,
account,
- "Expand to load",
+ "Loading…",
"",
parent,
null,
diff --git a/src/BetterMail.App/SearchFoldersWindow.cs b/src/BetterMail.App/SearchFoldersWindow.cs
new file mode 100644
index 0000000..aa7e50e
--- /dev/null
+++ b/src/BetterMail.App/SearchFoldersWindow.cs
@@ -0,0 +1,108 @@
+using Avalonia.Controls;
+using Avalonia.Layout;
+using BetterMail.Core;
+
+namespace BetterMail.App;
+
+public sealed class SearchFoldersWindow : Window
+{
+ private readonly HashSet _selected;
+ private readonly TextBlock _notice = new() { TextWrapping = Avalonia.Media.TextWrapping.Wrap };
+ private readonly TreeView _tree = new();
+ private readonly CancellationTokenSource _closed = new();
+ private readonly MainWindowViewModel? _owner;
+ private readonly Dictionary _boxes = [];
+ private readonly StackPanel _selectedRows = new() { Spacing = 3 };
+ public SearchFoldersWindow(MainWindowViewModel? owner, bool drive, IReadOnlyList accounts, IEnumerable selected, bool excluding)
+ {
+ _owner = owner; _selected = selected.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ Title = excluding ? "Exclude folders" : "Include folders";
+ Width = 620; Height = 720; MinWidth = 400; MinHeight = 400;
+ var apply = new Button { Content = "Use selected folders" }; apply.Click += (_, _) => Close((IReadOnlyList)_selected.ToArray());
+ var cancel = new Button { Content = "Cancel" }; cancel.Click += (_, _) => Close();
+ var clear = new Button { Content = "Clear selection" }; clear.Click += (_, _) => { _selected.Clear(); foreach (var box in _boxes.Values) box.IsChecked = false; RefreshSelected(); };
+ var find = new TextBox { PlaceholderText = "Find loaded folders" };
+ find.TextChanged += (_, _) => { foreach (var node in _tree.Items.OfType()) Filter(node, find.Text ?? ""); };
+ var grid = new Grid { Margin = new(20), RowDefinitions = new("Auto,Auto,*,Auto,Auto"), RowSpacing = 10 };
+ grid.Children.Add(new TextBlock { Text = Title, FontSize = 20 });
+ Grid.SetRow(find, 1); grid.Children.Add(find);
+ var scroll = new ScrollViewer { Content = _tree }; Grid.SetRow(scroll, 2); grid.Children.Add(scroll);
+ var selection = new StackPanel { Spacing = 5, Children = { _notice, new ScrollViewer { MaxHeight = 100, Content = _selectedRows } } };
+ Grid.SetRow(selection, 3); grid.Children.Add(selection);
+ var footer = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { clear, cancel, apply } };
+ Grid.SetRow(footer, 4); grid.Children.Add(footer); Content = grid;
+ Closed += (_, _) => _closed.Cancel();
+ if (owner is not null)
+ {
+ if (drive)
+ {
+ _tree.ItemsSource = owner.Accounts.Where(account => account.Capabilities.HasFlag(ProviderCapabilities.Files) &&
+ (accounts.Count == 0 || accounts.Contains(account.AccountId))).Select(account => DriveNode(account, null)).ToArray();
+ }
+ else
+ {
+ var mailboxIds = owner.Mailboxes.Where(mailbox => accounts.Count == 0 || accounts.Contains(mailbox.Id) || accounts.Contains(mailbox.AccountId)).Select(mailbox => mailbox.Id).ToHashSet();
+ _tree.ItemsSource = MailMoveFolder.Build(owner.Folders.Where(folder => mailboxIds.Contains(folder.MailboxId))).Select(MailNode).ToArray();
+ }
+ }
+ RefreshSelected();
+ _notice.Text += drive ? " Expand folders to browse Drive." : " Folder choices are limited to the selected accounts.";
+ }
+ private TreeViewItem MailNode(MailMoveFolder folder) => new()
+ {
+ Header = folder.Folder is { } item ? Choice(item.MailboxId + "::" + _owner!.SearchFolderPath(item), folder.Name) : new TextBlock { Text = folder.Name },
+ Tag = folder.Name, IsExpanded = folder.Folder is null,
+ ItemsSource = folder.Children.Select(MailNode).ToArray()
+ };
+ private TreeViewItem DriveNode(MailAccount account, CloudDriveItem? item)
+ {
+ var path = item?.Path ?? "/";
+ var node = new TreeViewItem { Header = Choice(account.AccountId + "::" + path, item?.Name ?? account.DisplayName + " · " + account.EmailAddress),
+ Tag = item?.Name ?? account.EmailAddress, ItemsSource = new[] { new TreeViewItem { Header = "Expand to load folders" } } };
+ var loaded = false; var loading = false;
+ node.Expanded += async (_, args) =>
+ {
+ if (args.Source != node || loaded || loading) return;
+ loading = true;
+ try
+ {
+ if (_owner!.FilesProvider is null) throw new InvalidOperationException("Drive is unavailable.");
+ var children = await _owner.FilesProvider.GetDriveItemsAsync(account, item, _closed.Token);
+ if (_closed.IsCancellationRequested) return;
+ node.ItemsSource = children.Where(child => child.IsFolder).OrderBy(child => child.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(child => DriveNode(account, child)).ToArray();
+ loaded = true;
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception error) { _notice.Text = "Folders could not be loaded: " + error.Message + " Collapse and expand to retry."; }
+ finally { loading = false; }
+ };
+ return node;
+ }
+ private CheckBox Choice(string value, string label)
+ {
+ var box = new CheckBox { Content = label, IsChecked = _selected.Contains(value) };
+ _boxes[value] = box;
+ box.IsCheckedChanged += (_, _) => { if (box.IsChecked == true) _selected.Add(value); else _selected.Remove(value); RefreshSelected(); };
+ return box;
+ }
+ private void RefreshSelected()
+ {
+ _notice.Text = $"{_selected.Count} selected";
+ _selectedRows.Children.Clear();
+ foreach (var value in _selected.ToArray())
+ {
+ var remove = new Button { Content = "× " + value, HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalContentAlignment = HorizontalAlignment.Left };
+ remove.Click += (_, _) => { _selected.Remove(value); if (_boxes.TryGetValue(value, out var box)) box.IsChecked = false; RefreshSelected(); };
+ _selectedRows.Children.Add(remove);
+ }
+ }
+ private static bool Filter(TreeViewItem node, string text)
+ {
+ var childMatch = false;
+ foreach (var child in node.Items.OfType()) childMatch |= Filter(child, text);
+ node.IsVisible = childMatch || (node.Tag as string ?? "").Contains(text, StringComparison.OrdinalIgnoreCase);
+ if (text.Length > 0 && childMatch) node.IsExpanded = true;
+ return node.IsVisible;
+ }
+}
diff --git a/src/BetterMail.App/SearchOptionsWindow.axaml b/src/BetterMail.App/SearchOptionsWindow.axaml
index bd1ecd1..1e32e42 100644
--- a/src/BetterMail.App/SearchOptionsWindow.axaml
+++ b/src/BetterMail.App/SearchOptionsWindow.axaml
@@ -1,18 +1,27 @@
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/src/BetterMail.App/SearchOptionsWindow.axaml.cs b/src/BetterMail.App/SearchOptionsWindow.axaml.cs
index 01eb8cf..0724397 100644
--- a/src/BetterMail.App/SearchOptionsWindow.axaml.cs
+++ b/src/BetterMail.App/SearchOptionsWindow.axaml.cs
@@ -1,98 +1,264 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using BetterMail.Core;
+using System.Globalization;
namespace BetterMail.App;
public sealed partial class SearchOptionsWindow : Window
{
- private readonly Dictionary _fields = [];
private readonly Action? _apply;
- private string _loadedQuery = "";
+ private readonly MainWindowViewModel? _owner;
+ private readonly Dictionary _text = [];
+ private readonly Dictionary _choices = [];
+ private readonly Dictionary _single = [];
+ private readonly Dictionary> _paths = [];
+ private readonly List _dates = [];
+ private readonly StackPanel _mail = new() { Spacing = 12 };
+ private readonly StackPanel _datePanel = new() { Spacing = 8 };
+ private readonly Button _include = new() { HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch };
+ private readonly Button _exclude = new() { HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch };
+ private bool _loading = true;
public const string Help = """
- type:{mail|people|calendar|todo|drive|notes|everything} — one type; | means a choice, not query syntax.
- account:{name or email} — exact linked account; shared mailbox addresses require type:mail.
- Mail keys (imply type:mail):
- in:{Inbox/Projects} — exact full folder path; use account: to disambiguate. Explicit paths include archive, junk or trash.
- from:{name or email} to:{name or email} cc:{name or email}
- subject:{words} — subject contains these words.
- date:{>=2026-09-01} date:{<2026-10-01} — received date range.
- date:{>2026-09-01T14:30} — date and time; Z or ±HH:mm may specify a timezone.
- date:{>14:30} — local time of day on any date.
- Date operators: =, >, >=, <, <=. No operator means equals. Dates use YYYY-MM-DD. A date without time compares whole local days; a time without a date compares HH:mm:ss (missing seconds = 00).
- has:{attachments|noattachments} is:{read|unread|flagged|pinned}
- importance:{low|normal|high} category:{exact category}
- archives:{true|false} — false by default; junk/trash need an explicit in: path.
- Repeat date: to make a range. Other keys may appear once. Values are case-insensitive. Plain words search content; quotes/braces keep a phrase together for mail. Advanced mail filters search the synced cache.
- Example: budget type:mail account:{alex@work.example} in:{Inbox/Projects} from:{Jamie} has:attachments date:{>=2026-09-01}
+ type:mail type:people — choose multiple types (mail, people, calendar, todo, drive, notes). Omit type to search everything.
+ account:{name, email or ID} — repeat to select accounts/shared mailboxes. Shared mailboxes apply to mail.
+ in:{Inbox/Projects} — repeat to include full mail folder paths. notin:{Inbox/Old} excludes a folder and its descendants.
+ A picker-qualified path is ownerID::path and targets that exact mailbox/account. Unqualified paths match across the selected accounts.
+ With type:drive only, in: and notin: select Drive paths including descendants. Drive path searches use the synced file index.
+ Mail-only conditions apply to mail when multiple types are selected:
+ from:{name or email} to:{name or email} cc:{name or email} subject:{words}
+ date:{>=2026-09-01} date:{<2026-10-01} — combine date conditions.
+ date:{>2026-09-01T14:30} — date/time (optional Z or ±HH:mm). date:{>14:30} — local time on any day.
+ Operators: =, >, >=, <, <=. Dates use YYYY-MM-DD; times use HH:mm or HH:mm:ss.
+ has:attachments or has:noattachments; omit for either.
+ is:unread is:flagged — all chosen states must match (read, unread, flagged, pinned). Read and unread conflict.
+ category:Finance category:Projects — any selected category. importance:low|normal|high (choose one).
+ archives:true — include archives; junk/trash require an explicit in: path.
+ Plain words search content. Braces or quotes keep phrases together. Unknown keys/values are errors.
""";
public SearchOptionsWindow() => InitializeComponent();
- public SearchOptionsWindow(string query, Action apply) : this()
+ public SearchOptionsWindow(string query, Action apply, MainWindowViewModel? owner = null) : this()
{
- _apply = apply;
- SyntaxHelp.Text = Help;
- AddField("words", "Words / phrases", "budget \"quarterly review\"");
- foreach (var (key, label, hint) in new[]
+ _apply = apply; _owner = owner; SyntaxHelp.Text = Help;
+ _include.Classes.Add("filterChoice"); _exclude.Classes.Add("filterChoice");
+ SearchQuery parsed;
+ try { parsed = SearchQuery.Parse(query); }
+ catch (FormatException error)
{
- ("type", "Type", "mail, people, calendar, todo, drive, notes"),
- ("account", "Account / shared mailbox", "Name or email address"),
- ("in", "Folder path (mail)", "Inbox/Projects"),
- ("from", "From (mail)", "Name or email address"),
- ("to", "To (mail)", "Name or email address"),
- ("cc", "Cc (mail)", "Name or email address"),
- ("subject", "Subject contains (mail)", "Words in the subject"),
- ("date", "Received dates / times (mail)", ">=2026-09-01; <2026-10-01 (separate constraints with ; )"),
- ("has", "Attachments (mail)", "attachments or noattachments"),
- ("is", "State (mail)", "read, unread, flagged, pinned"),
- ("importance", "Importance (mail)", "low, normal, high"),
- ("category", "Category (mail)", "Exact category name"),
- ("archives", "Include archives (mail)", "true or false")
- }) AddField(key, label, hint);
- QueryText.Text = query;
- ReadQuery();
- }
- private void AddField(string key, string label, string hint)
- {
- var input = new TextBox { PlaceholderText = hint };
+ QueryText.Text = query;
+ FormError.Text = error.Message + " Correct the text in the main search box, then reopen Advanced filter.";
+ ApplyButton.IsEnabled = false;
+ return;
+ }
+ AddText(FieldsPanel, "words", "Words / phrases", string.Join(' ', parsed.Terms.Select(SearchQuery.Encode)));
+ AddChoices(FieldsPanel, "type", "Types", new[] { "Mail", "People", "Calendar", "To Do", "Drive", "Notes" }.Select(value => new SearchChoice(value, value)), parsed.Values("type").Where(value => value != "Everything"));
+ var accounts = owner?.Accounts.Select(account => new SearchChoice(account.AccountId, account.DisplayName + " · " + account.EmailAddress)) ?? [];
+ var shared = owner?.Mailboxes.Where(mailbox => mailbox.IsShared).Select(mailbox => new SearchChoice(mailbox.Id, "Shared · " + mailbox.DisplayName + " · " + mailbox.Address)) ?? [];
+ var selectedAccounts = parsed.Values("account").Select(value => owner?.SearchAccountIdentity(value) ?? value);
+ AddChoices(FieldsPanel, "account", "Accounts / shared mailboxes", accounts.Concat(shared), selectedAccounts);
+ _paths["Mail:in"] = []; _paths["Mail:notin"] = []; _paths["Drive:in"] = []; _paths["Drive:notin"] = [];
+ var context = parsed.IsDrivePathSearch ? "Drive" : "Mail";
+ _paths[context + ":in"].AddRange(parsed.Values("in")); _paths[context + ":notin"].AddRange(parsed.Values("notin"));
+ FieldsPanel.Children.Add(_include); FieldsPanel.Children.Add(_exclude);
+ _include.Click += async (_, _) => await ChoosePathsAsync("in");
+ _exclude.Click += async (_, _) => await ChoosePathsAsync("notin");
+ _mail.Children.Add(new TextBlock { Text = "Mail", FontSize = 18, FontWeight = Avalonia.Media.FontWeight.SemiBold });
+ foreach (var (key, label) in new[] { ("from", "From"), ("to", "To"), ("cc", "Cc"), ("subject", "Subject contains") })
+ AddText(_mail, key, label, parsed[key] ?? "");
+ AddSingle("has", "Attachments", ["Not set", "Yes", "No"], parsed["has"] is "attachments" or "attachment" ? "Yes" : parsed["has"] == "noattachments" ? "No" : "Not set");
+ AddChoices(_mail, "is", "State (all selected conditions)", new[] { "read", "unread", "flagged", "pinned" }.Select(value => new SearchChoice(value, value)), parsed.Values("is"));
+ AddChoices(_mail, "category", "Categories", (owner?.Messages.SelectMany(message => message.Categories).Distinct(StringComparer.OrdinalIgnoreCase) ?? []).Select(value => new SearchChoice(value, value)), parsed.Values("category"));
+ AddSingle("importance", "Importance", ["Not set", "low", "normal", "high"], parsed["importance"] ?? "Not set");
+ AddSingle("archives", "Include archives", ["Not set", "true", "false"], parsed["archives"] ?? "Not set");
+ _mail.Children.Add(new TextBlock { Text = "Received date / time", FontWeight = Avalonia.Media.FontWeight.SemiBold });
+ _mail.Children.Add(_datePanel);
+ foreach (var date in parsed.Dates) AddDate(date);
+ var addDate = new Button { Content = "+ Date condition" }; addDate.Click += (_, _) => { AddDate(null); Changed(); };
+ _mail.Children.Add(addDate); FieldsPanel.Children.Add(_mail);
+ _loading = false; Changed();
+ if (owner is not null) _ = LoadCategoriesAsync();
+ }
+ private async Task LoadCategoriesAsync()
+ {
+ try { _choices["category"].AddOptions((await _owner!.SearchCategoriesAsync()).Select(value => new SearchChoice(value, value))); }
+ catch (Exception error) { FormError.Text = "Some cached categories could not be loaded: " + error.Message; }
+ }
+ private void AddText(StackPanel panel, string key, string label, string value)
+ {
+ var input = new TextBox { Text = value }; _text[key] = input;
Avalonia.Automation.AutomationProperties.SetName(input, label);
- _fields.Add(key, input);
- FieldsPanel.Children.Add(new StackPanel { Spacing = 4, Children = { new TextBlock { Text = label }, input } });
+ panel.Children.Add(new StackPanel { Spacing = 4, Children = { new TextBlock { Text = label }, input } });
+ input.TextChanged += (_, _) => Changed();
}
- private void ReadQuery()
+ private void AddChoices(StackPanel panel, string key, string label, IEnumerable options, IEnumerable selected)
{
- try
+ var control = new SearchMultiChoice(label, options, selected, Changed); _choices[key] = control; panel.Children.Add(control);
+ }
+ private void AddSingle(string key, string label, string[] options, string selected)
+ {
+ var input = new ComboBox { ItemsSource = options, SelectedItem = selected, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch };
+ _single[key] = input; Avalonia.Automation.AutomationProperties.SetName(input, label);
+ _mail.Children.Add(new StackPanel { Spacing = 4, Children = { new TextBlock { Text = label }, input } });
+ input.SelectionChanged += (_, _) => Changed();
+ }
+ private bool DriveOnly => _choices["type"].Selected.SequenceEqual(new[] { "Drive" });
+ private bool IncludesMail => _choices["type"].Selected.Count == 0 || _choices["type"].Selected.Contains("Mail");
+ private void AddDate(SearchDate? date)
+ {
+ SearchDateRow row = null!;
+ row = new SearchDateRow(date, Changed, () => { _dates.Remove(row); _datePanel.Children.Remove(row); Changed(); });
+ _dates.Add(row); _datePanel.Children.Add(row);
+ }
+ private async Task ChoosePathsAsync(string key)
+ {
+ var context = DriveOnly ? "Drive" : "Mail";
+ var paths = _paths[context + ":" + key];
+ var picker = new SearchFoldersWindow(_owner, DriveOnly, _choices["account"].SelectedAvailable, paths, key == "notin");
+ var result = await picker.ShowDialog?>(this);
+ if (result is not null) { paths.Clear(); paths.AddRange(result); Changed(); }
+ }
+ private string Query()
+ {
+ var words = SearchQuery.Parse(_text["words"].Text ?? "");
+ if (words.Fields.Count > 0 || words.Dates.Count > 0) throw new FormatException("Use Words / phrases for content, not search keys.");
+ var tokens = words.Terms.Select(SearchQuery.Encode).ToList();
+ void Add(string key, IEnumerable values) => tokens.AddRange(values.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => key + ":" + SearchQuery.Encode(value.Trim())));
+ Add("type", _choices["type"].Selected); Add("account", _choices["account"].SelectedAvailable);
+ if (IncludesMail || DriveOnly)
+ {
+ var context = DriveOnly ? "Drive" : "Mail";
+ Add("in", _paths[context + ":in"]); Add("notin", _paths[context + ":notin"]);
+ }
+ if (IncludesMail)
{
- var parsed = SearchQuery.Parse(QueryText.Text ?? "");
- foreach (var (key, input) in _fields)
- input.Text = key switch { "words" => string.Join(' ', parsed.Terms.Select(SearchQuery.Encode)), "date" => string.Join("; ", parsed.Dates.Select(date => date.Source)), _ => parsed[key] ?? "" };
- _loadedQuery = QueryText.Text ?? "";
- FormError.Text = "";
- ApplyButton.IsEnabled = true;
+ foreach (var pair in _text.Where(pair => pair.Key != "words")) Add(pair.Key, [pair.Value.Text ?? ""]);
+ foreach (var key in new[] { "is", "category" }) Add(key, _choices[key].Selected);
+ foreach (var (key, input) in _single)
+ {
+ var value = input.SelectedItem as string;
+ if (value is not null && value != "Not set") Add(key, [key == "has" ? value == "Yes" ? "attachments" : "noattachments" : value]);
+ }
+ Add("date", _dates.Select(row => row.Value()));
}
- catch (FormatException exception) { FormError.Text = exception.Message; ApplyButton.IsEnabled = false; }
+ return SearchQuery.Parse(string.Join(' ', tokens)).Serialize();
+ }
+ private void Changed()
+ {
+ if (_loading) return;
+ _mail.IsVisible = IncludesMail;
+ _choices["account"].SetAvailable(value => IncludesMail || _owner?.Mailboxes.Any(mailbox => mailbox.IsShared && mailbox.Id == value) != true);
+ _include.IsVisible = _exclude.IsVisible = IncludesMail || DriveOnly;
+ var context = DriveOnly ? "Drive" : "Mail";
+ _include.Content = $"Include {context.ToLowerInvariant()} folders · {_paths[context + ":in"].Count} selected";
+ _exclude.Content = $"Exclude {context.ToLowerInvariant()} folders · {_paths[context + ":notin"].Count} selected";
+ try { QueryText.Text = Query(); FormError.Text = ""; ApplyButton.IsEnabled = true; }
+ catch (FormatException error) { FormError.Text = error.Message; ApplyButton.IsEnabled = false; }
}
- private void ReadClicked(object? sender, RoutedEventArgs args) => ReadQuery();
private void ApplyClicked(object? sender, RoutedEventArgs args)
{
try
{
- if (_loadedQuery != (QueryText.Text ?? "")) throw new FormatException("The query changed. Choose 'Read query into fields' before applying the form.");
- var result = BuildQuery(_fields.ToDictionary(field => field.Key, field => field.Value.Text ?? ""));
- _apply?.Invoke(result);
- Close();
+ var query = Query(); _owner?.ValidateSearchReferences(SearchQuery.Parse(query));
+ _apply?.Invoke(query); Close();
}
- catch (FormatException exception) { FormError.Text = exception.Message; }
+ catch (FormatException error) { FormError.Text = error.Message; }
}
internal static string BuildQuery(IReadOnlyDictionary values)
{
var words = SearchQuery.Parse(values.GetValueOrDefault("words") ?? "");
- if (words.Fields.Count > 0 || words.Dates.Count > 0) throw new FormatException("Put search keys in their fields or in the Query box; Words / phrases is for content.");
- var fields = values.Where(field => field.Key is not ("words" or "date") && !string.IsNullOrWhiteSpace(field.Value));
- var text = string.Join(' ', words.Terms.Select(SearchQuery.Encode)
- .Concat(fields.Select(field => field.Key + ":" + SearchQuery.Encode(field.Value.Trim())))
- .Concat((values.GetValueOrDefault("date") ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(date => "date:" + SearchQuery.Encode(date))));
- return SearchQuery.Parse(text).Serialize();
+ if (words.Fields.Count > 0 || words.Dates.Count > 0) throw new FormatException("Words / phrases is for content.");
+ return SearchQuery.Parse(string.Join(' ', words.Terms.Select(SearchQuery.Encode)
+ .Concat(values.Where(pair => pair.Key is not ("words" or "date") && !string.IsNullOrWhiteSpace(pair.Value)).Select(pair => pair.Key + ":" + SearchQuery.Encode(pair.Value.Trim())))
+ .Concat((values.GetValueOrDefault("date") ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(value => "date:" + SearchQuery.Encode(value))))).Serialize();
}
private void CancelClicked(object? sender, RoutedEventArgs args) => Close();
}
+
+public sealed record SearchChoice(string Value, string Label);
+public sealed class SearchMultiChoice : StackPanel
+{
+ private readonly List<(SearchChoice Choice, CheckBox Box)> _options = [];
+ private readonly Button _button = new() { HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch, HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left };
+ private readonly StackPanel _items = new() { Spacing = 3 };
+ private readonly Action _changed;
+ private Func _available = _ => true;
+ private string _find = "";
+ public IReadOnlyList SelectedAvailable => Selected.Where(_available).ToArray();
+ public void SetAvailable(Func available)
+ {
+ _available = available; RefreshOptions(); Update();
+ }
+ private void RefreshOptions()
+ {
+ foreach (var item in _options) item.Box.IsVisible = _available(item.Choice.Value) && item.Choice.Label.Contains(_find, StringComparison.OrdinalIgnoreCase);
+ }
+ public IReadOnlyList Selected => _options.Where(item => item.Box.IsChecked == true).Select(item => item.Choice.Value).ToArray();
+ public SearchMultiChoice(string label, IEnumerable options, IEnumerable selected, Action changed)
+ {
+ _changed = changed; Spacing = 4;
+ _button.Classes.Add("filterChoice");
+ Children.Add(new TextBlock { Text = label }); Children.Add(_button);
+ var find = new TextBox { PlaceholderText = "Find options" };
+ find.TextChanged += (_, _) => { _find = find.Text ?? ""; RefreshOptions(); };
+ _button.Flyout = new Flyout { Content = new StackPanel { Width = 380, Spacing = 8, Children = { find, new ScrollViewer { MaxHeight = 280, Content = _items } } } };
+ Avalonia.Automation.AutomationProperties.SetName(_button, label);
+ var values = selected.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ AddOptions(options.Concat(values.Select(value => new SearchChoice(value, value))));
+ foreach (var item in _options) item.Box.IsChecked = values.Contains(item.Choice.Value);
+ Update();
+ }
+ public void AddOptions(IEnumerable options)
+ {
+ foreach (var choice in options)
+ {
+ if (_options.Any(item => item.Choice.Value.Equals(choice.Value, StringComparison.OrdinalIgnoreCase))) continue;
+ var box = new CheckBox { Content = choice.Label }; _options.Add((choice, box)); _items.Children.Add(box);
+ box.IsCheckedChanged += (_, _) => { Update(); _changed(); };
+ }
+ }
+ private void Update()
+ {
+ var label = new TextBlock { Text = SelectedAvailable.Count == 0 ? "Any" : string.Join(", ", _options.Where(item => item.Box.IsChecked == true && _available(item.Choice.Value)).Select(item => item.Choice.Label)),
+ TextWrapping = Avalonia.Media.TextWrapping.Wrap };
+ var arrow = new TextBlock { Text = "⌄", VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center };
+ Grid.SetColumn(arrow, 1);
+ _button.HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Stretch;
+ _button.Content = new Grid { ColumnDefinitions = new("*,Auto"), ColumnSpacing = 10, Children = { label, arrow } };
+ }
+}
+
+internal sealed class SearchDateRow : StackPanel
+{
+ private readonly ComboBox _mode = new() { ItemsSource = new[] { "Date", "Date and time", "Time" }, Width = 145 };
+ private readonly ComboBox _operator = new() { ItemsSource = new[] { "=", ">", ">=", "<", "<=" }, Width = 65 };
+ private readonly DatePicker _date = new() { Width = 280 };
+ private readonly TimePicker _time = new() { ClockIdentifier = "24HourClock" };
+ private string? _original;
+ public SearchDateRow(SearchDate? value, Action changed, Action remove)
+ {
+ Spacing = 5;
+ _mode.SelectedIndex = value?.Time is not null ? 2 : value is { WholeDay: false } ? 1 : 0;
+ _operator.SelectedItem = value?.Operator ?? ">=";
+ _date.SelectedDate = value?.Instant?.ToLocalTime() ?? DateTimeOffset.Now;
+ _time.SelectedTime = value?.Time?.ToTimeSpan() ?? value?.Instant?.LocalDateTime.TimeOfDay ?? TimeSpan.Zero;
+ var delete = new Button { Content = "Remove" }; delete.Click += (_, _) => remove();
+ Children.Add(new WrapPanel { Children = { _mode, _operator, delete } });
+ Children.Add(new WrapPanel { Children = { _date, _time } });
+ void Update() { _date.IsVisible = _mode.SelectedIndex != 2; _time.IsVisible = _mode.SelectedIndex != 0; }
+ Update(); _original = value?.Source;
+ _mode.SelectionChanged += (_, _) => { _original = null; Update(); changed(); };
+ _operator.SelectionChanged += (_, _) => { _original = null; changed(); };
+ _date.SelectedDateChanged += (_, _) => { _original = null; changed(); };
+ _time.SelectedTimeChanged += (_, _) => { _original = null; changed(); };
+ }
+ public string Value()
+ {
+ if (_original is not null) return _original;
+ if (_mode.SelectedIndex != 2 && _date.SelectedDate is null || _mode.SelectedIndex != 0 && _time.SelectedTime is null)
+ throw new FormatException("Choose a date/time for each condition.");
+ var date = _date.SelectedDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ var time = (_time.SelectedTime ?? TimeSpan.Zero).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
+ return (_operator.SelectedItem as string ?? "=") + (_mode.SelectedIndex == 0 ? date : _mode.SelectedIndex == 2 ? time : date + "T" + time);
+ }
+}
diff --git a/src/BetterMail.App/SettingsView.axaml b/src/BetterMail.App/SettingsView.axaml
index 98ceddd..c88dfc8 100644
--- a/src/BetterMail.App/SettingsView.axaml
+++ b/src/BetterMail.App/SettingsView.axaml
@@ -8,10 +8,10 @@
-
-
-
@@ -143,6 +143,11 @@
+
+
+
+
+
@@ -177,6 +182,24 @@
Opacity="0.62" TextWrapping="Wrap" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -305,9 +328,7 @@
BorderBrush="{DynamicResource BetterMailDividerBrush}"
BorderThickness="1"
Background="White">
-
+
-
+
+
diff --git a/src/BetterMail.App/SettingsView.axaml.cs b/src/BetterMail.App/SettingsView.axaml.cs
index 3fae6d0..9bbffc2 100644
--- a/src/BetterMail.App/SettingsView.axaml.cs
+++ b/src/BetterMail.App/SettingsView.axaml.cs
@@ -65,6 +65,48 @@ private void ApplyResponsiveLayout(double width)
}
}
+ private Window? _signatureWindow;
+ private void EditSignatureClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (_signatureWindow is { } existing) { existing.Activate(); return; }
+ if (DataContext is not MainWindowViewModel vm) return;
+ var editor = new RichHtmlEditor { Html = vm.SignatureEditorHtml, IsReadOnly = !vm.CanEditSelectedSignature };
+ var title = new TextBlock { Text = vm.SignatureEditorName, FontSize = 20, FontWeight = Avalonia.Media.FontWeight.SemiBold };
+ var done = new Button { Content = "Done", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right };
+ var grid = new Grid { RowDefinitions = new RowDefinitions("Auto,*,Auto"), RowSpacing = 12, Margin = new Thickness(16) };
+ Grid.SetRow(editor, 1); Grid.SetRow(done, 2);
+ grid.Children.Add(title); grid.Children.Add(editor); grid.Children.Add(done);
+ var window = new Window { Title = "Signature editor", Width = 760, Height = 560, MinWidth = 400, MinHeight = 360, Content = grid };
+ // Bind to the selected signature only while that signature is still active.
+ var signature = vm.SelectedSignature;
+ editor.PropertyChanged += (_, change) =>
+ {
+ if (change.Property == RichHtmlEditor.HtmlProperty && ReferenceEquals(signature, vm.SelectedSignature)) vm.SignatureEditorHtml = editor.Html;
+ };
+ done.Click += async (_, _) => { await editor.CaptureAsync(); window.Close(); };
+ void SelectionChanged(object? _, System.ComponentModel.PropertyChangedEventArgs change)
+ {
+ if (change.PropertyName == nameof(vm.SelectedSignature) && !ReferenceEquals(signature, vm.SelectedSignature)) window.Close();
+ else if (ReferenceEquals(signature, vm.SelectedSignature))
+ {
+ if (change.PropertyName == nameof(vm.SignatureEditorHtml)) editor.Html = vm.SignatureEditorHtml;
+ if (change.PropertyName == nameof(vm.SignatureEditorName)) title.Text = vm.SignatureEditorName;
+ }
+ }
+ vm.PropertyChanged += SelectionChanged;
+ window.Closed += (_, _) => { vm.PropertyChanged -= SelectionChanged; _signatureWindow = null; };
+ _signatureWindow = window;
+ IndependentWindow.Show(window);
+ }
+
+ private void PreviewSignatureClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+ var preview = new NativeWebView { Source = vm.SelectedSignatureTemplatePreviewUri };
+ preview.NavigationStarted += SignaturePreviewNavigationStarted;
+ IndependentWindow.Show(new Window { Title = "Signature template preview", Width = 640, Height = 420, Content = preview });
+ }
+
private void SignaturePreviewNavigationStarted(
object? sender,
WebViewNavigationStartingEventArgs e)
diff --git a/src/BetterMail.App/TasksWorkspaceView.axaml b/src/BetterMail.App/TasksWorkspaceView.axaml
index c73a431..477e0ce 100644
--- a/src/BetterMail.App/TasksWorkspaceView.axaml
+++ b/src/BetterMail.App/TasksWorkspaceView.axaml
@@ -22,13 +22,7 @@
Command="{Binding NewTaskCommand}"
AutomationProperties.Name="Create task in selected list"
TabIndex="302" />
-
+
@@ -39,11 +33,11 @@
BorderThickness="0,0,1,0">
-
@@ -77,7 +71,7 @@
-
@@ -93,11 +87,11 @@
-
-
+
@@ -158,7 +152,7 @@
-
-
-
+
+
+
+
-
+
+
-
+
+
+
+
viewModel.NewTaskCommand,
Key.F5 => viewModel.RefreshCommand,
+ Key.N when args.KeyModifiers.HasFlag(KeyModifiers.Control) => viewModel.NewTaskCommand,
+ Key.Enter when viewModel.SelectedTask is not null && !viewModel.IsEditorOpen => viewModel.EditTaskCommand,
Key.Delete => viewModel.RequestDeleteCommand,
Key.Space when viewModel.SelectedTask is not null => viewModel.ToggleCompleteCommand,
Key.Escape when viewModel.IsDeleteConfirmationOpen => viewModel.CancelDeleteCommand,
Key.Escape when viewModel.IsEditorOpen => viewModel.CloseEditorCommand,
_ => null
};
- var parameter = args.Key == Key.Space ? viewModel.SelectedTask : null;
+ var parameter = args.Key is Key.Space or Key.Enter ? viewModel.SelectedTask : null;
if (command?.CanExecute(parameter) == true)
{
command.Execute(parameter);
diff --git a/src/BetterMail.App/TasksWorkspaceViewModel.cs b/src/BetterMail.App/TasksWorkspaceViewModel.cs
index 30763ae..6cd7bf9 100644
--- a/src/BetterMail.App/TasksWorkspaceViewModel.cs
+++ b/src/BetterMail.App/TasksWorkspaceViewModel.cs
@@ -135,7 +135,16 @@ private set
public string EditorHeading => IsEditing ? "Edit task" : "New task";
public string EditorTitle { get => _editorTitle; set => SetProperty(ref _editorTitle, value); }
public bool EditorHasDueDate { get => _editorHasDueDate; set => SetProperty(ref _editorHasDueDate, value); }
- public DateTimeOffset? EditorDueDate { get => _editorDueDate; set => SetProperty(ref _editorDueDate, value); }
+ public DateTimeOffset? EditorDueDate
+ {
+ get => _editorDueDate;
+ set { if (SetProperty(ref _editorDueDate, value)) RaisePropertyChanged(nameof(EditorCalendarDate)); }
+ }
+ public DateTime? EditorCalendarDate
+ {
+ get => EditorDueDate?.LocalDateTime.Date;
+ set => EditorDueDate = value is { } date ? new DateTimeOffset(DateTime.SpecifyKind(date.Date, DateTimeKind.Local)) : null;
+ }
public TimeSpan? EditorDueTime { get => _editorDueTime; set => SetProperty(ref _editorDueTime, value); }
public string? EditorError
{
@@ -198,7 +207,11 @@ public async Task UpdateAccountsAsync(
IReadOnlyList accounts,
CancellationToken cancellationToken = default)
{
- if (_accountsLoaded && _accounts.SequenceEqual(accounts)) return;
+ if (_accountsLoaded && _accounts.SequenceEqual(accounts))
+ {
+ await ReloadFromCacheAsync(cancellationToken);
+ return;
+ }
if (_accountsLoaded && WorkspaceAccountOrder.TryApply(AccountGroups, accounts, static item => item.Account))
{
_accounts = accounts.ToArray();
@@ -209,6 +222,49 @@ public async Task UpdateAccountsAsync(
_accountsLoaded = true;
}
+ internal async Task ReloadFromCacheAsync(CancellationToken cancellationToken = default)
+ {
+ if (_store is null || IsLoading) return;
+ var accounts = _accounts.ToArray();
+ var snapshots = await Task.WhenAll(accounts.Select(async account =>
+ {
+ var lists = await _store.GetWorkspaceItemsAsync("task-list", account.AccountId, "all", cancellationToken);
+ var contents = await Task.WhenAll(lists.Where(list => list.AccountId == account.AccountId).Select(async list =>
+ (List: list, Tasks: await _store.GetWorkspaceItemsAsync("task", account.AccountId, list.ProviderId, cancellationToken))));
+ return (Account: account, Contents: contents);
+ }));
+ // Account changes and provider operations may have started while the cache was read.
+ if (IsLoading || !_accounts.SequenceEqual(accounts)) return;
+ var selectedList = SelectedList;
+ var selectedTask = SelectedTask;
+ var groups = new List();
+ foreach (var snapshot in snapshots)
+ {
+ var group = AccountGroups.FirstOrDefault(item => item.Account == snapshot.Account) ?? new TaskAccountGroup(snapshot.Account);
+ var lists = new List();
+ foreach (var content in snapshot.Contents)
+ {
+ var list = group.Lists.FirstOrDefault(item => item.Info == content.List) ?? new TaskListChoice(group, content.List);
+ var items = content.Tasks.Where(task => task.AccountId == snapshot.Account.AccountId && task.ListId == list.Info.ProviderId)
+ .Select(task => list.Tasks.FirstOrDefault(item => item.Info == task) ?? new TaskWorkspaceItem(list, task)).ToArray();
+ CollectionUpdates.Reconcile(list.Tasks, items, item => item.Info.ProviderId);
+ lists.Add(list);
+ }
+ CollectionUpdates.Reconcile(group.Lists, lists, item => item.Info.ProviderId);
+ groups.Add(group);
+ }
+ CollectionUpdates.Reconcile(AccountGroups, groups, item => item.Account.AccountId);
+ SelectedList = selectedList is null ? null : groups
+ .Where(group => group.Account.AccountId == selectedList.Group.Account.AccountId)
+ .SelectMany(group => group.Lists).FirstOrDefault(list => list.Info.ProviderId == selectedList.Info.ProviderId);
+ RebuildVisibleTasks();
+ SelectedTask = selectedTask is null ? null : VisibleTasks.FirstOrDefault(item =>
+ item.List.Group.Account.AccountId == selectedTask.List.Group.Account.AccountId &&
+ item.Info.ListId == selectedTask.Info.ListId && item.Info.ProviderId == selectedTask.Info.ProviderId);
+ // Editor fields and its original task snapshot remain untouched until Save or Cancel.
+ RaisePartialErrors();
+ }
+
private async Task LoadAccountAsync(
MailAccount account,
CancellationToken cancellationToken)
@@ -505,11 +561,8 @@ private void RebuildVisibleTasks()
.ThenBy(static task => task.Info.DueAt)
.ThenBy(static task => task.Info.Title, StringComparer.OrdinalIgnoreCase)
.ToArray();
- VisibleTasks.Clear();
- foreach (var task in tasks)
- {
- VisibleTasks.Add(task);
- }
+ CollectionUpdates.Reconcile(VisibleTasks, tasks,
+ task => (task.List.Group.Account.AccountId, task.Info.ListId, task.Info.ProviderId));
RaisePropertyChanged(nameof(HasTasks));
RaisePropertyChanged(nameof(HasNoTasks));
RaisePropertyChanged(nameof(OpenCount));
diff --git a/src/BetterMail.Core/EncryptedMailStore.MessageReads.cs b/src/BetterMail.Core/EncryptedMailStore.MessageReads.cs
new file mode 100644
index 0000000..5d0e656
--- /dev/null
+++ b/src/BetterMail.Core/EncryptedMailStore.MessageReads.cs
@@ -0,0 +1,62 @@
+using Microsoft.Data.Sqlite;
+
+namespace BetterMail.Core;
+
+public sealed partial class EncryptedMailStore
+{
+ private readonly SemaphoreSlim _messageReadGate = new(1, 1);
+ private SqliteConnection? _messageReadConnection;
+ private bool _messageReaderDisposed;
+
+ // Dedicated WAL snapshots let message bodies load while sync or folder navigation is busy.
+ // Private cache avoids shared-cache table locks; reuse the encrypted connection to avoid
+ // repeating SQLCipher key derivation on every message click.
+ private async Task WithMessageReadAsync(Func> action, CancellationToken token)
+ {
+ await _messageReadGate.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ return await Task.Run(async () =>
+ {
+ ObjectDisposedException.ThrowIf(_messageReaderDisposed, this);
+ _ = GetConnection(); // Require initialization, including schema migrations.
+ if (_messageReadConnection 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);
+ _messageReadConnection = connection;
+ }
+ catch { await connection.DisposeAsync().ConfigureAwait(false); throw; }
+ }
+ try { return await action(_messageReadConnection).ConfigureAwait(false); }
+ catch (SqliteException) when (token.IsCancellationRequested)
+ {
+ throw new OperationCanceledException(token);
+ }
+ }, token).ConfigureAwait(false);
+ }
+ finally { _messageReadGate.Release(); }
+ }
+
+ private async Task DisposeMessageReaderAsync()
+ {
+ await _messageReadGate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ _messageReaderDisposed = true;
+ if (_messageReadConnection is not null)
+ {
+ await _messageReadConnection.DisposeAsync().ConfigureAwait(false);
+ _messageReadConnection = null;
+ }
+ }
+ finally { _messageReadGate.Release(); }
+ }
+}
diff --git a/src/BetterMail.Core/EncryptedMailStore.Search.cs b/src/BetterMail.Core/EncryptedMailStore.Search.cs
index c8e8954..3555c97 100644
--- a/src/BetterMail.Core/EncryptedMailStore.Search.cs
+++ b/src/BetterMail.Core/EncryptedMailStore.Search.cs
@@ -5,15 +5,50 @@ namespace BetterMail.Core;
public sealed partial class EncryptedMailStore
{
+ public Task> SearchFilteredDriveFilesAsync(SearchQuery query, IReadOnlyList? accountIds = null,
+ int limit = 40, CancellationToken cancellationToken = default)
+ {
+ var clauses = new List();
+ var parameters = new List<(string Name, object Value)>();
+ string Add(object value) { var name = "$drive" + parameters.Count; parameters.Add((name, value)); return name; }
+ if (accountIds is not null) clauses.Add($"account_id IN (SELECT value FROM json_each({Add(JsonSerializer.Serialize(accountIds))}))");
+ const string parent = "lower(trim(replace(COALESCE(json_extract(payload_json, '$.ParentPath'), ''), '/drive/root:', ''), '/'))";
+ string PathCondition(string value)
+ {
+ var parts = value.Split("::", 2);
+ var path = string.Join('/', parts[^1].Replace("/drive/root:", "", StringComparison.OrdinalIgnoreCase)
+ .Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
+ var condition = path.Length == 0 ? "1 = 1" : $"({parent} = {Add(path)} OR instr({parent}, {Add(path + "/")}) = 1)";
+ return parts.Length == 2 ? $"(account_id = {Add(parts[0])} AND {condition})" : condition;
+ }
+ if (query.Values("in").Count > 0) clauses.Add("(" + string.Join(" OR ", query.Values("in").Select(PathCondition)) + ")");
+ if (query.Values("notin").Count > 0) clauses.Add("NOT (" + string.Join(" OR ", query.Values("notin").Select(PathCondition)) + ")");
+ return QueryWorkspaceItemsAsync("drive-file", null, null, query.Text, limit, cancellationToken,
+ clauses.Count == 0 ? "" : "AND " + string.Join(" AND ", clauses), parameters.ToArray());
+ }
+
+ public Task> GetMailCategoriesAsync(CancellationToken cancellationToken = default) =>
+ WithLockAsync>(async connection =>
+ {
+ var result = new List();
+ await using var command = connection.CreateCommand();
+ command.CommandText = "SELECT DISTINCT category.value FROM messages, json_each(categories_json) category WHERE category.type = 'text' ORDER BY category.value COLLATE NOCASE";
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) result.Add(reader.GetString(0));
+ return result;
+ }, cancellationToken);
+
public Task> SearchFilteredMailAsync(SearchQuery query,
IReadOnlyList folders, int limit = 500, CancellationToken cancellationToken = default,
- string? accountId = null, string? mailboxId = null, bool includeUnknownFolders = false, IReadOnlyList? knownFolders = null)
+ string? accountId = null, string? mailboxId = null, bool includeUnknownFolders = false, IReadOnlyList? knownFolders = null, IReadOnlyList? allowedMailboxIds = null)
{
var clauses = new List { "(EXISTS (SELECT 1 FROM json_each($folders) f WHERE json_extract(f.value, '$.MailboxId') = mailbox_id AND json_extract(f.value, '$.FolderId') = folder_id) OR ($unknown = 1 AND NOT EXISTS (SELECT 1 FROM json_each($known) f WHERE json_extract(f.value, '$.MailboxId') = mailbox_id AND json_extract(f.value, '$.FolderId') = folder_id) AND NOT EXISTS (SELECT 1 FROM mail_folders f WHERE f.mailbox_id = messages.mailbox_id AND f.provider_id = messages.folder_id)))",
"($account IS NULL OR EXISTS (SELECT 1 FROM mailboxes m WHERE m.account_id = $account AND m.account_id || ':' || lower(m.address) = messages.mailbox_id))",
"($mailbox IS NULL OR mailbox_id = $mailbox)" };
var parameters = new List<(string Name, object Value)> { ("$folders", JsonSerializer.Serialize(folders)), ("$unknown", includeUnknownFolders), ("$known", System.Text.Json.JsonSerializer.Serialize(knownFolders ?? [])), ("$account", (object?)accountId ?? DBNull.Value), ("$mailbox", (object?)mailboxId ?? DBNull.Value) };
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);
@@ -21,7 +56,7 @@ public Task> SearchFilteredMailAsync(SearchQuery quer
? $"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})");
}
- foreach (var key in new[] { "from", "to", "cc", "subject", "category" })
+ foreach (var key in new[] { "from", "to", "cc", "subject" })
{
if (query[key] is not { } value) continue;
var parameter = Add(value);
@@ -34,8 +69,10 @@ public Task> SearchFilteredMailAsync(SearchQuery quer
_ => Contains("subject")
});
}
+ if (query.Values("category").Count > 0)
+ clauses.Add($"EXISTS (SELECT 1 FROM json_each(categories_json) category JOIN json_each({Add(JsonSerializer.Serialize(query.Values("category")))}) chosen ON lower(category.value) = lower(chosen.value))");
if (query["has"] is { } has) clauses.Add("has_attachments = " + (has == "noattachments" ? "0" : "1"));
- if (query["is"] is { } state) clauses.Add(state switch
+ foreach (var state in query.Values("is")) clauses.Add(state switch
{ "read" => "is_read = 1", "unread" => "is_read = 0", "flagged" => "is_flagged = 1", _ => "is_pinned = 1" });
if (query["importance"] is { } importance) clauses.Add("importance = " + Add((int)Enum.Parse(importance, true)));
foreach (var date in query.Dates)
diff --git a/src/BetterMail.Core/EncryptedMailStore.cs b/src/BetterMail.Core/EncryptedMailStore.cs
index 5d190ac..6de0ebd 100644
--- a/src/BetterMail.Core/EncryptedMailStore.cs
+++ b/src/BetterMail.Core/EncryptedMailStore.cs
@@ -652,7 +652,7 @@ ORDER BY rowid LIMIT 500
string mailboxId,
string providerMessageId,
CancellationToken cancellationToken = default) =>
- (await QueryMessagesAsync(
+ (await QueryReadingPaneMessagesAsync(
"WHERE mailbox_id = $mailbox AND provider_id = $provider",
1,
true,
@@ -710,10 +710,10 @@ public Task> SearchMailboxAsync(string? mailboxId, st
public Task> GetThreadMessagesAsync(
string threadId,
CancellationToken cancellationToken = default) =>
- QueryMessagesAsync(
+ QueryReadingPaneMessagesAsync(
"""
- WHERE EXISTS (SELECT 1 FROM message_threads thread WHERE thread.mailbox_id = messages.mailbox_id
- AND thread.provider_id = messages.provider_id AND thread.thread_id = $thread)
+ WHERE (mailbox_id, provider_id) IN
+ (SELECT mailbox_id, provider_id FROM message_threads WHERE thread_id = $thread)
AND NOT EXISTS (SELECT 1 FROM mail_folders folder WHERE folder.mailbox_id = messages.mailbox_id
AND folder.provider_id = messages.folder_id AND folder.well_known_name IN ('deleteditems', 'junkemail'))
""",
@@ -902,8 +902,11 @@ public Task> SearchWorkspaceItemsAsync(
string query,
int limit = 200,
string? accountId = null,
- CancellationToken cancellationToken = default) =>
- QueryWorkspaceItemsAsync(kind, accountId, null, query, limit, cancellationToken);
+ CancellationToken cancellationToken = default,
+ IReadOnlyList? accountIds = null) =>
+ QueryWorkspaceItemsAsync(kind, accountId, null, query, limit, cancellationToken,
+ accountIds is null ? "" : "AND account_id IN (SELECT value FROM json_each($accounts))",
+ ("$accounts", JsonSerializer.Serialize(accountIds)));
public Task GarbageCollectWorkspaceAsync(
string accountId,
@@ -1410,6 +1413,7 @@ public async ValueTask DisposeAsync()
try
{
await DisposeFolderReaderAsync().ConfigureAwait(false);
+ await DisposeMessageReaderAsync().ConfigureAwait(false);
if (_connection is not null)
{
var connection = _connection;
@@ -1426,11 +1430,18 @@ public async ValueTask DisposeAsync()
}
private Task> QueryMessagesAsync(
- string where,
- int limit,
- bool includeBody,
- CancellationToken cancellationToken,
- params (string Name, object Value)[] parameters) => WithLockAsync>(async connection =>
+ string where, int limit, bool includeBody, CancellationToken cancellationToken,
+ params (string Name, object Value)[] parameters) =>
+ WithLockAsync(connection => QueryMessagesOnConnectionAsync(connection, where, limit, includeBody, cancellationToken, parameters), cancellationToken);
+
+ private Task> QueryReadingPaneMessagesAsync(
+ string where, int limit, bool includeBody, CancellationToken cancellationToken,
+ params (string Name, object Value)[] parameters) =>
+ WithMessageReadAsync(connection => QueryMessagesOnConnectionAsync(connection, where, limit, includeBody, cancellationToken, parameters), cancellationToken);
+
+ private static async Task> QueryMessagesOnConnectionAsync(
+ SqliteConnection connection, string where, int limit, bool includeBody, CancellationToken cancellationToken,
+ params (string Name, object Value)[] parameters)
{
var messages = new List();
await using var command = connection.CreateCommand();
@@ -1455,7 +1466,7 @@ FROM messages {where}
}
return messages;
- }, cancellationToken);
+ }
private async Task WithLockAsync(Func action, CancellationToken cancellationToken)
{
diff --git a/src/BetterMail.Core/Models.cs b/src/BetterMail.Core/Models.cs
index 9dba029..32ddad8 100644
--- a/src/BetterMail.Core/Models.cs
+++ b/src/BetterMail.Core/Models.cs
@@ -300,7 +300,9 @@ public sealed record CalendarEventDraft(
IReadOnlyList? Attendees = null,
bool IsReminderOn = true,
int ReminderMinutesBeforeStart = 15,
- CalendarRecurrence? Recurrence = null);
+ CalendarRecurrence? Recurrence = null,
+ string? Body = null,
+ bool BodyIsHtml = false);
public sealed record CalendarEvent(
string ProviderId,
diff --git a/src/BetterMail.Core/SearchQuery.cs b/src/BetterMail.Core/SearchQuery.cs
index 0d231ce..3787a23 100644
--- a/src/BetterMail.Core/SearchQuery.cs
+++ b/src/BetterMail.Core/SearchQuery.cs
@@ -7,11 +7,17 @@ namespace BetterMail.Core;
public sealed record SearchQuery(IReadOnlyList Terms, IReadOnlyDictionary Fields,
IReadOnlyList Dates)
{
- public static readonly string[] Keys = ["type", "account", "in", "from", "to", "cc", "subject", "date", "has", "is", "importance", "category", "archives"];
+ public static readonly string[] Keys = ["type", "account", "in", "notin", "from", "to", "cc", "subject", "date", "has", "is", "importance", "category", "archives"];
public string Text => string.Join(' ', Terms);
public string? this[string key] => Fields.GetValueOrDefault(key);
- public bool HasMailFilters => Dates.Count > 0 || Fields.Keys.Any(key => key is not ("type" or "account"));
- public string Scope => this["type"] ?? (HasMailFilters ? "Mail" : "Everything");
+ public IReadOnlyDictionary> Multiple { get; init; } = new Dictionary>();
+ public IReadOnlyList Values(string key) => Multiple.TryGetValue(key, out var values) ? values : this[key] is { } value ? [value] : [];
+ public bool HasMailFilters => Dates.Count > 0 || Fields.Keys.Any(key => key is "from" or "to" or "cc" or "subject" or "has" or "is" or "importance" or "category" or "archives") ||
+ (Values("in").Count + Values("notin").Count > 0 && !IsDrivePathSearch);
+ public bool IsDrivePathSearch => Values("type").Count == 1 && Values("type")[0] == "Drive";
+ public IReadOnlyList Scopes => Values("type").Count > 0 ? Values("type") : [HasMailFilters ? "Mail" : "Everything"];
+ public bool Includes(string scope) => Scopes.Contains("Everything") || Scopes.Contains(scope);
+ public string Scope => Scopes.Count == 1 ? Scopes[0] : "Multiple";
public string FtsText => string.Join(' ', Terms.Select(term => "\"" + term.Replace("\"", "\"\"") + "\"*"));
public static SearchQuery Parse(string text)
@@ -19,6 +25,7 @@ public static SearchQuery Parse(string text)
var terms = new List();
var fields = new Dictionary(StringComparer.OrdinalIgnoreCase);
var dates = new List();
+ var multiple = new Dictionary>(StringComparer.OrdinalIgnoreCase);
var index = 0;
while (index < text.Length)
{
@@ -31,7 +38,7 @@ public static SearchQuery Parse(string text)
text[start..index] is not ("http" or "https"))
{
key = text[start..index].ToLowerInvariant();
- if (!Keys.Contains(key)) throw new FormatException($"Unknown search key '{key}'. Open Search options for available keys.");
+ if (!Keys.Contains(key)) throw new FormatException($"Unknown search key '{key}'. Open Advanced filter for available keys.");
index++;
}
else index = start;
@@ -39,10 +46,17 @@ text[start..index] is not ("http" or "https"))
if (value.Length == 0) throw new FormatException($"Enter a value for {key ?? "the search term"}.");
if (key is null) terms.Add(value);
else if (key == "date") dates.Add(SearchDate.Parse(value));
- else if (!fields.TryAdd(key, value)) throw new FormatException($"Use '{key}' once. Date is the only repeatable key.");
+ else
+ {
+ if (!fields.TryAdd(key, value) && key is not ("type" or "account" or "in" or "notin" or "is" or "category"))
+ throw new FormatException($"Use '{key}' once.");
+ if (!multiple.TryGetValue(key, out var values)) multiple[key] = values = [];
+ values.Add(value);
+ }
}
- if (fields.TryGetValue("type", out var type))
- fields["type"] = type.ToLowerInvariant() switch
+ if (multiple.TryGetValue("type", out var types))
+ for (var i = 0; i < types.Count; i++)
+ types[i] = types[i].ToLowerInvariant() switch
{
"everything" or "all" => "Everything", "mail" => "Mail", "people" or "contacts" => "People",
"calendar" => "Calendar", "todo" or "todos" or "tasks" or "to do" => "To Do",
@@ -53,18 +67,42 @@ text[start..index] is not ("http" or "https"))
Validate("is", ["read", "unread", "flagged", "pinned"]);
Validate("importance", ["low", "normal", "high"]);
Validate("archives", ["true", "false"]);
- var query = new SearchQuery(terms, fields, dates);
- if (query.HasMailFilters && query.Scope != "Mail")
+ foreach (var pair in multiple) fields[pair.Key] = pair.Value[0];
+ var query = new SearchQuery(terms, fields, dates) { Multiple = multiple.ToDictionary(pair => pair.Key,
+ pair => (IReadOnlyList)pair.Value.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), StringComparer.OrdinalIgnoreCase) };
+ if (query.Values("is").Contains("read") && query.Values("is").Contains("unread"))
+ throw new FormatException("Choose read or unread; a message cannot be both.");
+ if (query.HasMailFilters && !query.Includes("Mail"))
throw new FormatException("Folder, address, subject, date, attachment, state, importance, category and archive filters require type:mail (or omit type).");
return query;
void Validate(string key, string[] allowed)
{
- if (!fields.TryGetValue(key, out var value)) return;
- value = value.ToLowerInvariant();
- if (!allowed.Contains(value)) throw new FormatException($"{key}: use {string.Join(", ", allowed)}.");
- fields[key] = value;
+ if (!multiple.TryGetValue(key, out var values)) return;
+ for (var i = 0; i < values.Count; i++)
+ {
+ var value = values[i].ToLowerInvariant();
+ if (!allowed.Contains(value)) throw new FormatException($"{key}: use {string.Join(", ", allowed)}.");
+ values[i] = value;
+ }
+ }
+ }
+
+ public static IReadOnlyList Tokens(string text)
+ {
+ var result = new List();
+ var start = 0; char close = '\0'; var escaped = false;
+ for (var i = 0; i < text.Length; i++)
+ {
+ var c = text[i];
+ if (escaped) { escaped = false; continue; }
+ if (c == '\\' && close != '\0') { escaped = true; continue; }
+ if (close != '\0') { if (c == close) close = '\0'; continue; }
+ if (c is '{' or '"') { close = c == '{' ? '}' : '"'; continue; }
+ if (char.IsWhiteSpace(c)) { if (i > start) result.Add(text[start..i]); start = i + 1; }
}
+ if (start < text.Length) result.Add(text[start..]);
+ return result;
}
private static string ReadValue(string text, ref int index)
@@ -92,7 +130,7 @@ private static string ReadValue(string text, ref int index)
public static string Encode(string value) => value.Length > 0 && !value.Any(c => char.IsWhiteSpace(c) || c is ':' or '{' or '}' or '"' or '\\')
? value : "{" + value.Replace("\\", "\\\\").Replace("}", "\\}") + "}";
public string Serialize() => string.Join(' ', Terms.Select(Encode)
- .Concat(Fields.Select(field => field.Key + ":" + Encode(field.Value)))
+ .Concat(Fields.Keys.SelectMany(key => Values(key).Select(value => key + ":" + Encode(value))))
.Concat(Dates.Select(date => "date:" + Encode(date.Source))));
}
diff --git a/src/BetterMail.Microsoft365/Microsoft365WorkspaceProvider.cs b/src/BetterMail.Microsoft365/Microsoft365WorkspaceProvider.cs
index 3eb2685..33a19d8 100644
--- a/src/BetterMail.Microsoft365/Microsoft365WorkspaceProvider.cs
+++ b/src/BetterMail.Microsoft365/Microsoft365WorkspaceProvider.cs
@@ -1485,7 +1485,7 @@ internal static string CalendarViewEndpoint(
internal static object BuildEventPayload(CalendarEventDraft draft)
{
ValidateDraft(draft);
- return new
+ var payload = new
{
subject = draft.Subject,
start = GraphDate(draft.StartsAt),
@@ -1500,6 +1500,12 @@ internal static object BuildEventPayload(CalendarEventDraft draft)
reminderMinutesBeforeStart = draft.ReminderMinutesBeforeStart,
recurrence = draft.Recurrence is null ? null : BuildRecurrence(draft.Recurrence)
};
+ var result = JsonSerializer.SerializeToElement(payload).EnumerateObject()
+ .ToDictionary(property => property.Name, property => (object)property.Value.Clone());
+ // Omitted bodies leave existing descriptions intact for older callers.
+ if (draft.Body is not null)
+ result["body"] = new { content = draft.Body, contentType = draft.BodyIsHtml ? "html" : "text" };
+ return result;
}
internal static CalendarEvent MapEvent(
diff --git a/tests/BetterMail.Tests/AdvancedSearchTests.cs b/tests/BetterMail.Tests/AdvancedSearchTests.cs
new file mode 100644
index 0000000..de3567a
--- /dev/null
+++ b/tests/BetterMail.Tests/AdvancedSearchTests.cs
@@ -0,0 +1,181 @@
+using BetterMail.App;
+using BetterMail.Core;
+
+namespace BetterMail.Tests;
+
+public sealed class AdvancedSearchTests
+{
+ [Fact]
+ public async Task PeopleSearchRanksAllAccountsBeforeLimitingProviderAndCachedResults()
+ {
+ var token = TestContext.Current.CancellationToken;
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-people-ranking-" + Guid.NewGuid());
+ try
+ {
+ await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('F', 64));
+ await store.InitializeAsync(token);
+ var provider = System.Reflection.DispatchProxy.Create();
+ var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null, workspaceProvider: provider);
+ foreach (var id in new[] { "a", "b", "c" })
+ {
+ var account = new MailAccount("microsoft365", id, "tenant", id + "@example.test", id, ProviderCapabilities.Contacts);
+ vm.Accounts.Add(account);
+ vm.Mailboxes.Add(new(id, account.EmailAddress, id));
+ }
+ vm.SearchText = "person type:people account:a account:b";
+ await ((AsyncCommand)vm.SearchCommand).ExecuteAsync();
+ Assert.Null(vm.SearchError);
+ Assert.Equal(30, vm.GlobalSearchResults.Count);
+ Assert.Contains(vm.GlobalSearchResults, result => result.Value is ContactInfo { AccountId: "b" });
+ Assert.DoesNotContain(vm.GlobalSearchResults, result => result.Value is ContactInfo { AccountId: "c" });
+ var selected = vm.GlobalSearchResults.Select(result => ((ContactInfo)result.Value!).ProviderId).Order().ToArray();
+ vm.Accounts.Move(1, 0);
+ await ((AsyncCommand)vm.SearchCommand).ExecuteAsync();
+ Assert.Equal(selected, vm.GlobalSearchResults.Select(result => ((ContactInfo)result.Value!).ProviderId).Order().ToArray());
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
+ public class PeopleSearchProvider : System.Reflection.DispatchProxy
+ {
+ protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args)
+ {
+ if (method?.Name != "SearchContactsAsync") throw new NotSupportedException(method?.Name);
+ var account = (MailAccount)args![0]!;
+ IReadOnlyList contacts = Enumerable.Range(0, account.AccountId == "a" ? 40 : 1)
+ .Select(index => new ContactInfo(account.AccountId + index, (account.AccountId == "a" ? "Z person " : "A person ") + index,
+ [account.AccountId + index + "@example.test"], account.AccountId)).ToArray();
+ return Task.FromResult(contacts);
+ }
+ }
+
+ [Fact]
+ public async Task WorkspaceSearchIncludesSharedParentAndLimitsAcrossAllAccounts()
+ {
+ var token = TestContext.Current.CancellationToken;
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-account-search-" + Guid.NewGuid());
+ try
+ {
+ await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('C', 64));
+ await store.InitializeAsync(token);
+ var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null);
+ foreach (var id in new[] { "a", "b", "c" })
+ {
+ var account = new MailAccount("microsoft365", id, "tenant", id + "@example.test", id, ProviderCapabilities.Mail | ProviderCapabilities.Files);
+ vm.Accounts.Add(account);
+ var files = Enumerable.Range(0, id == "a" ? 60 : 1).Select(i => new CloudFile(id + i, "Budget", 1, null, id)).ToArray();
+ await store.UpsertWorkspaceItemsAsync("drive-file", id, "all", files, file => file.ProviderId, file => file.Name, token);
+ }
+ var combined = await store.SearchWorkspaceItemsAsync("drive-file", "Budget", 40, cancellationToken: token, accountIds: ["a", "b"]);
+ Assert.Equal(40, combined.Count);
+ Assert.Contains(combined, file => file.AccountId == "b");
+ Assert.DoesNotContain(combined, file => file.AccountId == "c");
+ var reversed = await store.SearchWorkspaceItemsAsync("drive-file", "Budget", 40, cancellationToken: token, accountIds: ["b", "a"]);
+ Assert.Equal(combined.Select(file => file.ProviderId), reversed.Select(file => file.ProviderId));
+ var shared = new Mailbox("b", "team@example.test", "Team", IsShared: true);
+ vm.Mailboxes.Add(shared);
+ vm.SearchText = "Budget type:mail type:drive account:" + shared.Id;
+ await ((AsyncCommand)vm.SearchCommand).ExecuteAsync();
+ Assert.Null(vm.SearchError);
+ Assert.Contains(vm.GlobalSearchResults, result => result.Value is CloudFile { AccountId: "b" });
+ Assert.DoesNotContain(vm.GlobalSearchResults, result => result.Value is CloudFile { AccountId: "a" or "c" });
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
+ [Fact]
+ public void EmptySearchFocusKeepsAdvancedFilterReachable()
+ {
+ var vm = new MainWindowViewModel(null, Path.GetTempPath(), _ => { }, _ => { }, null);
+ vm.OpenSearchInput();
+ Assert.True(vm.IsGlobalSearchOpen);
+ Assert.True(vm.IsSearchEditing);
+ Assert.Contains("Advanced filter", vm.SearchNotice);
+ }
+
+ [Fact]
+ public void RepeatedSelectionsRoundTripAndRejectContradictoryStates()
+ {
+ var query = SearchQuery.Parse("budget type:mail type:drive account:a account:b in:Inbox in:Projects notin:Old category:Finance category:Projects is:unread is:flagged");
+ Assert.True(query.Includes("Mail")); Assert.True(query.Includes("Drive")); Assert.False(query.Includes("People"));
+ var restored = SearchQuery.Parse(query.Serialize());
+ foreach (var key in new[] { "type", "account", "in", "notin", "category", "is" }) Assert.Equal(query.Values(key), restored.Values(key));
+ Assert.Throws(() => SearchQuery.Parse("is:read is:unread"));
+ Assert.Throws(() => SearchQuery.Parse("type:Mails"));
+ Assert.False(SearchQuery.Parse("type:drive in:Projects notin:Old").HasMailFilters);
+ }
+
+ [Fact]
+ public void BadgesKeepExactTextAndDistinguishInvalidFiltersFromValidOnes()
+ {
+ var vm = new MainWindowViewModel(null, Path.GetTempPath(), _ => { }, _ => { }, null);
+ const string raw = "budget type:Mails has:attachments subject:{quarterly review}";
+ vm.SearchText = raw;
+ Assert.True(vm.ShowSearchBadges);
+ Assert.False(vm.SearchBadges.Single(item => item.Text == "type:Mails").IsValid);
+ Assert.True(vm.SearchBadges.Single(item => item.Text == "has:attachments").IsValid);
+ Assert.False(vm.SearchBadges[0].IsFilter);
+ vm.IsSearchEditing = true; Assert.False(vm.ShowSearchBadges); Assert.Equal(raw, vm.SearchText);
+ vm.IsSearchEditing = false; Assert.True(vm.ShowSearchBadges); Assert.Equal(raw, vm.SearchText);
+ vm.SearchText = "type:mail account:missing";
+ Assert.True(vm.SearchBadges[0].IsValid); Assert.False(vm.SearchBadges[1].IsValid);
+ vm.SearchText = "subject:{unfinished phrase";
+ Assert.False(Assert.Single(vm.SearchBadges).IsValid);
+ }
+
+ [Fact]
+ public async Task MailAndDriveFilteringApplyAccountsPathsAndCategoriesBeforeTheLimit()
+ {
+ var token = TestContext.Current.CancellationToken;
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-advanced-search-" + Guid.NewGuid());
+ try
+ {
+ await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('B', 64));
+ await store.InitializeAsync(token);
+ var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null);
+ foreach (var id in new[] { "a", "b", "c" })
+ {
+ var account = new MailAccount("microsoft365", id, "tenant", id + "@example.test", id, ProviderCapabilities.Mail | ProviderCapabilities.Files);
+ var mailbox = new Mailbox(id, account.EmailAddress, id);
+ await store.SaveAccountAsync(account, token); await store.SaveMailboxAsync(mailbox, token);
+ vm.Accounts.Add(account); vm.Mailboxes.Add(mailbox);
+ foreach (var folder in new[] { new MailFolder(mailbox.Id, "inbox", "Inbox", 0, 0, "inbox"),
+ new MailFolder(mailbox.Id, "old", "Old", 0, 0, ParentProviderId: "inbox"),
+ new MailFolder(mailbox.Id, "child", "Child", 0, 0, ParentProviderId: "old") })
+ vm.Folders.Add(new(folder, id));
+ var message = new MailMessage(mailbox.Id, id + "-match", null, null, "inbox", "Budget", new("Person", "person@example.test"), [],
+ DateTimeOffset.Now, "Budget", "Budget", false, false, true, MailImportance.Normal, [id == "a" ? "Finance" : "Projects"], null, IsFlagged: true);
+ await store.ApplySyncPageAsync(id, new([message, message with { ProviderId = id + "-excluded", FolderId = "child" }], null, false), token);
+ await store.UpsertWorkspaceItemsAsync("drive-file", id, "index", new[] {
+ new CloudFile(id + "-file", "Budget", 10, null, id, ParentPath: "/drive/root:/Projects/Active"),
+ new CloudFile(id + "-old-file", "Budget", 10, null, id, ParentPath: "/drive/root:/Projects/Old/Child") }, item => item.ProviderId, item => item.Name, token);
+ }
+ vm.SearchText = "Budget type:mail type:drive account:a account:b category:Finance category:Projects is:unread is:flagged notin:{Inbox/Old}";
+ await ((AsyncCommand)vm.SearchCommand).ExecuteAsync();
+ Assert.Null(vm.SearchError);
+ var mails = vm.GlobalSearchResults.Where(item => item.Value is MailMessage).Select(item => (MailMessage)item.Value!).ToArray();
+ Assert.Equal(new[] { "a-match", "b-match" }, mails.Select(item => item.ProviderId).Order().ToArray());
+ Assert.All(vm.GlobalSearchResults.Where(item => item.Value is CloudFile), item => Assert.NotEqual("c", ((CloudFile)item.Value!).AccountId));
+ Assert.Contains(vm.GlobalSearchResults, item => item.Value is CloudFile);
+ vm.SearchText = "Budget type:drive account:a account:b in:Projects notin:{Projects/Old}";
+ await ((AsyncCommand)vm.SearchCommand).ExecuteAsync();
+ Assert.Null(vm.SearchError);
+ Assert.Equal(new[] { "a-file", "b-file" }, vm.GlobalSearchResults.Select(item => ((CloudFile)item.Value!).ProviderId).Order().ToArray());
+ var unrelated = Enumerable.Range(0, 600).Select(index => new CloudFile("unrelated-" + index, "Budget", 10, null, "a", ParentPath: "/Other")).ToArray();
+ await store.UpsertWorkspaceItemsAsync("drive-file", "a", "index", unrelated, item => item.ProviderId, item => item.Name, token);
+ var limited = await store.SearchFilteredDriveFilesAsync(SearchQuery.Parse("Budget type:drive in:{a::Projects} notin:{a::Projects/Old}"), ["a", "b"], 1, token);
+ Assert.Equal("a-file", Assert.Single(limited).ProviderId);
+ Assert.Empty(await store.SearchFilteredDriveFilesAsync(SearchQuery.Parse("type:drive notin:{/}"), ["a"], 1, token));
+ Assert.Equal(new[] { "Finance", "Projects" }, await store.GetMailCategoriesAsync(token));
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
+ [Theory]
+ [InlineData("a::Inbox", "a", "Inbox", true)]
+ [InlineData("a::Inbox", "b", "Inbox", false)]
+ [InlineData("Inbox/Old", "a", "Inbox/Old/Child", true)]
+ [InlineData("Inbox/Old", "a", "Inbox/Older", false)]
+ public void FolderMatchingRespectsOwnersAndPathBoundaries(string choice, string owner, string actual, bool match) =>
+ Assert.Equal(match, MainWindowViewModel.SearchPathMatches(choice, owner, actual, true));
+}
diff --git a/tests/BetterMail.Tests/CalendarWorkspaceViewModelTests.cs b/tests/BetterMail.Tests/CalendarWorkspaceViewModelTests.cs
index 286c84c..7357bfa 100644
--- a/tests/BetterMail.Tests/CalendarWorkspaceViewModelTests.cs
+++ b/tests/BetterMail.Tests/CalendarWorkspaceViewModelTests.cs
@@ -7,6 +7,38 @@ public sealed class CalendarWorkspaceViewModelTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 14, 9, 0, 0, TimeSpan.FromHours(2));
+ [Fact]
+ public async Task EmailEventPreservesFullHtmlAndSupportsDescriptionChanges()
+ {
+ var provider = new FakeCalendarProvider();
+ var vm = new CalendarWorkspaceViewModel(provider, Accounts(), () => Now);
+ await vm.InitializeAsync(TestContext.Current.CancellationToken);
+ const string body = "Full content & details
Second paragraph
";
+ var mail = new MailMessage("mailbox", "email", null, null, "inbox", "Planning from email",
+ new MailAddress("Sender", "sender@example.com"), [], Now, "Short snippet", body,
+ true, true, false, MailImportance.Normal, [], null);
+ await vm.OpenFromEmailAsync(mail, Accounts()[0].AccountId);
+ Assert.True(vm.IsEditorOpen);
+ Assert.False(vm.IsEditing);
+ Assert.Equal(mail.Subject, vm.EditorSubject);
+ Assert.Contains("Full content & details", vm.EditorDescription);
+ Assert.Contains("Second paragraph", vm.EditorDescription);
+ Assert.DoesNotContain("color:red", vm.EditorDescription);
+ Assert.DoesNotContain("Short snippet", vm.EditorDescription);
+ Assert.Equal("", vm.EditorAttendees);
+ var draft = vm.BuildDraft("work");
+ Assert.Equal(body, draft.Body);
+ Assert.True(draft.BodyIsHtml);
+ Assert.Null(provider.CreatedDraft);
+ vm.EditorDescription = "Edited details";
+ draft = vm.BuildDraft("work");
+ Assert.Equal("Edited details", draft.Body);
+ Assert.False(draft.BodyIsHtml);
+ vm.EditorDescription = "";
+ Assert.Equal("", vm.BuildDraft("work").Body);
+ await Assert.ThrowsAsync(() => vm.OpenFromEmailAsync(mail with { Body = null }, null));
+ }
+
[Fact]
public async Task UnchangedAccountsPreserveCalendarChoices()
{
@@ -65,6 +97,7 @@ public async Task NavigatesModesAndBuildsFullEditableEventPayload()
await WaitUntilAsync(() => viewModel.IsEditorOpen);
viewModel.EditorSubject = "Planning";
viewModel.EditorLocation = "Boardroom";
+ viewModel.EditorDescription = "Discussion notes";
viewModel.EditorAttendees = "ada@example.com; grace@example.com";
viewModel.EditorStartDate = Now.LocalDateTime.Date;
viewModel.EditorEndDate = Now.LocalDateTime.Date;
@@ -98,10 +131,13 @@ public async Task NavigatesModesAndBuildsFullEditableEventPayload()
.Single(item => item.Source.Event.ProviderId == "created");
viewModel.EditEventCommand.Execute(created);
await WaitUntilAsync(() => viewModel.IsEditorOpen && viewModel.IsEditing);
+ Assert.Equal("Discussion notes", viewModel.EditorDescription);
viewModel.EditorSubject = "Updated planning";
+ viewModel.EditorDescription = "Updated notes";
viewModel.SaveEventCommand.Execute(null);
await WaitUntilAsync(() => provider.UpdatedDraft is not null && !viewModel.IsEditorOpen);
Assert.Equal("Updated planning", provider.UpdatedDraft!.Subject);
+ Assert.Equal("Updated notes", provider.UpdatedDraft.Body);
viewModel.EditEventCommand.Execute(created);
await WaitUntilAsync(() => viewModel.IsEditorOpen);
@@ -265,7 +301,7 @@ public Task CreateEventAsync(
var created = new CalendarEvent(
"created", draft.CalendarId, draft.Subject, draft.StartsAt, draft.EndsAt,
draft.Location, draft.Attendees, draft.IsReminderOn,
- draft.ReminderMinutesBeforeStart, draft.Recurrence, account.AccountId);
+ draft.ReminderMinutesBeforeStart, draft.Recurrence, account.AccountId, Body: draft.Body, BodyIsHtml: draft.BodyIsHtml);
_events.Add(created);
return Task.FromResult(created);
}
diff --git a/tests/BetterMail.Tests/MainWindowViewModelTests.cs b/tests/BetterMail.Tests/MainWindowViewModelTests.cs
index 1043cae..40905a5 100644
--- a/tests/BetterMail.Tests/MainWindowViewModelTests.cs
+++ b/tests/BetterMail.Tests/MainWindowViewModelTests.cs
@@ -175,8 +175,19 @@ public async Task MovePickerResolvesCurrentCachedMessageAfterSync(bool alreadyIn
var snapshot = vm.MoveSelectionSnapshot();
var fresh = stale with { FolderId = alreadyInDestination ? "archive" : "other", Subject = "Updated subject", Body = "Updated body", IsFlagged = true, IsRead = false };
await store.ApplySyncPageAsync("fresh", new([fresh], null, false), token);
- if (standalone) await vm.HandlePreviewActionAsync(new(ConversationAction.Move, snapshot[0], destination));
- else await vm.MoveSnapshotToFolderAsync(snapshot, destination);
+ var accepted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var moving = standalone
+ ? vm.HandlePreviewActionAsync(new(ConversationAction.Move, snapshot[0], destination, () => accepted.SetResult()))
+ : vm.MoveSnapshotToFolderAsync(snapshot, destination, () => accepted.SetResult());
+ if (!alreadyInDestination)
+ {
+ await accepted.Task.WaitAsync(token);
+ Assert.False(provider.MoveRelease.Task.IsCompleted);
+ Assert.NotEmpty(await store.GetMailActionsAsync(token));
+ Assert.NotEmpty(vm.BusyActions);
+ }
+ await moving;
+ Assert.Equal(!alreadyInDestination, accepted.Task.IsCompleted);
var actions = await store.GetMailActionsAsync(token);
if (alreadyInDestination) Assert.Empty(actions);
else
@@ -462,8 +473,8 @@ public void ChoosesResponsiveShellLayout(double width, int expected) =>
Assert.Equal((ResponsiveLayoutMode)expected, MainWindow.LayoutModeFor(width));
[Theory]
- [InlineData(839, false)]
- [InlineData(840, true)]
+ [InlineData(999, false)]
+ [InlineData(1000, true)]
public void KeepsInlineMailActionsOnlyWhenTheyFit(double width, bool expected) =>
Assert.Equal(expected, MainWindow.UsesInlineMailActions(width));
diff --git a/tests/BetterMail.Tests/MainWindowXamlTests.cs b/tests/BetterMail.Tests/MainWindowXamlTests.cs
index 785c6c2..4f29eff 100644
--- a/tests/BetterMail.Tests/MainWindowXamlTests.cs
+++ b/tests/BetterMail.Tests/MainWindowXamlTests.cs
@@ -27,13 +27,13 @@ public void BusyCancelBindingCanBeInstantiated()
}
[Fact]
- public void BetterMailWindowsAreIndependentExceptTheMoveDestinationPicker()
+ public void BetterMailWindowsAreIndependentExceptFolderPickers()
{
var appDirectory = Path.Combine(FindRepositoryRoot(), "src", "BetterMail.App");
var windows = string.Join('\n', Directory.EnumerateFiles(appDirectory)
.Where(path => Path.GetExtension(path) is ".cs" or ".axaml")
- // The destination picker is deliberately modal; editors and workspace windows stay independent.
- .Where(path => Path.GetFileName(path) != "MailMoveWindow.axaml.cs")
+ // Folder pickers are modal within their owning window; editors and workspace windows stay independent.
+ .Where(path => Path.GetFileName(path) is not ("MailMoveWindow.axaml.cs" or "SearchOptionsWindow.axaml.cs"))
.Select(File.ReadAllText));
Assert.DoesNotContain("ShowDialog", windows);
@@ -70,9 +70,8 @@ public void SettingsIncludesDiscoverableBrandedAboutInformation()
Assert.Contains("ItemsSource=" + (char)34 + "{Binding SettingsTabs}" + (char)34, settings);
Assert.Contains("ItemsSource=" + (char)34 + "{Binding SignatureTemplates}" + (char)34, settings);
Assert.Contains("SelectedItem=" + (char)34 + "{Binding SelectedSignatureTemplate}" + (char)34, settings);
- Assert.Contains("Source=" + (char)34 + "{Binding SelectedSignatureTemplatePreviewUri}" + (char)34, settings);
- Assert.Contains("Selected signature template preview", settings);
- Assert.Contains("Details beyond the preview.
", 400));
+ var message = new MailMessage(mailbox.Id, "message", "thread", null, "inbox", "Subject",
+ new("Sender", "sender@example.test"), [], DateTimeOffset.UtcNow, "Short preview", body, true, true, false, MailImportance.Normal, [], null);
+ var reply = message with { ProviderId = "reply", ReceivedAt = message.ReceivedAt.AddMinutes(1) };
+ await store.ApplySyncPageAsync("seed", new([message, reply], null, false), token);
+ var gate = Field(store, "_gate");
+ var folders = Field(store, "_folderReadGate");
+ var writer = Field(store, "_connection");
+ var vm = new MainWindowViewModel(store, directory, _ => { }, _ => { }, null);
+ vm.Accounts.Add(account); vm.Mailboxes.Add(mailbox);
+ await gate.WaitAsync(token);
+ await folders.WaitAsync(token);
+ try
+ {
+ await using var transaction = writer.BeginTransaction();
+ await using var command = writer.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = "UPDATE messages SET subject='Not committed yet';";
+ await command.ExecuteNonQueryAsync(token);
+ var clock = Stopwatch.StartNew();
+ var cached = await store.GetMessageAsync(mailbox.Id, message.ProviderId, token).WaitAsync(TimeSpan.FromSeconds(5), token);
+ Assert.Equal(body, cached!.Body);
+ Assert.Equal("Subject", cached.Subject);
+ var thread = await store.GetThreadMessagesAsync(ConversationThread.ThreadIdentity(message), token).WaitAsync(TimeSpan.FromSeconds(5), token);
+ Assert.Equal(2, thread.Count);
+ Assert.All(thread, item => Assert.Null(item.Body));
+ vm.Messages.Add(message with { Body = null });
+ vm.SelectedMessage = vm.Messages[0];
+ async Task WaitForBodyAsync()
+ {
+ while (vm.ConversationThread.SelectedMessage?.Message.Body != body ||
+ vm.ConversationThread.SelectedMessage?.BodyHtml.Contains("Full cached message", StringComparison.Ordinal) != true)
+ await Task.Delay(10, token);
+ }
+ await WaitForBodyAsync().WaitAsync(TimeSpan.FromSeconds(5), token);
+ Assert.Equal(body, vm.SelectedMessage!.Body);
+ TestContext.Current.TestOutputHelper!.WriteLine($"Full body, thread and reading pane while writer/folder gates held: {clock.ElapsedMilliseconds} ms");
+ await transaction.CommitAsync(token);
+ }
+ finally
+ {
+ vm.SelectedMessage = null;
+ folders.Release(); gate.Release();
+ }
+ Assert.Equal("Not committed yet", (await store.GetMessageAsync(mailbox.Id, message.ProviderId, token))!.Subject);
+ var readerGate = Field(store, "_messageReadGate");
+ await readerGate.WaitAsync(token);
+ try
+ {
+ using var cancelled = CancellationTokenSource.CreateLinkedTokenSource(token);
+ var obsolete = store.GetMessageAsync(mailbox.Id, message.ProviderId, cancelled.Token);
+ cancelled.Cancel();
+ await Assert.ThrowsAnyAsync(() => obsolete);
+ }
+ finally { readerGate.Release(); }
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
+ private static T Field(EncryptedMailStore store, string name) =>
+ (T)typeof(EncryptedMailStore).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(store)!;
+}
diff --git a/tests/BetterMail.Tests/Microsoft365WorkspaceProviderTests.cs b/tests/BetterMail.Tests/Microsoft365WorkspaceProviderTests.cs
index a99d809..465723a 100644
--- a/tests/BetterMail.Tests/Microsoft365WorkspaceProviderTests.cs
+++ b/tests/BetterMail.Tests/Microsoft365WorkspaceProviderTests.cs
@@ -15,6 +15,20 @@ public sealed class Microsoft365WorkspaceProviderTests
"User",
ProviderCapabilities.Contacts | ProviderCapabilities.Tasks);
+ [Fact]
+ public void CalendarBodyPayloadPreservesFormatAndDistinguishesOmittedFromCleared()
+ {
+ var draft = new CalendarEventDraft("calendar", "Subject", DateTimeOffset.Now, DateTimeOffset.Now.AddHours(1));
+ var omitted = JsonSerializer.SerializeToElement(Microsoft365WorkspaceProvider.BuildEventPayload(draft));
+ Assert.False(omitted.TryGetProperty("body", out _));
+ var html = JsonSerializer.SerializeToElement(Microsoft365WorkspaceProvider.BuildEventPayload(draft with { Body = "Details
", BodyIsHtml = true }));
+ Assert.Equal("html", html.GetProperty("body").GetProperty("contentType").GetString());
+ Assert.Equal("Details
", html.GetProperty("body").GetProperty("content").GetString());
+ var cleared = JsonSerializer.SerializeToElement(Microsoft365WorkspaceProvider.BuildEventPayload(draft with { Body = "" }));
+ Assert.Equal("text", cleared.GetProperty("body").GetProperty("contentType").GetString());
+ Assert.Equal("", cleared.GetProperty("body").GetProperty("content").GetString());
+ }
+
[Fact]
public void BuildsCalendarSelectedMeetingPayload()
{
diff --git a/tests/BetterMail.Tests/PreviewActionTests.cs b/tests/BetterMail.Tests/PreviewActionTests.cs
new file mode 100644
index 0000000..12c1513
--- /dev/null
+++ b/tests/BetterMail.Tests/PreviewActionTests.cs
@@ -0,0 +1,73 @@
+using BetterMail.App;
+using BetterMail.Core;
+
+namespace BetterMail.Tests;
+
+public sealed class PreviewActionTests
+{
+ [Fact]
+ public void DefaultReplyIsAllAndCanBeSetToSender()
+ {
+ var vm = new MainWindowViewModel(null, Path.GetTempPath(), _ => { }, _ => { }, null);
+ Assert.True(new AppPreferences().DefaultReplyAll);
+ Assert.Same(vm.ReplyAllCommand, vm.DefaultReplyCommand);
+ Assert.Same(vm.ConversationThread.ReplyAllCommand, vm.ConversationThread.DefaultReplyCommand);
+ vm.DefaultReplyAll = false;
+ Assert.Same(vm.ReplyCommand, vm.DefaultReplyCommand);
+ Assert.Same(vm.ConversationThread.ReplyCommand, vm.ConversationThread.DefaultReplyCommand);
+ Assert.Equal("Reply", vm.DefaultReplyLabel);
+ }
+
+ [Fact]
+ public void WindowActionPreferencesPersistIndependentlyAndDefaultToStayOpen()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-preview-settings-" + Guid.NewGuid());
+ try
+ {
+ var vm = new MainWindowViewModel(null, directory, _ => { }, _ => { }, null);
+ Assert.All(vm.PreviewActionSettings, setting => Assert.False(vm.ShouldClosePreview(setting.Action)));
+ vm.ConfigurePreviewActions(["Delete", "Reply", "unrecognized"]);
+ AppPreferencesStore.Save(directory, new(ClosePreviewAfterActions: vm.GetClosePreviewActions()));
+ var restored = new MainWindowViewModel(null, directory, _ => { }, _ => { }, null);
+ restored.ConfigurePreviewActions(AppPreferencesStore.Load(directory).ClosePreviewAfterActions);
+ Assert.True(restored.ShouldClosePreview(ConversationAction.Delete));
+ Assert.True(restored.ShouldClosePreview(ConversationAction.Reply));
+ Assert.False(restored.ShouldClosePreview(ConversationAction.Forward));
+ Assert.False(restored.ShouldClosePreview(ConversationAction.ViewHeaders));
+ restored.PreviewActionSettings.Single(item => item.Action == ConversationAction.Delete).Behavior = "Stay open";
+ Assert.False(restored.ShouldClosePreview(ConversationAction.Delete));
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
+ [Theory]
+ [InlineData(ConversationAction.Reply)]
+ [InlineData(ConversationAction.ReplyAll)]
+ [InlineData(ConversationAction.Forward)]
+ public async Task ComposeAcceptanceFollowsComposerOpeningAndRejectsMissingAccounts(ConversationAction action)
+ {
+ var vm = new MainWindowViewModel(null, Path.GetTempPath(), _ => { }, _ => { }, null);
+ var message = new MailMessage("box", "id", null, null, "inbox", "Subject", new MailAddress("Sender", "sender@example.test"),
+ [], DateTimeOffset.Now, "Body", "Body", false, true, false, MailImportance.Normal, [], null);
+ var opened = false;
+ var accepted = false;
+ vm.ComposeRequested += _ => opened = true;
+ var request = new ConversationActionRequest(action, message, Accepted: () => { Assert.True(opened); accepted = true; });
+ await vm.HandlePreviewActionAsync(request);
+ Assert.False(accepted);
+ vm.Accounts.Add(new("microsoft365", "account", "tenant", "me@example.test", "Me", ProviderCapabilities.Mail));
+ await vm.HandlePreviewActionAsync(request);
+ Assert.True(accepted);
+ }
+
+ [Fact]
+ public async Task UnavailableMoveDoesNotClosePreview()
+ {
+ var vm = new MainWindowViewModel(null, Path.GetTempPath(), _ => { }, _ => { }, null);
+ var message = new MailMessage("box", "id", null, null, "inbox", "Subject", new MailAddress("Sender", "sender@example.test"),
+ [], DateTimeOffset.Now, "Body", "Body", false, true, false, MailImportance.Normal, [], null);
+ var accepted = false;
+ await vm.HandlePreviewActionAsync(new(ConversationAction.Delete, message, Accepted: () => accepted = true));
+ Assert.False(accepted);
+ }
+}
diff --git a/tests/BetterMail.Tests/TasksWorkspaceViewModelTests.cs b/tests/BetterMail.Tests/TasksWorkspaceViewModelTests.cs
index c70bbd8..e1c48a4 100644
--- a/tests/BetterMail.Tests/TasksWorkspaceViewModelTests.cs
+++ b/tests/BetterMail.Tests/TasksWorkspaceViewModelTests.cs
@@ -20,6 +20,55 @@ public sealed class TasksWorkspaceViewModelTests
DisplayName = "Account B"
};
+ [Fact]
+ public async Task SyncedCacheUpdatesTasksWithoutResettingNavigationOrEditor()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-task-cache-" + Guid.NewGuid());
+ var token = TestContext.Current.CancellationToken;
+ try
+ {
+ await using var store = new EncryptedMailStore(Path.Combine(directory, "mail.db"), new string('D', 64));
+ await store.InitializeAsync(token);
+ await store.SaveAccountAsync(AccountA, token);
+ var vm = new TasksWorkspaceViewModel(new FakeTasksProvider(), [AccountA], store);
+ await vm.UpdateAccountsAsync([AccountA], token);
+ var group = Assert.Single(vm.AccountGroups);
+ var list = Assert.Single(group.Lists);
+ await vm.SelectListAsync(list);
+ var original = Assert.Single(vm.VisibleTasks);
+ vm.SelectedTask = original;
+ await vm.OpenEditTaskAsync(original);
+ vm.EditorTitle = "Unsaved local title";
+ var remote = original.Info with { Title = "Changed on another client", IsComplete = true };
+ var added = remote with { ProviderId = "new-task", Title = "Added remotely" };
+ await store.ReplaceWorkspaceItemsAsync("task", AccountA.AccountId, list.Info.ProviderId,
+ new[] { remote, added }, task => task.ProviderId, task => task.Title, token);
+
+ await vm.ReloadFromCacheAsync(token);
+
+ Assert.Same(group, Assert.Single(vm.AccountGroups));
+ Assert.Same(list, vm.SelectedList);
+ Assert.Equal(2, vm.VisibleTasks.Count);
+ Assert.Equal(remote, vm.SelectedTask!.Info);
+ Assert.True(vm.IsEditorOpen);
+ Assert.Equal("Unsaved local title", vm.EditorTitle);
+ Assert.False(vm.IsLoading);
+ var selected = vm.SelectedTask;
+ await vm.ReloadFromCacheAsync(token);
+ Assert.Same(selected, vm.SelectedTask);
+
+ await ((AsyncCommand)vm.CloseEditorCommand).ExecuteAsync();
+ await store.ReplaceWorkspaceItemsAsync("task", AccountA.AccountId, list.Info.ProviderId,
+ new[] { added }, task => task.ProviderId, task => task.Title, token);
+ // Reopening with unchanged accounts must consume the updated cache too.
+ await vm.UpdateAccountsAsync([AccountA], token);
+ Assert.Equal(added, Assert.Single(vm.VisibleTasks).Info);
+ Assert.Null(vm.SelectedTask);
+ Assert.Same(list, vm.SelectedList);
+ }
+ finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
+ }
+
[Fact]
public async Task UnchangedAccountsRetainLoadedNavigation()
{
@@ -94,7 +143,8 @@ public async Task CreatesEditsCompletesAndDeletesOwnedTask()
await viewModel.OpenNewTaskAsync();
viewModel.EditorTitle = " File report ";
viewModel.EditorHasDueDate = true;
- viewModel.EditorDueDate = new DateTimeOffset(2026, 7, 20, 0, 0, 0, TimeSpan.Zero);
+ viewModel.EditorCalendarDate = new DateTime(2026, 7, 20);
+ Assert.Equal(new DateTime(2026, 7, 20), viewModel.EditorCalendarDate);
viewModel.EditorDueTime = new TimeSpan(14, 30, 0);
await viewModel.SaveEditorAsync();
@@ -105,6 +155,7 @@ public async Task CreatesEditsCompletesAndDeletesOwnedTask()
Assert.Equal(AccountA.AccountId, created.Info.AccountId);
await viewModel.OpenEditTaskAsync(created);
+ Assert.Equal(created.Info.DueAt!.Value.LocalDateTime.Date, viewModel.EditorCalendarDate);
viewModel.EditorTitle = "Updated report";
viewModel.EditorHasDueDate = false;
await viewModel.SaveEditorAsync();
diff --git a/tools/BetterMail.UiPreview/Program.Search.cs b/tools/BetterMail.UiPreview/Program.Search.cs
new file mode 100644
index 0000000..61a1ac6
--- /dev/null
+++ b/tools/BetterMail.UiPreview/Program.Search.cs
@@ -0,0 +1,146 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Styling;
+using Avalonia.VisualTree;
+using BetterMail.App;
+using BetterMail.Core;
+using System.Diagnostics;
+using System.Reflection;
+
+internal static partial class Program
+{
+ private static async Task CaptureSearchAsync(string output)
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "bettermail-search-ui-" + Guid.NewGuid());
+ var vm = new MainWindowViewModel(null, directory, _ => { }, _ => { }, null,
+ workspaceProvider: DispatchProxy.Create());
+ var account = PreviewProvider.Account;
+ var mailbox = new Mailbox(account.AccountId, account.EmailAddress, "Alex Morgan");
+ vm.Accounts.Add(account); vm.Mailboxes.Add(mailbox);
+ await vm.InitializeAsync();
+ vm.Folders.Add(new(new(mailbox.Id, "inbox", "Inbox", 0, 0, "inbox"), mailbox.DisplayName));
+ vm.Folders.Add(new(new(mailbox.Id, "projects", "Projects", 0, 0, ParentProviderId: "inbox"), mailbox.DisplayName));
+ vm.Folders.Add(new(new(mailbox.Id, "old", "Old", 0, 0, ParentProviderId: "projects"), mailbox.DisplayName));
+ var message = new MailMessage(mailbox.Id, "sample", null, null, "projects", "Quarterly budget review",
+ new("Jamie Chen", "jamie@example.test"), [new("Alex Morgan", account.EmailAddress)], DateTimeOffset.Now,
+ "The updated numbers are ready for review.", "The updated numbers are ready for review.", false, false, true, MailImportance.Normal, ["Finance", "Projects"], null);
+ vm.Messages.Add(message);
+ var window = new MainWindow { DataContext = vm, Width = 1440, Height = 960, WindowDecorations = WindowDecorations.None,
+ WindowStartupLocation = WindowStartupLocation.Manual, Position = new(0, 0) };
+ window.Show(); await Task.Delay(300);
+ vm.SearchText = "budget type:mail type:Mails has:attachments";
+ vm.IsSearchEditing = false;
+ await Shot("search-badges-light", window);
+ if (vm.SearchBadges.Count(item => !item.IsValid) != 1) throw new InvalidOperationException("Invalid type was not identified independently.");
+ var badgeButton = window.GetVisualDescendants().OfType().Single(button => Avalonia.Automation.AutomationProperties.GetName(button) == "Edit search filters");
+ badgeButton.Focus(); await Task.Delay(300);
+ var input = window.FindControl("MailSearch")!;
+ if (!input.IsFocused || !input.IsVisible || input.Text != vm.SearchText) throw new InvalidOperationException("Badges did not restore exact editable text on focus.");
+ window.FindControl("RailSettings")!.Focus(); await Task.Delay(200);
+ if (!vm.ShowSearchBadges) throw new InvalidOperationException("Unfocused search did not show badges.");
+ Application.Current!.RequestedThemeVariant = ThemeVariant.Dark;
+ await Shot("search-badges-dark", window);
+ Application.Current!.RequestedThemeVariant = ThemeVariant.Light;
+ vm.SearchText = "budget type:mail";
+ vm.GlobalSearchResults.Add(new("Mail", message.Subject, message.Preview, "Mail", message));
+ vm.IsGlobalSearchOpen = true;
+ typeof(MainWindowViewModel).GetProperty(nameof(vm.IsGlobalSearchRunning))!.SetValue(vm, true);
+ await Task.Delay(300);
+ var panel = window.FindControl("GlobalSearchResultsPanel")!;
+ var progress = panel.GetVisualDescendants().OfType().Single();
+ if (Math.Abs(progress.Bounds.Width - ((Control)progress.Parent!).Bounds.Width) > 1 || progress.VerticalAlignment != Avalonia.Layout.VerticalAlignment.Top)
+ throw new InvalidOperationException("Search progress does not span the results table.");
+ if (panel.GetVisualDescendants().OfType().Any(button => button.Content as string == "×")) throw new InvalidOperationException("Search popup still has a close button.");
+ await Shot("search-results-light", window);
+ Application.Current!.RequestedThemeVariant = ThemeVariant.Dark;
+ await Shot("search-results-dark", window);
+ await Input("none", "click", "35", "600");
+ if (vm.IsGlobalSearchOpen) throw new InvalidOperationException("Search popup did not close on outside click.");
+ typeof(MainWindowViewModel).GetProperty(nameof(vm.IsGlobalSearchRunning))!.SetValue(vm, false);
+ Application.Current!.RequestedThemeVariant = ThemeVariant.Light;
+ var query = "budget type:mail account:" + SearchQuery.Encode(account.AccountId) + " in:{Inbox/Projects} notin:{Inbox/Projects/Old} category:Finance date:{>=2026-09-01}";
+ string? applied = null;
+ var options = new SearchOptionsWindow(query, result => applied = result, vm) { Position = new(0, 0), WindowDecorations = WindowDecorations.None,
+ WindowStartupLocation = WindowStartupLocation.Manual };
+ options.Show(); await Task.Delay(300);
+ if (options.GetVisualDescendants().OfType().Any(button => button.Content as string == "Read query into fields")) throw new InvalidOperationException("Obsolete query import button remains.");
+ var choices = options.FindControl("FieldsPanel")!.Children.OfType().ToArray();
+ var typeControl = choices[0];
+ var typeButton = typeControl.Children.OfType().Single();
+ var typeFlyout = (Flyout)typeButton.Flyout!;
+ typeFlyout.ShowAt(typeButton); await Task.Delay(200);
+ await Shot("search-type-multiselect-light", options);
+ var checks = ((Control)typeFlyout.Content!).GetVisualDescendants().OfType().ToArray();
+ checks.Single(box => box.Content as string == "Mail").IsChecked = false;
+ checks.Single(box => box.Content as string == "Drive").IsChecked = true;
+ typeFlyout.Hide(); await Task.Delay(100);
+ var generated = SearchQuery.Parse(options.FindControl("QueryText")!.Text!);
+ if (generated.Scope != "Drive" || generated.HasMailFilters || generated.Values("category").Count > 0) throw new InvalidOperationException("Hidden mail-only conditions leaked into Drive search.");
+ checks.Single(box => box.Content as string == "Drive").IsChecked = false;
+ checks.Single(box => box.Content as string == "Mail").IsChecked = true;
+ await Shot("search-options-light", options);
+ Application.Current!.RequestedThemeVariant = ThemeVariant.Dark;
+ await Shot("search-options-dark", options);
+ Application.Current!.RequestedThemeVariant = ThemeVariant.Light;
+ var dateLabel = options.GetVisualDescendants().OfType().First(block => block.Text == "Received date / time");
+ var dateScroll = dateLabel.GetVisualAncestors().OfType().First();
+ dateScroll.Offset = new Vector(0, dateScroll.Offset.Y + dateLabel.TranslatePoint(new Point(), dateScroll)!.Value.Y - 12);
+ await Shot("search-date-filters-light", options);
+ Application.Current!.RequestedThemeVariant = ThemeVariant.Dark;
+ await Shot("search-date-filters-dark", options);
+ var syntax = options.GetVisualDescendants().OfType().Single(); syntax.IsExpanded = true;
+ options.FindControl("SyntaxHelp")!.BringIntoView();
+ await Shot("search-syntax-dark", options);
+ options.FindControl("ApplyButton")!.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
+ if (applied is null || SearchQuery.Parse(applied).Values("notin").Count != 1) throw new InvalidOperationException("Advanced form did not apply folder exclusions.");
+ var folders = new SearchFoldersWindow(vm, false, [account.AccountId], [], true) { Position = new(0, 0), WindowDecorations = WindowDecorations.None,
+ WindowStartupLocation = WindowStartupLocation.Manual };
+ folders.Show(); await Task.Delay(300);
+ foreach (var node in folders.GetVisualDescendants().OfType().ToArray()) node.IsExpanded = true;
+ await Task.Delay(100);
+ foreach (var node in folders.GetVisualDescendants().OfType().ToArray()) node.IsExpanded = true;
+ await Shot("search-folder-exclusions-dark", folders); folders.Close();
+ var driveFolders = new SearchFoldersWindow(vm, true, [account.AccountId], [], false) { Position = new(0, 0), WindowDecorations = WindowDecorations.None,
+ WindowStartupLocation = WindowStartupLocation.Manual };
+ driveFolders.Show(); await Task.Delay(100);
+ var driveTree = driveFolders.GetVisualDescendants().OfType().Single();
+ var root = driveTree.Items.OfType().Single(); root.IsExpanded = true;
+ for (var i = 0; i < 20 && root.Items.OfType().All(item => item.Tag is null); i++) await Task.Delay(100);
+ if (!root.Items.OfType().Any(item => item.Tag as string == "Autumn launch")) throw new InvalidOperationException("Drive folder picker did not load child folders.");
+ if (root.Items.OfType().Count() != 1) throw new InvalidOperationException("Drive folder picker included files.");
+ await Shot("search-drive-folders-dark", driveFolders); driveFolders.Close();
+ var settings = new Window { DataContext = vm, Content = new SettingsView(), Width = 1100, Height = 900,
+ Position = new(0, 0), WindowDecorations = WindowDecorations.None, WindowStartupLocation = WindowStartupLocation.Manual };
+ vm.SelectedSettingsTab = vm.SettingsTabs.Single(tab => tab.Name == "Mail & notifications");
+ settings.Show(); await Task.Delay(200);
+ var close = settings.GetVisualDescendants().OfType().First(button => Avalonia.Automation.AutomationProperties.GetName(button) == "Close settings");
+ var title = settings.GetVisualDescendants().OfType().First(block => block.Text == "Settings");
+ if (close.Bounds.Right > title.Bounds.Left) throw new InvalidOperationException("Settings close button is not left of its title.");
+ var heading = settings.GetVisualDescendants().OfType().First(block => block.Text == "Separate mail windows");
+ var scroll = heading.GetVisualAncestors().OfType().First();
+ scroll.Offset = new Vector(0, scroll.Offset.Y + heading.TranslatePoint(new Point(), scroll)!.Value.Y - 12);
+ await Shot("preview-action-settings-dark", settings);
+ Application.Current!.RequestedThemeVariant = ThemeVariant.Light;
+ await Shot("preview-action-settings-light", settings);
+ settings.Close(); window.Close();
+ Console.WriteLine("Search badges, focus, light dismissal, full-width progress, grouped multi-select form, exclusions, and left Settings close passed.");
+
+ async Task Shot(string name, Window target)
+ {
+ target.Position = new PixelPoint(0, 0);
+ await Task.Delay(1000);
+ using var process = Process.Start(new ProcessStartInfo("python3") { ArgumentList = { "tools/BetterMail.UiPreview/capture.py", Path.Combine(output, name + ".png"), ((int)target.Width).ToString(), ((int)target.Height).ToString() } })!;
+ await process.WaitForExitAsync(); if (process.ExitCode != 0) throw new InvalidOperationException("Screenshot failed.");
+ Console.WriteLine(name);
+ }
+ async Task Input(params string[] parts)
+ {
+ var info = new ProcessStartInfo("python3"); info.ArgumentList.Add("tools/BetterMail.UiPreview/input.py");
+ foreach (var part in parts) info.ArgumentList.Add(part);
+ using var process = Process.Start(info)!; await process.WaitForExitAsync();
+ if (process.ExitCode != 0) throw new InvalidOperationException("Input failed.");
+ await Task.Delay(200);
+ }
+ }
+}
diff --git a/tools/BetterMail.UiPreview/Program.Workspaces.cs b/tools/BetterMail.UiPreview/Program.Workspaces.cs
new file mode 100644
index 0000000..3aea851
--- /dev/null
+++ b/tools/BetterMail.UiPreview/Program.Workspaces.cs
@@ -0,0 +1,108 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Styling;
+using Avalonia.VisualTree;
+using BetterMail.App;
+using BetterMail.Core;
+using System.Diagnostics;
+using System.Reflection;
+
+internal static partial class Program
+{
+ private static async Task CaptureWorkspaceFixesAsync(string output)
+ {
+ var vm = new MainWindowViewModel(null, Path.Combine(Path.GetTempPath(), "bettermail-workspaces-" + Guid.NewGuid()), _ => { }, _ => { }, null,
+ workspaceProvider: DispatchProxy.Create());
+ vm.Accounts.Add(PreviewProvider.Account);
+ // Exercise initial contacts with no mailbox tree loaded yet.
+ await vm.InitializeAsync();
+ var window = new MainWindow { DataContext = vm, Width = 1280, Height = 900, WindowDecorations = WindowDecorations.None,
+ WindowStartupLocation = WindowStartupLocation.Manual, Position = new(0, 0) };
+ window.Show();
+ await ((AsyncCommand)vm.ShowContactsCommand).ExecuteAsync();
+ await (Task)typeof(MainWindowViewModel).GetProperty("PeopleBackgroundRefresh", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(vm)!;
+ if (vm.People.Count == 0) throw new InvalidOperationException("First People load requires a manual refresh.");
+ vm.People.Add(PersonEntry.Discovered(new("taylor@studio.example", "Taylor Brooks", [], 3, DateTimeOffset.Now), "Mail history"));
+ foreach (var dark in new[] { false, true })
+ {
+ Application.Current!.RequestedThemeVariant = dark ? ThemeVariant.Dark : ThemeVariant.Light;
+ vm.PeopleCardView = false; await Shot("people-table-" + (dark ? "dark" : "light"));
+ vm.PeopleCardView = true; await Shot("people-cards-" + (dark ? "dark" : "light"));
+ }
+ if (window.GetVisualDescendants().OfType().Any(button => button.IsEffectivelyVisible && Equals(button.Content, "Refresh")))
+ throw new InvalidOperationException("People still has a Refresh button.");
+ var eventMail = new MailMessage("preview", "event-email", null, null, "inbox", "Design review",
+ new MailAddress("Jamie", "jamie@studio.example"), [], DateTimeOffset.Now, "Short preview",
+ "Hi Alex,
Let’s review the updated designs together.
Agenda: navigation, contacts, and calendar.
",
+ true, true, false, MailImportance.Normal, [], null);
+ await (Task)typeof(MainWindowViewModel).GetMethod("CreateEventFromEmailAsync", BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(vm, [eventMail, null])!;
+ if (vm.CalendarWorkspace?.IsEditorOpen != true || !vm.CalendarWorkspace.EditorDescription.Contains("Agenda:"))
+ throw new InvalidOperationException("Email did not open an event with its full content.");
+ await Shot("event-from-email-dark");
+ await ((AsyncCommand)vm.CalendarWorkspace.CloseEditorCommand).ExecuteAsync();
+ await ((AsyncCommand)vm.ShowTasksCommand).ExecuteAsync();
+ await Shot("tasks-dark");
+ var tasksView = window.GetVisualDescendants().OfType().Single();
+ var taskList = tasksView.FindControl("TaskList")!;
+ var row = taskList.GetVisualDescendants().OfType().First();
+ var point = row.PointToScreen(new Point(120, 15));
+ var input = new ProcessStartInfo("python3") { UseShellExecute = false };
+ foreach (var arg in new[] { Path.GetFullPath("tools/BetterMail.UiPreview/input.py"), "none", "doubleclick", point.X.ToString(), point.Y.ToString() }) input.ArgumentList.Add(arg);
+ using (var process = Process.Start(input)!) await process.WaitForExitAsync();
+ await Task.Delay(200);
+ var tasks = (TasksWorkspaceViewModel)tasksView.DataContext!;
+ if (!tasks.IsEditorOpen) throw new InvalidOperationException("Task double-click did not open the editor.");
+ var picker = tasksView.GetVisualDescendants().OfType().Single();
+ if (picker.SelectedDate != tasks.EditorDueDate?.LocalDateTime.Date) throw new InvalidOperationException("Task date binding failed.");
+ picker.SelectedDate = new DateTime(2026, 10, 4);
+ if (tasks.EditorCalendarDate != picker.SelectedDate) throw new InvalidOperationException("Task date edit did not update the model.");
+ await Shot("task-editor-dark");
+ await ((AsyncCommand)tasks.CloseEditorCommand).ExecuteAsync();
+ await ((AsyncCommand)vm.ShowNotesCommand).ExecuteAsync();
+ await Task.Delay(200);
+ var notes = (NotesWorkspaceViewModel)vm.ActiveWorkspace!;
+ var tree = window.GetVisualDescendants().OfType().Single().FindControl("NotesTree")!;
+ foreach (var kind in new[] { NoteNodeKind.Account, NoteNodeKind.Notebook, NoteNodeKind.Section })
+ {
+ var node = tree.GetVisualDescendants().OfType().First(item => item.DataContext is NoteTreeNode n && n.Kind == kind);
+ node.IsExpanded = true;
+ await Task.Delay(200);
+ if (((NoteTreeNode)node.DataContext!).Children.Any(child => child.Kind == NoteNodeKind.Placeholder))
+ throw new InvalidOperationException("Expanded Notes node retained its placeholder.");
+ }
+ await Shot("notes-expanded-dark");
+ var notebook = notes.AccountRoots[0].AllChildren[0];
+ notebook.Error = "This library exceeds Microsoft's item limit (10008). Cached notes are kept. Open it in OneNote or move it to a smaller library.";
+ typeof(NotesWorkspaceViewModel).GetMethod("RaisePartialErrors", BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(notes, null);
+ await Shot("notes-limit-dark");
+ typeof(MainWindowViewModel).GetProperty("ActiveModule")!.SetValue(vm, "Mail");
+ typeof(MainWindowViewModel).GetProperty("NextCalendarEvent")!.SetValue(vm,
+ new CalendarEventSource(PreviewProvider.Account, new CalendarChoice(new("team", "Team", "#5576CF", true, PreviewProvider.Account.AccountId), "#5576CF", () => { }),
+ new("upcoming", "team", "Review the autumn launch plans", DateTimeOffset.Now.AddMinutes(15), DateTimeOffset.Now.AddMinutes(45), "Studio", AccountId: PreviewProvider.Account.AccountId)));
+ window.Width = 700;
+ await Shot("mail-header-narrow-dark");
+ window.Close();
+ var settings = new Window { DataContext = vm, Content = new SettingsView(), Width = 1100, Height = 900, Position = new(0, 0), WindowDecorations = WindowDecorations.None };
+ vm.SelectedSettingsTab = vm.SettingsTabs.Single(tab => tab.Name == "Signatures");
+ settings.Show();
+ if (settings.GetVisualDescendants().OfType().Any()) throw new InvalidOperationException("Native signature surface remains in the settings scroll area.");
+ await Shot("signature-settings-dark", settings);
+ vm.SelectedSettingsTab = vm.SettingsTabs.Single(tab => tab.Name == "Mail & notifications");
+ await Shot("default-reply-settings-dark", settings);
+ settings.Close();
+ Console.WriteLine("Initial contacts, task double-click/date binding, and real Notes tree expansion passed.");
+
+ async Task Shot(string name, Window? target = null)
+ {
+ target ??= window;
+ target.Position = new(0, 0); await Task.Delay(500);
+ var info = new ProcessStartInfo("python3") { UseShellExecute = false };
+ info.ArgumentList.Add(Path.GetFullPath("tools/BetterMail.UiPreview/capture.py"));
+ info.ArgumentList.Add(Path.Combine(output, name + ".png"));
+ info.ArgumentList.Add(((int)target.Width).ToString()); info.ArgumentList.Add(((int)target.Height).ToString());
+ using var process = Process.Start(info)!; await process.WaitForExitAsync();
+ if (process.ExitCode != 0) throw new InvalidOperationException("Screenshot failed.");
+ }
+ }
+}
diff --git a/tools/BetterMail.UiPreview/Program.cs b/tools/BetterMail.UiPreview/Program.cs
index 2ffd75e..aa2d40a 100644
--- a/tools/BetterMail.UiPreview/Program.cs
+++ b/tools/BetterMail.UiPreview/Program.cs
@@ -10,7 +10,7 @@
// A standalone Linux visual-review host. Uses the real views and fictional, offline data.
// Never starts the production app lifetime, account authentication, or synchronization.
-internal static class Program
+internal static partial class Program
{
[STAThread]
public static void Main(string[] args)
@@ -23,7 +23,7 @@ public static void Main(string[] args)
using var stop = new CancellationTokenSource();
Dispatcher.UIThread.Post(async () =>
{
- try { await CaptureAsync(output); }
+ try { 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(); }
});
@@ -89,6 +89,16 @@ private static async Task CaptureAsync(string output)
await Shot("mailbox-order-light", settingsWindow);
Application.Current!.RequestedThemeVariant = ThemeVariant.Dark;
await Shot("mailbox-order-dark", settingsWindow);
+ vm.SelectedSettingsTab = vm.SettingsTabs.Single(tab => tab.Name == "Mail & notifications");
+ vm.ConfigurePreviewActions(["Delete", "Archive", "Move", "Reply", "ReplyAll", "Forward"]);
+ await Task.Delay(200);
+ var previewSettings = settingsWindow.GetVisualDescendants().OfType().First(block => block.Text == "Separate mail windows");
+ var settingsScroll = previewSettings.GetVisualAncestors().OfType().First();
+ var settingsPosition = previewSettings.TranslatePoint(new Point(), settingsScroll)!.Value;
+ settingsScroll.Offset = new Vector(0, settingsScroll.Offset.Y + settingsPosition.Y - 12);
+ await Shot("preview-action-settings-dark", settingsWindow);
+ Application.Current!.RequestedThemeVariant = ThemeVariant.Light;
+ await Shot("preview-action-settings-light", settingsWindow);
settingsWindow.Close();
vm.Mailboxes.Remove(studioMailbox);
vm.Mailboxes.Remove(supportMailbox);
@@ -185,7 +195,18 @@ async Task ClickRow(int index, string modifier)
if (!openedPreview.IsVisible || openedPreview.Title != previewMessages[3].Subject)
throw new InvalidOperationException("Preview did not immediately open the clicked mail.");
await opening;
- openedPreview.Close();
+ var separateThread = (ConversationThreadViewModel)((ConversationThreadView)openedPreview.Content!).DataContext!;
+ vm.ConfigurePreviewActions([]);
+ await ((AsyncCommand)separateThread.ReplyCommand).ExecuteAsync();
+ if (!openedPreview.IsVisible) throw new InvalidOperationException("Stay open closed the preview.");
+ var composeWindows = (System.Collections.IDictionary)typeof(MainWindow).GetField("_composeWindows", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(window)!;
+ foreach (var compose in composeWindows.Values.Cast().ToArray()) compose.Close();
+ vm.ConfigurePreviewActions(["Reply"]);
+ await ((AsyncCommand)separateThread.ReplyCommand).ExecuteAsync();
+ if (openedPreview.IsVisible || composeWindows.Count == 0)
+ throw new InvalidOperationException("Close after reply did not leave the composer open and close the preview.");
+ foreach (var compose in composeWindows.Values.Cast().ToArray()) compose.Close();
+ Console.WriteLine("Separate preview honors Stay open and Close after reply, while composer remains available.");
window.Activate();
Console.WriteLine("Preview opens the clicked mail immediately, even without a cached thread.");
@@ -252,6 +273,17 @@ async Task ClickRow(int index, string modifier)
if (vm.ConversationThread.SelectedMessage != keyboardHeader.DataContext)
throw new InvalidOperationException("Focused thread header did not activate with Enter.");
Console.WriteLine("Thread headers remain keyboard accessible without an arrow control.");
+ var busyHeader = (ConversationMessageItem)keyboardHeader.DataContext!;
+ vm.BusyActions.Add(new MailAction("header-loader", account.AccountId, busyHeader.Message.MailboxId, busyHeader.Message.ProviderId,
+ MailActionKind.Move, busyHeader.Message.Subject, DateTimeOffset.Now, busyHeader.Message.ProviderId, "archive", "Archive"));
+ await Task.Delay(200);
+ var headerContainer = (Grid)keyboardHeader.Parent!;
+ var indicator = headerContainer.Children.OfType().Single();
+ if (!indicator.IsVisible || Math.Abs(indicator.Bounds.Bottom - headerContainer.Bounds.Height) > 1)
+ throw new InvalidOperationException("Pending action indicator is not at the bottom of the whole thread header.");
+ await Shot("thread-action-bottom-light");
+ vm.BusyActions.Remove(vm.BusyActions.Single(action => action.Id == "header-loader"));
+
headerImages = threadView.GetVisualDescendants().OfType().ToArray();
vm.MailSenderImagesEnabled = false;
if (threadView.ShowSenderImages || headerImages.Any(i => i.AllowLoading || i.IsVisible))
diff --git a/tools/BetterMail.UiPreview/input.py b/tools/BetterMail.UiPreview/input.py
index 78d525c..9ecb71d 100644
--- a/tools/BetterMail.UiPreview/input.py
+++ b/tools/BetterMail.UiPreview/input.py
@@ -19,10 +19,13 @@ def key(name, down):
t.XTestFakeKeyEvent(d, x.XKeysymToKeycode(d, x.XStringToKeysym(name.encode())), down, 0)
modifier = sys.argv[1]
if modifier != 'none': key(modifier, 1)
-if sys.argv[2] == 'click':
+if sys.argv[2] in ('click', 'doubleclick'):
t.XTestFakeMotionEvent(d, -1, int(sys.argv[3]), int(sys.argv[4]), 0)
t.XTestFakeButtonEvent(d, 1, 1, 0)
t.XTestFakeButtonEvent(d, 1, 0, 0)
+ if sys.argv[2] == 'doubleclick':
+ t.XTestFakeButtonEvent(d, 1, 1, 70)
+ t.XTestFakeButtonEvent(d, 1, 0, 0)
elif sys.argv[2] == 'move':
t.XTestFakeMotionEvent(d, -1, int(sys.argv[3]), int(sys.argv[4]), 0)
elif sys.argv[2] == 'drag':