diff --git a/Wino.Core.Domain/Entities/Mail/MailCategory.cs b/Wino.Core.Domain/Entities/Mail/MailCategory.cs
new file mode 100644
index 00000000..0bb92227
--- /dev/null
+++ b/Wino.Core.Domain/Entities/Mail/MailCategory.cs
@@ -0,0 +1,25 @@
+using System;
+using SQLite;
+using Wino.Core.Domain.Enums;
+
+namespace Wino.Core.Domain.Entities.Mail;
+
+public class MailCategory
+{
+ [PrimaryKey]
+ public Guid Id { get; set; }
+
+ public Guid MailAccountId { get; set; }
+
+ public string RemoteId { get; set; }
+
+ public string Name { get; set; }
+
+ public bool IsFavorite { get; set; }
+
+ public string BackgroundColorHex { get; set; }
+
+ public string TextColorHex { get; set; }
+
+ public MailCategorySource Source { get; set; } = MailCategorySource.Local;
+}
diff --git a/Wino.Core.Domain/Entities/Mail/MailCategoryAssignment.cs b/Wino.Core.Domain/Entities/Mail/MailCategoryAssignment.cs
new file mode 100644
index 00000000..dc12bd34
--- /dev/null
+++ b/Wino.Core.Domain/Entities/Mail/MailCategoryAssignment.cs
@@ -0,0 +1,14 @@
+using System;
+using SQLite;
+
+namespace Wino.Core.Domain.Entities.Mail;
+
+public class MailCategoryAssignment
+{
+ [PrimaryKey]
+ public Guid Id { get; set; }
+
+ public Guid MailCategoryId { get; set; }
+
+ public Guid MailCopyUniqueId { get; set; }
+}
diff --git a/Wino.Core.Domain/Entities/Shared/MailAccount.cs b/Wino.Core.Domain/Entities/Shared/MailAccount.cs
index f549f65e..045206ef 100644
--- a/Wino.Core.Domain/Entities/Shared/MailAccount.cs
+++ b/Wino.Core.Domain/Entities/Shared/MailAccount.cs
@@ -132,5 +132,10 @@ public class MailAccount
///
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook;
+ ///
+ /// Gets whether the account can perform category definition sync type.
+ ///
+ public bool IsCategorySyncSupported => ProviderType == MailProviderType.Outlook;
+
public override string ToString() => Name;
}
diff --git a/Wino.Core.Domain/Enums/MailCategorySource.cs b/Wino.Core.Domain/Enums/MailCategorySource.cs
new file mode 100644
index 00000000..c24b08d9
--- /dev/null
+++ b/Wino.Core.Domain/Enums/MailCategorySource.cs
@@ -0,0 +1,7 @@
+namespace Wino.Core.Domain.Enums;
+
+public enum MailCategorySource
+{
+ Local,
+ Outlook
+}
diff --git a/Wino.Core.Domain/Enums/MailOperation.cs b/Wino.Core.Domain/Enums/MailOperation.cs
index 3f34c2fa..ea2773f3 100644
--- a/Wino.Core.Domain/Enums/MailOperation.cs
+++ b/Wino.Core.Domain/Enums/MailOperation.cs
@@ -13,6 +13,7 @@ public enum MailSynchronizerOperation
AlwaysMoveTo,
MoveToFocused,
Archive,
+ UpdateCategories,
}
public enum FolderSynchronizerOperation
@@ -35,6 +36,13 @@ public enum CalendarSynchronizerOperation
TentativeEvent,
}
+public enum CategorySynchronizerOperation
+{
+ CreateCategory,
+ UpdateCategory,
+ DeleteCategory,
+}
+
// UI requests
public enum MailOperation
{
diff --git a/Wino.Core.Domain/Enums/MailSynchronizationType.cs b/Wino.Core.Domain/Enums/MailSynchronizationType.cs
index 650c2a59..416125f2 100644
--- a/Wino.Core.Domain/Enums/MailSynchronizationType.cs
+++ b/Wino.Core.Domain/Enums/MailSynchronizationType.cs
@@ -3,6 +3,7 @@
public enum MailSynchronizationType
{
UpdateProfile, // Only update profile information
+ Categories, // Only update mail categories
ExecuteRequests, // Run the queued requests, and then synchronize if needed.
FoldersOnly, // Only synchronize folder metadata.
InboxOnly, // Only Inbox, Sent, Draft and Deleted folders.
diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs
index eb78105a..5a23c97c 100644
--- a/Wino.Core.Domain/Enums/WinoPage.cs
+++ b/Wino.Core.Domain/Enums/WinoPage.cs
@@ -24,6 +24,7 @@ public enum WinoPage
AppPreferencesPage,
SettingOptionsPage,
AliasManagementPage,
+ MailCategoryManagementPage,
ImapCalDavSettingsPage,
KeyboardShortcutsPage,
CalendarPage,
diff --git a/Wino.Core.Domain/Interfaces/IFolderMenuItem.cs b/Wino.Core.Domain/Interfaces/IFolderMenuItem.cs
index 26413208..a9d2e063 100644
--- a/Wino.Core.Domain/Interfaces/IFolderMenuItem.cs
+++ b/Wino.Core.Domain/Interfaces/IFolderMenuItem.cs
@@ -14,6 +14,22 @@ public interface IFolderMenuItem : IBaseFolderMenuItem
public interface IMergedAccountFolderMenuItem : IBaseFolderMenuItem { }
+public interface IMailCategoryMenuItem : IBaseFolderMenuItem
+{
+ Entities.Mail.MailCategory MailCategory { get; }
+ string TextColorHex { get; }
+ string BackgroundColorHex { get; }
+ bool HasTextColor { get; }
+}
+
+public interface IMergedMailCategoryMenuItem : IBaseFolderMenuItem
+{
+ IReadOnlyList Categories { get; }
+ string TextColorHex { get; }
+ string BackgroundColorHex { get; }
+ bool HasTextColor { get; }
+}
+
public interface IBaseFolderMenuItem : IMenuItem
{
string FolderName { get; }
diff --git a/Wino.Core.Domain/Interfaces/IMailCategoryService.cs b/Wino.Core.Domain/Interfaces/IMailCategoryService.cs
new file mode 100644
index 00000000..a21aba3e
--- /dev/null
+++ b/Wino.Core.Domain/Interfaces/IMailCategoryService.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Models.Accounts;
+
+namespace Wino.Core.Domain.Interfaces;
+
+public interface IMailCategoryService
+{
+ Task> GetCategoriesAsync(Guid accountId);
+ Task> GetFavoriteCategoriesAsync(Guid accountId);
+ Task GetCategoryAsync(Guid categoryId);
+ Task CategoryNameExistsAsync(Guid accountId, string name, Guid? excludedCategoryId = null);
+ Task CreateCategoryAsync(MailCategory category);
+ Task UpdateCategoryAsync(MailCategory category);
+ Task DeleteCategoryAsync(Guid categoryId);
+ Task DeleteCategoriesAsync(Guid accountId);
+ Task ToggleFavoriteAsync(Guid categoryId, bool isFavorite);
+ Task UpdateRemoteIdAsync(Guid categoryId, string remoteId);
+ Task ReplaceCategoriesAsync(Guid accountId, IEnumerable categories);
+ Task ReplaceMailAssignmentsAsync(Guid accountId, Guid mailCopyUniqueId, IEnumerable categoryNames);
+ Task AssignCategoryAsync(Guid categoryId, IEnumerable mailCopyUniqueIds);
+ Task UnassignCategoryAsync(Guid categoryId, IEnumerable mailCopyUniqueIds);
+ Task> GetCategoriesForMailAsync(Guid accountId, IEnumerable mailCopyUniqueIds);
+ Task> GetAssignedCategoryIdsForAllAsync(IEnumerable mailCopyUniqueIds);
+ Task> GetCategoryNamesForMailAsync(Guid mailCopyUniqueId);
+ Task> GetMailCopiesForCategoryAsync(Guid categoryId);
+ Task> GetUnreadCategoryCountResultsAsync(IEnumerable accountIds);
+}
diff --git a/Wino.Core.Domain/Interfaces/IMailDialogService.cs b/Wino.Core.Domain/Interfaces/IMailDialogService.cs
index dc60c422..880ba7df 100644
--- a/Wino.Core.Domain/Interfaces/IMailDialogService.cs
+++ b/Wino.Core.Domain/Interfaces/IMailDialogService.cs
@@ -11,6 +11,7 @@ using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders;
+using Wino.Core.Domain.Models.MailItem;
namespace Wino.Core.Domain.Interfaces;
@@ -51,6 +52,13 @@ public interface IMailDialogService : IDialogServiceBase
/// Created alias model if not canceled.
Task ShowCreateAccountAliasDialogAsync();
+ ///
+ /// Presents a dialog to the user for mail category creation/modification.
+ ///
+#pragma warning disable CS8625
+ Task ShowEditMailCategoryDialogAsync(MailCategory category = null);
+#pragma warning restore CS8625
+
///
/// Presents a dialog to the user to show email source.
///
diff --git a/Wino.Core.Domain/Interfaces/IRequestBundle.cs b/Wino.Core.Domain/Interfaces/IRequestBundle.cs
index 955b89d8..849fb26f 100644
--- a/Wino.Core.Domain/Interfaces/IRequestBundle.cs
+++ b/Wino.Core.Domain/Interfaces/IRequestBundle.cs
@@ -72,3 +72,9 @@ public interface ICalendarActionRequest : IRequestBase
Guid? LocalCalendarItemId { get; }
CalendarSynchronizerOperation Operation { get; }
}
+
+public interface ICategoryActionRequest : IRequestBase
+{
+ Guid AccountId { get; }
+ CategorySynchronizerOperation Operation { get; }
+}
diff --git a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs
index 7689e2bb..0d7629c1 100644
--- a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs
+++ b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs
@@ -63,6 +63,12 @@ public interface ISynchronizationManager
Task SynchronizeAliasesAsync(Guid accountId,
CancellationToken cancellationToken = default);
+ ///
+ /// Handles category synchronization for the given account.
+ ///
+ Task SynchronizeCategoriesAsync(Guid accountId,
+ CancellationToken cancellationToken = default);
+
///
/// Handles profile synchronization for the given account.
///
diff --git a/Wino.Core.Domain/Interfaces/IWinoRequestDelegator.cs b/Wino.Core.Domain/Interfaces/IWinoRequestDelegator.cs
index 0cf1cabf..4a54ea21 100644
--- a/Wino.Core.Domain/Interfaces/IWinoRequestDelegator.cs
+++ b/Wino.Core.Domain/Interfaces/IWinoRequestDelegator.cs
@@ -1,4 +1,6 @@
-using System.Threading.Tasks;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
@@ -36,4 +38,9 @@ public interface IWinoRequestDelegator
///
/// Calendar preparation request.
Task ExecuteAsync(CalendarOperationPreparationRequest calendarOperationPreparationRequest);
+
+ ///
+ /// Queues pre-built requests for a single account and triggers synchronization.
+ ///
+ Task ExecuteAsync(Guid accountId, IEnumerable requests);
}
diff --git a/Wino.Core.Domain/MenuItems/MailCategoryMenuItem.cs b/Wino.Core.Domain/MenuItems/MailCategoryMenuItem.cs
new file mode 100644
index 00000000..c5232137
--- /dev/null
+++ b/Wino.Core.Domain/MenuItems/MailCategoryMenuItem.cs
@@ -0,0 +1,48 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Folders;
+
+namespace Wino.Core.Domain.MenuItems;
+
+public partial class MailCategoryMenuItem : MenuItemBase, IFolderMenuItem, IMailCategoryMenuItem
+{
+ private IReadOnlyList _handlingFolders;
+
+ [ObservableProperty]
+ private int unreadItemCount;
+
+ public MailCategoryMenuItem(MailCategory category, MailAccount parentAccount, IEnumerable handlingFolders, IMenuItem parentMenuItem)
+ : base(category, category.Id, parentMenuItem)
+ {
+ ParentAccount = parentAccount;
+ _handlingFolders = handlingFolders?.ToList() ?? [];
+ }
+
+ public string FolderName => Parameter.Name;
+ public bool IsSynchronizationEnabled => false;
+ public SpecialFolderType SpecialFolderType => SpecialFolderType.Other;
+ public IEnumerable HandlingFolders => _handlingFolders;
+ public new ObservableCollection SubMenuItems { get; } = [];
+ public bool IsMoveTarget => true;
+ public bool IsSticky => false;
+ public bool IsSystemFolder => false;
+ public bool ShowUnreadCount => true;
+ public string AssignedAccountName => ParentAccount?.Name;
+ public MailAccount ParentAccount { get; private set; }
+ public string TextColorHex => Parameter.TextColorHex;
+ public string BackgroundColorHex => Parameter.BackgroundColorHex;
+ public bool HasTextColor => !string.IsNullOrWhiteSpace(Parameter.TextColorHex);
+ public MailCategory MailCategory => Parameter;
+
+ public void UpdateFolder(IMailItemFolder folder)
+ {
+ }
+
+ public void UpdateParentAccounnt(MailAccount account) => ParentAccount = account;
+}
diff --git a/Wino.Core.Domain/MenuItems/MenuItemCollection.cs b/Wino.Core.Domain/MenuItems/MenuItemCollection.cs
index 7504c0a7..9a9b34f4 100644
--- a/Wino.Core.Domain/MenuItems/MenuItemCollection.cs
+++ b/Wino.Core.Domain/MenuItems/MenuItemCollection.cs
@@ -22,11 +22,13 @@ public class MenuItemCollection : ObservableRangeCollection
public IEnumerable GetAllAccountMenuItems()
{
- foreach (var item in this)
+ var rootItems = this.ToList();
+
+ foreach (var item in rootItems)
{
if (item is MergedAccountMenuItem mergedAccountMenuItem)
{
- foreach (var singleItem in mergedAccountMenuItem.SubMenuItems.OfType())
+ foreach (var singleItem in mergedAccountMenuItem.SubMenuItems.OfType().ToList())
{
yield return singleItem;
}
@@ -40,9 +42,11 @@ public class MenuItemCollection : ObservableRangeCollection
public IEnumerable GetAllFolderMenuItems(Guid folderId)
{
- foreach (var item in this)
+ var rootItems = this.ToList();
+
+ foreach (var item in rootItems)
{
- if (item is IBaseFolderMenuItem folderMenuItem)
+ if (item is IBaseFolderMenuItem folderMenuItem && item is not IMailCategoryMenuItem && item is not IMergedMailCategoryMenuItem)
{
if (folderMenuItem.HandlingFolders.Any(a => a.Id == folderId))
{
@@ -50,7 +54,7 @@ public class MenuItemCollection : ObservableRangeCollection
}
else if (folderMenuItem.SubMenuItems.Any())
{
- foreach (var subItem in folderMenuItem.SubMenuItems.OfType())
+ foreach (var subItem in folderMenuItem.SubMenuItems.OfType().ToList())
{
if (subItem.HandlingFolders.Any(a => a.Id == folderId))
{
@@ -65,8 +69,10 @@ public class MenuItemCollection : ObservableRangeCollection
public bool TryGetAccountMenuItem(Guid accountId, out IAccountMenuItem value)
{
- value = this.OfType().FirstOrDefault(a => a.AccountId == accountId);
- value ??= this.OfType().FirstOrDefault(a => a.SubMenuItems.OfType().Where(b => b.AccountId == accountId) != null);
+ var rootItems = this.ToList();
+
+ value = rootItems.OfType().FirstOrDefault(a => a.AccountId == accountId);
+ value ??= rootItems.OfType().FirstOrDefault(a => a.SubMenuItems.OfType().Any(b => b.AccountId == accountId));
return value != null;
}
@@ -74,7 +80,9 @@ public class MenuItemCollection : ObservableRangeCollection
// Pattern: Look for special folder menu item inside the loaded folders for Windows Mail style menu items.
public bool TryGetWindowsStyleRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
{
- value = this.OfType()
+ var rootItems = this.ToList();
+
+ value = rootItems.OfType()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
return value != null;
@@ -84,7 +92,9 @@ public class MenuItemCollection : ObservableRangeCollection
// This will not look for the folders inside individual account menu items inside merged account menu item.
public bool TryGetMergedAccountSpecialFolderMenuItem(Guid mergedInboxId, SpecialFolderType specialFolderType, out IBaseFolderMenuItem value)
{
- value = this.OfType()
+ var rootItems = this.ToList();
+
+ value = rootItems.OfType()
.Where(a => a.MergedInbox.Id == mergedInboxId)
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
@@ -93,11 +103,14 @@ public class MenuItemCollection : ObservableRangeCollection
public bool TryGetFolderMenuItem(Guid folderId, out IBaseFolderMenuItem value)
{
+ var rootItems = this.ToList();
+
// Root folders
- value = this.OfType()
+ value = rootItems.OfType()
+ .Where(a => a is not IMailCategoryMenuItem && a is not IMergedMailCategoryMenuItem)
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
- value ??= this.OfType()
+ value ??= rootItems.OfType()
.SelectMany(a => a.SubMenuItems)
.OfType()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
@@ -105,10 +118,23 @@ public class MenuItemCollection : ObservableRangeCollection
return value != null;
}
+ public bool TryGetCategoryMenuItem(Guid categoryId, out IBaseFolderMenuItem value)
+ {
+ var rootItems = this.ToList();
+
+ value = rootItems.OfType()
+ .FirstOrDefault(a => a.MailCategory.Id == categoryId);
+
+ value ??= rootItems.OfType()
+ .FirstOrDefault(a => a.Categories.Any(b => b.Id == categoryId)) as IBaseFolderMenuItem;
+
+ return value != null;
+ }
+
public void UpdateUnreadItemCountsToZero()
{
// Handle the root folders.
- foreach (var item in this.OfType())
+ foreach (var item in this.OfType().ToList())
{
RecursivelyResetUnreadItemCount(item);
}
@@ -120,7 +146,7 @@ public class MenuItemCollection : ObservableRangeCollection
if (baseFolderMenuItem.SubMenuItems == null) return;
- foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType())
+ foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType().ToList())
{
RecursivelyResetUnreadItemCount(subMenuItem);
}
@@ -128,7 +154,9 @@ public class MenuItemCollection : ObservableRangeCollection
public bool TryGetSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
{
- value = this.OfType()
+ var rootItems = this.ToList();
+
+ value = rootItems.OfType()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
return value != null;
@@ -142,11 +170,12 @@ public class MenuItemCollection : ObservableRangeCollection
public AccountMenuItem GetSpecificAccountMenuItem(Guid accountId)
{
AccountMenuItem accountMenuItem = null;
+ var rootItems = this.ToList();
- accountMenuItem = this.OfType().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
+ accountMenuItem = rootItems.OfType().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
// Look for the items inside the merged accounts if regular menu item is not found.
- accountMenuItem ??= this.OfType()
+ accountMenuItem ??= rootItems.OfType()
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))?.SubMenuItems
.OfType()
.FirstOrDefault(a => a.AccountId == accountId);
@@ -167,7 +196,7 @@ public class MenuItemCollection : ObservableRangeCollection
/// Whether menu items should be enabled or disabled.
public async Task SetAccountMenuItemEnabledStatusAsync(bool isEnabled)
{
- var accountItems = this.Where(a => a is IAccountMenuItem).Cast();
+ var accountItems = this.Where(a => a is IAccountMenuItem).Cast().ToList();
await _dispatcher.ExecuteOnUIThread(() =>
{
@@ -192,6 +221,7 @@ public class MenuItemCollection : ObservableRangeCollection
{
// Check root-level items.
var rootItem = this.OfType()
+ .Where(a => a is not IMailCategoryMenuItem && a is not IMergedMailCategoryMenuItem)
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
if (rootItem != null)
@@ -201,7 +231,7 @@ public class MenuItemCollection : ObservableRangeCollection
}
// Check sub-items of root folders.
- foreach (var rootFolder in this.OfType())
+ foreach (var rootFolder in this.OfType().ToList())
{
var subItem = rootFolder.SubMenuItems
.OfType()
diff --git a/Wino.Core.Domain/MenuItems/MergedMailCategoryMenuItem.cs b/Wino.Core.Domain/MenuItems/MergedMailCategoryMenuItem.cs
new file mode 100644
index 00000000..b750fbb6
--- /dev/null
+++ b/Wino.Core.Domain/MenuItems/MergedMailCategoryMenuItem.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Folders;
+
+namespace Wino.Core.Domain.MenuItems;
+
+public partial class MergedMailCategoryMenuItem : MenuItemBase, IMenuItem>, IMergedAccountFolderMenuItem, IMergedMailCategoryMenuItem
+{
+ private readonly IReadOnlyList _handlingFolders;
+
+ [ObservableProperty]
+ private int unreadItemCount;
+
+ public MergedMailCategoryMenuItem(List categories, IEnumerable handlingFolders, MergedInbox mergedInbox)
+ : base(categories, null, null)
+ {
+ _handlingFolders = handlingFolders?.ToList() ?? [];
+ MergedInbox = mergedInbox;
+ }
+
+ public string FolderName => Parameter.FirstOrDefault()?.Name ?? string.Empty;
+ public bool IsSynchronizationEnabled => false;
+ public SpecialFolderType SpecialFolderType => SpecialFolderType.Other;
+ public IEnumerable HandlingFolders => _handlingFolders;
+ public bool IsMoveTarget => true;
+ public bool IsSticky => false;
+ public bool IsSystemFolder => false;
+ public bool ShowUnreadCount => true;
+ public string AssignedAccountName => MergedInbox?.Name;
+ public MergedInbox MergedInbox { get; }
+ public string TextColorHex => Parameter.FirstOrDefault()?.TextColorHex;
+ public string BackgroundColorHex => Parameter.FirstOrDefault()?.BackgroundColorHex;
+ public bool HasTextColor => !string.IsNullOrWhiteSpace(TextColorHex);
+ public IReadOnlyList Categories => Parameter;
+
+ public void UpdateFolder(IMailItemFolder folder)
+ {
+ }
+}
diff --git a/Wino.Core.Domain/Models/Accounts/UnreadCategoryCountResult.cs b/Wino.Core.Domain/Models/Accounts/UnreadCategoryCountResult.cs
new file mode 100644
index 00000000..eadee1e3
--- /dev/null
+++ b/Wino.Core.Domain/Models/Accounts/UnreadCategoryCountResult.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace Wino.Core.Domain.Models.Accounts;
+
+public class UnreadCategoryCountResult
+{
+ public Guid CategoryId { get; set; }
+ public Guid AccountId { get; set; }
+ public int UnreadItemCount { get; set; }
+}
diff --git a/Wino.Core.Domain/Models/MailItem/MailCategoryColorOption.cs b/Wino.Core.Domain/Models/MailItem/MailCategoryColorOption.cs
new file mode 100644
index 00000000..2b412f9e
--- /dev/null
+++ b/Wino.Core.Domain/Models/MailItem/MailCategoryColorOption.cs
@@ -0,0 +1,3 @@
+namespace Wino.Core.Domain.Models.MailItem;
+
+public sealed record MailCategoryColorOption(string BackgroundColorHex, string TextColorHex);
diff --git a/Wino.Core.Domain/Models/MailItem/MailCategoryDialogResult.cs b/Wino.Core.Domain/Models/MailItem/MailCategoryDialogResult.cs
new file mode 100644
index 00000000..024fe5ac
--- /dev/null
+++ b/Wino.Core.Domain/Models/MailItem/MailCategoryDialogResult.cs
@@ -0,0 +1,3 @@
+namespace Wino.Core.Domain.Models.MailItem;
+
+public sealed record MailCategoryDialogResult(string Name, string BackgroundColorHex, string TextColorHex);
diff --git a/Wino.Core.Domain/Models/MailItem/MailCategoryPalette.cs b/Wino.Core.Domain/Models/MailItem/MailCategoryPalette.cs
new file mode 100644
index 00000000..17b1a5bc
--- /dev/null
+++ b/Wino.Core.Domain/Models/MailItem/MailCategoryPalette.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+
+namespace Wino.Core.Domain.Models.MailItem;
+
+public static class MailCategoryPalette
+{
+ public static IReadOnlyList DefaultOptions { get; } =
+ [
+ new("#FEE2E2", "#991B1B"),
+ new("#FECACA", "#7F1D1D"),
+ new("#FFEDD5", "#9A3412"),
+ new("#FED7AA", "#7C2D12"),
+ new("#FEF3C7", "#92400E"),
+ new("#FDE68A", "#78350F"),
+ new("#ECFCCB", "#3F6212"),
+ new("#D9F99D", "#365314"),
+ new("#DCFCE7", "#166534"),
+ new("#BBF7D0", "#14532D"),
+ new("#CCFBF1", "#115E59"),
+ new("#99F6E4", "#134E4A"),
+ new("#CFFAFE", "#155E75"),
+ new("#A5F3FC", "#164E63"),
+ new("#DBEAFE", "#1D4ED8"),
+ new("#BFDBFE", "#1E3A8A"),
+ new("#E0E7FF", "#4338CA"),
+ new("#DDD6FE", "#5B21B6"),
+ new("#F3E8FF", "#7E22CE"),
+ new("#FCE7F3", "#9D174D")
+ ];
+}
diff --git a/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs b/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs
index c57b1589..76b8abf0 100644
--- a/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs
+++ b/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs
@@ -9,4 +9,5 @@ public record NewMailItemPackage(
MailCopy Copy,
MimeMessage Mime,
string AssignedRemoteFolderId,
- IReadOnlyList ExtractedContacts = null);
+ IReadOnlyList ExtractedContacts = null,
+ IReadOnlyList CategoryNames = null);
diff --git a/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs b/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs
index 0ac98064..f75c652e 100644
--- a/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs
+++ b/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs
@@ -17,4 +17,8 @@ public record MailListInitializationOptions(IEnumerable Folders
List PreFetchMailCopies = null,
bool DeduplicateByServerId = false,
int Skip = 0,
- int Take = 0);
+ int Take = 0)
+{
+ public IReadOnlyList CategoryIds { get; init; }
+ public bool IsCategoryView => CategoryIds?.Count > 0;
+}
diff --git a/Wino.Core.Domain/Models/Requests/RequestBase.cs b/Wino.Core.Domain/Models/Requests/RequestBase.cs
index 9ea7876c..2f307e17 100644
--- a/Wino.Core.Domain/Models/Requests/RequestBase.cs
+++ b/Wino.Core.Domain/Models/Requests/RequestBase.cs
@@ -35,6 +35,10 @@ public abstract record CalendarRequestBase(CalendarItem Item) : RequestBase Item?.Id;
}
+public abstract record CategoryRequestBase(Guid AccountId) : RequestBase, ICategoryActionRequest
+{
+}
+
public class BatchCollection : List, IUIChangeRequest where TRequestType : IUIChangeRequest
{
public BatchCollection(IEnumerable collection) : base(collection)
diff --git a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs
index 5002a059..4a3581e4 100644
--- a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs
+++ b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs
@@ -170,6 +170,7 @@ public static class SettingsNavigationInfoProvider
WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage,
WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage,
WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage,
+ WinoPage.MailCategoryManagementPage => WinoPage.ManageAccountsPage,
WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage,
WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage,
WinoPage.CreateEmailTemplatePage => WinoPage.EmailTemplatesPage,
diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json
index 5f97615f..83d0802a 100644
--- a/Wino.Core.Domain/Translations/en_US/resources.json
+++ b/Wino.Core.Domain/Translations/en_US/resources.json
@@ -67,6 +67,7 @@
"BasicIMAPSetupDialog_Password": "Password",
"BasicIMAPSetupDialog_Title": "IMAP Account",
"Busy": "Busy",
+ "Buttons_Add": "Add",
"Buttons_AddAccount": "Add Account",
"Buttons_FixAccount": "Fix Account",
"Buttons_AddNewAlias": "Add New Alias",
@@ -875,10 +876,28 @@
"SettingsManageAccountSettings_Title": "Manage Accounts",
"SettingsManageAliases_Description": "See e-mail aliases assigned for this account, update or delete them.",
"SettingsManageAliases_Title": "Aliases",
+ "SettingsMailCategories_Description": "Manage synchronized and local categories for this account.",
+ "SettingsMailCategories_Title": "Categories",
"SettingsEditAccountDetails_Title": "Edit Account Details",
"SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.",
"EditAccountDetailsPage_SaveSuccess_Title": "Changes Saved",
"EditAccountDetailsPage_SaveSuccess_Message": "Your account details have been updated successfully.",
+ "MailCategoryManagementPage_Title": "Categories",
+ "MailCategoryManagementPage_Description": "Create, edit, delete, and favorite categories for this account.",
+ "MailCategoryManagementPage_Empty": "No categories yet.",
+ "MailCategoryManagementPage_DeleteConfirmationTitle": "Delete Category",
+ "MailCategoryManagementPage_DeleteConfirmationMessage": "Delete category \"{0}\"?",
+ "MailCategoryManagementPage_RefreshConfirmationMessage": "This will delete all your local categories, and re-synchronize everything from the server. Do you want to continue?",
+ "MailCategoryMenuItem": "Category",
+ "MailCategoryDialog_CreateTitle": "Create category",
+ "MailCategoryDialog_EditTitle": "Edit category",
+ "MailCategoryDialog_Name": "Name",
+ "MailCategoryDialog_NamePlaceholder": "Category name",
+ "MailCategoryDialog_Color": "Color",
+ "MailCategoryDialog_InvalidNameTitle": "Category name required",
+ "MailCategoryDialog_InvalidNameMessage": "Enter a category name to continue.",
+ "MailCategoryDialog_DuplicateTitle": "Category already exists",
+ "MailCategoryDialog_DuplicateMessage": "A category with the same name already exists for this account.",
"SettingsManageLink_Description": "Move items to add new link or remove existing link.",
"SettingsManageLink_Title": "Manage Link",
"SettingsMarkAsRead_Description": "Change what should happen to the selected item.",
@@ -1491,11 +1510,13 @@
"AccountSetup_Step_TestingCalendarAuth": "Testing calendar authentication",
"AccountSetup_Step_SavingAccount": "Saving account information",
"AccountSetup_Step_FetchingCalendarMetadata": "Fetching calendar metadata",
+ "AccountSetup_Step_SyncingCategories": "Synchronizing categories",
"AccountSetup_Step_SyncingAliases": "Synchronizing aliases",
"AccountSetup_Step_Finalizing": "Finalizing setup",
"AccountSetup_FailureMessage": "Setup failed. Go back to fix your settings, or try again later.",
"AccountSetup_SuccessMessage": "Your account has been set up successfully!",
"AccountSetup_GoBackButton": "Go Back",
"AccountSetup_TryAgainButton": "Try Again",
+ "Exception_FailedToSynchronizeCategories": "Failed to synchronize categories",
"ImapCalDavSettings_AutoDiscoveryFailed": "Auto-discovery failed. Please enter settings manually in the Advanced tab."
}
diff --git a/Wino.Core.Tests/Services/MailFetchingTests.cs b/Wino.Core.Tests/Services/MailFetchingTests.cs
index 61fa2437..c13869f3 100644
--- a/Wino.Core.Tests/Services/MailFetchingTests.cs
+++ b/Wino.Core.Tests/Services/MailFetchingTests.cs
@@ -510,7 +510,8 @@ public class MailFetchingTests : IAsyncLifetime
preferencesService.Object,
contactPictureFileService.Object);
- var folderService = new FolderService(db, accountService);
+ var mailCategoryService = new MailCategoryService(db);
+ var folderService = new FolderService(db, accountService, mailCategoryService);
var contactService = new ContactService(db);
var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
@@ -522,6 +523,7 @@ public class MailFetchingTests : IAsyncLifetime
signatureService.Object,
mimeFileService.Object,
preferencesService.Object,
- sentMailReceiptService);
+ sentMailReceiptService,
+ mailCategoryService);
}
}
diff --git a/Wino.Core.Tests/Services/MailThreadingTests.cs b/Wino.Core.Tests/Services/MailThreadingTests.cs
index 1f0af6ab..3e1f827b 100644
--- a/Wino.Core.Tests/Services/MailThreadingTests.cs
+++ b/Wino.Core.Tests/Services/MailThreadingTests.cs
@@ -269,7 +269,8 @@ public class MailThreadingTests : IAsyncLifetime
preferencesService.Object,
contactPictureFileService.Object);
- var folderService = new FolderService(db, accountService);
+ var mailCategoryService = new MailCategoryService(db);
+ var folderService = new FolderService(db, accountService, mailCategoryService);
var contactService = new ContactService(db);
var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService);
@@ -281,6 +282,7 @@ public class MailThreadingTests : IAsyncLifetime
signatureService.Object,
mimeFileService.Object,
preferencesService.Object,
- sentMailReceiptService);
+ sentMailReceiptService,
+ mailCategoryService);
}
}
diff --git a/Wino.Core.Tests/Synchronizers/OutlookSynchronizerRequestSuccessTests.cs b/Wino.Core.Tests/Synchronizers/OutlookSynchronizerRequestSuccessTests.cs
index c723e79d..98aa4260 100644
--- a/Wino.Core.Tests/Synchronizers/OutlookSynchronizerRequestSuccessTests.cs
+++ b/Wino.Core.Tests/Synchronizers/OutlookSynchronizerRequestSuccessTests.cs
@@ -70,8 +70,9 @@ public sealed class OutlookSynchronizerRequestSuccessTests
var authenticator = new Mock(MockBehavior.Loose);
var errorFactory = new Mock(MockBehavior.Loose);
+ var mailCategoryService = new Mock(MockBehavior.Loose);
- return new OutlookSynchronizer(account, authenticator.Object, changeProcessor, errorFactory.Object);
+ return new OutlookSynchronizer(account, authenticator.Object, changeProcessor, errorFactory.Object, mailCategoryService.Object);
}
private static MailCopy CreateMailCopy() =>
diff --git a/Wino.Core/Requests/Category/MailCategoryCreateRequest.cs b/Wino.Core/Requests/Category/MailCategoryCreateRequest.cs
new file mode 100644
index 00000000..0234104c
--- /dev/null
+++ b/Wino.Core/Requests/Category/MailCategoryCreateRequest.cs
@@ -0,0 +1,10 @@
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Models.Requests;
+
+namespace Wino.Core.Requests.Category;
+
+public record MailCategoryCreateRequest(MailCategory Category) : CategoryRequestBase(Category.MailAccountId)
+{
+ public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.CreateCategory;
+}
diff --git a/Wino.Core/Requests/Category/MailCategoryDeleteRequest.cs b/Wino.Core/Requests/Category/MailCategoryDeleteRequest.cs
new file mode 100644
index 00000000..64b4100c
--- /dev/null
+++ b/Wino.Core/Requests/Category/MailCategoryDeleteRequest.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Models.Requests;
+
+namespace Wino.Core.Requests.Category;
+
+public record MailCategoryDeleteRequest(
+ MailCategory Category,
+ string PreviousRemoteId,
+ IReadOnlyList AffectedMessages = null) : CategoryRequestBase(Category.MailAccountId)
+{
+ public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.DeleteCategory;
+}
diff --git a/Wino.Core/Requests/Category/MailCategoryRequestModels.cs b/Wino.Core/Requests/Category/MailCategoryRequestModels.cs
new file mode 100644
index 00000000..2b76b388
--- /dev/null
+++ b/Wino.Core/Requests/Category/MailCategoryRequestModels.cs
@@ -0,0 +1,5 @@
+using System.Collections.Generic;
+
+namespace Wino.Core.Requests.Category;
+
+public sealed record MailCategoryMessageUpdateTarget(string MessageId, IReadOnlyList CategoryNames);
diff --git a/Wino.Core/Requests/Category/MailCategoryUpdateRequest.cs b/Wino.Core/Requests/Category/MailCategoryUpdateRequest.cs
new file mode 100644
index 00000000..78babdf2
--- /dev/null
+++ b/Wino.Core/Requests/Category/MailCategoryUpdateRequest.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Models.Requests;
+
+namespace Wino.Core.Requests.Category;
+
+public record MailCategoryUpdateRequest(
+ MailCategory Category,
+ string PreviousName,
+ string PreviousRemoteId,
+ IReadOnlyList AffectedMessages = null) : CategoryRequestBase(Category.MailAccountId)
+{
+ public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.UpdateCategory;
+}
diff --git a/Wino.Core/Requests/Mail/MailCategoryAssignmentRequest.cs b/Wino.Core/Requests/Mail/MailCategoryAssignmentRequest.cs
new file mode 100644
index 00000000..b87cc56b
--- /dev/null
+++ b/Wino.Core/Requests/Mail/MailCategoryAssignmentRequest.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Collections.Generic;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Requests;
+
+namespace Wino.Core.Requests.Mail;
+
+public record MailCategoryAssignmentRequest(
+ MailCopy Item,
+ Guid MailCategoryId,
+ string CategoryName,
+ IReadOnlyList CategoryNames,
+ bool IsAssigned) : MailRequestBase(Item), ICustomFolderSynchronizationRequest
+{
+ public override MailSynchronizerOperation Operation => MailSynchronizerOperation.UpdateCategories;
+ public List SynchronizationFolderIds => [Item.FolderId];
+ public bool ExcludeMustHaveFolders => true;
+}
+
+public class BatchMailCategoryAssignmentRequest : BatchCollection
+{
+ public BatchMailCategoryAssignmentRequest(IEnumerable collection) : base(collection)
+ {
+ }
+}
diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs
index a6bf288d..b570aea9 100644
--- a/Wino.Core/Services/SynchronizationManager.cs
+++ b/Wino.Core/Services/SynchronizationManager.cs
@@ -370,6 +370,26 @@ public class SynchronizationManager : ISynchronizationManager, IRecipient
+ /// Handles category synchronization for the given account.
+ ///
+ /// Account ID to synchronize categories for
+ /// Cancellation token
+ /// Synchronization result
+ public async Task SynchronizeCategoriesAsync(Guid accountId,
+ CancellationToken cancellationToken = default)
+ {
+ EnsureInitialized();
+
+ var options = new MailSynchronizationOptions
+ {
+ AccountId = accountId,
+ Type = MailSynchronizationType.Categories
+ };
+
+ return await SynchronizeMailAsync(options, cancellationToken);
+ }
+
///
/// Handles profile synchronization for the given account.
///
diff --git a/Wino.Core/Services/SynchronizerFactory.cs b/Wino.Core/Services/SynchronizerFactory.cs
index 7aa25880..ccbf2d6d 100644
--- a/Wino.Core/Services/SynchronizerFactory.cs
+++ b/Wino.Core/Services/SynchronizerFactory.cs
@@ -26,6 +26,7 @@ public class SynchronizerFactory : ISynchronizerFactory
private readonly ICalDavClient _calDavClient;
private readonly IAutoDiscoveryService _autoDiscoveryService;
private readonly ICalendarService _calendarService;
+ private readonly IMailCategoryService _mailCategoryService;
private readonly List synchronizerCache = new();
@@ -41,7 +42,8 @@ public class SynchronizerFactory : ISynchronizerFactory
UnifiedImapSynchronizer unifiedImapSynchronizer,
ICalDavClient calDavClient,
IAutoDiscoveryService autoDiscoveryService,
- ICalendarService calendarService)
+ ICalendarService calendarService,
+ IMailCategoryService mailCategoryService)
{
_outlookChangeProcessor = outlookChangeProcessor;
_gmailChangeProcessor = gmailChangeProcessor;
@@ -56,6 +58,7 @@ public class SynchronizerFactory : ISynchronizerFactory
_calDavClient = calDavClient;
_autoDiscoveryService = autoDiscoveryService;
_calendarService = calendarService;
+ _mailCategoryService = mailCategoryService;
}
public async Task GetAccountSynchronizerAsync(Guid accountId)
@@ -86,7 +89,7 @@ public class SynchronizerFactory : ISynchronizerFactory
{
case Domain.Enums.MailProviderType.Outlook:
var outlookAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Outlook) as IOutlookAuthenticator;
- return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory);
+ return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory, _mailCategoryService);
case Domain.Enums.MailProviderType.Gmail:
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
diff --git a/Wino.Core/Services/WinoRequestDelegator.cs b/Wino.Core/Services/WinoRequestDelegator.cs
index b98c7ec6..bdd339b1 100644
--- a/Wino.Core/Services/WinoRequestDelegator.cs
+++ b/Wino.Core/Services/WinoRequestDelegator.cs
@@ -200,6 +200,21 @@ public class WinoRequestDelegator : IWinoRequestDelegator
await QueueCalendarSynchronizationAsync(accountId);
}
+ public async Task ExecuteAsync(Guid accountId, IEnumerable requests)
+ {
+ var requestList = requests?.Where(a => a != null).ToList() ?? [];
+ if (requestList.Count == 0)
+ return;
+
+ foreach (var request in requestList)
+ {
+ await QueueRequestAsync(request, accountId).ConfigureAwait(false);
+ }
+
+ await SendSyncActionsAddedAsync(requestList, accountId).ConfigureAwait(false);
+ await QueueSynchronizationAsync(accountId).ConfigureAwait(false);
+ }
+
private async Task CreateCalendarEventRequestAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
{
var composeResult = calendarPreparationRequest.ComposeResult
diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs
index c353411d..2cc0a4a8 100644
--- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs
+++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs
@@ -41,6 +41,7 @@ using Wino.Core.Integration.Processors;
using Wino.Core.Misc;
using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Calendar;
+using Wino.Core.Requests.Category;
using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail;
using Wino.Messaging.UI;
@@ -107,6 +108,7 @@ public class OutlookSynchronizer : WinoSynchronizer !string.IsNullOrWhiteSpace(a?.DisplayName))
+ .Select(a =>
+ {
+ var colorOption = GetMailCategoryColorOption(a.Color);
+
+ return new MailCategory
+ {
+ MailAccountId = Account.Id,
+ RemoteId = a.Id,
+ Name = a.DisplayName,
+ BackgroundColorHex = colorOption.BackgroundColorHex,
+ TextColorHex = colorOption.TextColorHex,
+ Source = MailCategorySource.Outlook
+ };
+ })
+ .ToList() ?? [];
+
+ await _mailCategoryService.ReplaceCategoriesAsync(Account.Id, categories).ConfigureAwait(false);
+ }
+
+ private async Task ReplaceMailAssignmentsAsync(string messageId, IEnumerable categoryNames)
+ {
+ var localMailCopies = await _outlookChangeProcessor.GetMailCopiesAsync([messageId]).ConfigureAwait(false);
+
+ foreach (var localMailCopy in localMailCopies)
+ {
+ await _mailCategoryService.ReplaceMailAssignmentsAsync(Account.Id, localMailCopy.UniqueId, categoryNames ?? []).ConfigureAwait(false);
+ }
+ }
+
private async Task GetSpecialFolderIdsAsync(CancellationToken cancellationToken)
{
var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
@@ -1767,6 +1814,87 @@ public class OutlookSynchronizer : WinoSynchronizer> UpdateCategories(BatchMailCategoryAssignmentRequest request)
+ => ForEachRequest(request, item => CreateMessageCategoryPatchRequest(item.Item.Id, item.CategoryNames));
+
+ public override List> CreateCategory(MailCategoryCreateRequest request)
+ {
+ var outlookCategory = new OutlookCategory
+ {
+ DisplayName = request.Category.Name,
+ Color = GetOutlookCategoryColor(request.Category)
+ };
+
+ var requestInfo = _graphClient.Me.Outlook.MasterCategories.ToPostRequestInformation(outlookCategory);
+ return [new HttpRequestBundle(requestInfo, request)];
+ }
+
+ public override List> UpdateCategory(MailCategoryUpdateRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.PreviousRemoteId))
+ return CreateCategory(new MailCategoryCreateRequest(request.Category));
+
+ var hasNameChanged = !string.Equals(request.PreviousName, request.Category.Name, StringComparison.Ordinal);
+ if (!hasNameChanged)
+ {
+ var requestInfo = _graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToPatchRequestInformation(new OutlookCategory
+ {
+ Color = GetOutlookCategoryColor(request.Category)
+ });
+
+ return [new HttpRequestBundle(requestInfo, request)];
+ }
+
+ var bundles = new List>();
+ var createRequestInfo = _graphClient.Me.Outlook.MasterCategories.ToPostRequestInformation(new OutlookCategory
+ {
+ DisplayName = request.Category.Name,
+ Color = GetOutlookCategoryColor(request.Category)
+ });
+
+ bundles.Add(new HttpRequestBundle(createRequestInfo, request));
+
+ foreach (var target in request.AffectedMessages ?? [])
+ {
+ bundles.Add(new HttpRequestBundle(
+ CreateMessageCategoryPatchRequest(target.MessageId, target.CategoryNames),
+ request));
+ }
+
+ bundles.Add(new HttpRequestBundle(
+ _graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToDeleteRequestInformation(),
+ request));
+
+ return bundles;
+ }
+
+ public override List> DeleteCategory(MailCategoryDeleteRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.PreviousRemoteId))
+ return [];
+
+ var bundles = new List>();
+
+ foreach (var target in request.AffectedMessages ?? [])
+ {
+ bundles.Add(new HttpRequestBundle(
+ CreateMessageCategoryPatchRequest(target.MessageId, target.CategoryNames),
+ request));
+ }
+
+ bundles.Add(new HttpRequestBundle(
+ _graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToDeleteRequestInformation(),
+ request));
+
+ return bundles;
+ }
+
+ private RequestInformation CreateMessageCategoryPatchRequest(string messageId, IReadOnlyList categoryNames)
+ => _graphClient.Me.Messages[messageId].ToPatchRequestInformation(new Message
+ {
+ Categories = categoryNames?.ToList() ?? []
+ });
+
public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem,
MailKit.ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
@@ -1962,7 +2090,9 @@ public class OutlookSynchronizer : WinoSynchronizer();
+ if (!string.IsNullOrWhiteSpace(createdCategoryId))
+ {
+ await _mailCategoryService.UpdateRemoteIdAsync(createCategoryRequest.Category.Id, createdCategoryId).ConfigureAwait(false);
+ }
+ return;
+ }
+
+ if (bundle?.UIChangeRequest is MailCategoryUpdateRequest updateCategoryRequest)
+ {
+ var updatedCategoryId = json?["id"]?.GetValue();
+ if (!string.IsNullOrWhiteSpace(updatedCategoryId))
+ {
+ await _mailCategoryService.UpdateRemoteIdAsync(updateCategoryRequest.Category.Id, updatedCategoryId).ConfigureAwait(false);
+ }
}
}
catch (Exception ex)
@@ -2367,11 +2520,68 @@ public class OutlookSynchronizer : WinoSynchronizer color switch
+ {
+ CategoryColor.Preset0 => new("#FEE2E2", "#991B1B"),
+ CategoryColor.Preset1 => new("#FFEDD5", "#9A3412"),
+ CategoryColor.Preset2 => new("#FEF3C7", "#92400E"),
+ CategoryColor.Preset3 => new("#ECFCCB", "#3F6212"),
+ CategoryColor.Preset4 => new("#DCFCE7", "#166534"),
+ CategoryColor.Preset5 => new("#CCFBF1", "#115E59"),
+ CategoryColor.Preset6 => new("#CFFAFE", "#155E75"),
+ CategoryColor.Preset7 => new("#DBEAFE", "#1D4ED8"),
+ CategoryColor.Preset8 => new("#E0E7FF", "#4338CA"),
+ CategoryColor.Preset9 => new("#F3E8FF", "#7E22CE"),
+ CategoryColor.Preset10 => new("#FCE7F3", "#9D174D"),
+ CategoryColor.Preset11 => new("#FECACA", "#7F1D1D"),
+ CategoryColor.Preset12 => new("#FED7AA", "#7C2D12"),
+ CategoryColor.Preset13 => new("#FDE68A", "#78350F"),
+ CategoryColor.Preset14 => new("#D9F99D", "#365314"),
+ CategoryColor.Preset15 => new("#BBF7D0", "#14532D"),
+ CategoryColor.Preset16 => new("#99F6E4", "#134E4A"),
+ CategoryColor.Preset17 => new("#A5F3FC", "#164E63"),
+ CategoryColor.Preset18 => new("#BFDBFE", "#1E3A8A"),
+ CategoryColor.Preset19 => new("#DDD6FE", "#5B21B6"),
+ CategoryColor.Preset20 => new("#E5E7EB", "#374151"),
+ CategoryColor.Preset21 => new("#D1D5DB", "#1F2937"),
+ CategoryColor.Preset22 => new("#F3F4F6", "#111827"),
+ CategoryColor.Preset23 => new("#E2E8F0", "#334155"),
+ CategoryColor.Preset24 => new("#F8FAFC", "#475569"),
+ _ => new("#E5E7EB", "#374151")
+ };
+
+ private static CategoryColor GetOutlookCategoryColor(MailCategory category)
+ => (category.BackgroundColorHex?.ToUpperInvariant(), category.TextColorHex?.ToUpperInvariant()) switch
+ {
+ ("#FEE2E2", "#991B1B") => CategoryColor.Preset0,
+ ("#FFEDD5", "#9A3412") => CategoryColor.Preset1,
+ ("#FEF3C7", "#92400E") => CategoryColor.Preset2,
+ ("#ECFCCB", "#3F6212") => CategoryColor.Preset3,
+ ("#DCFCE7", "#166534") => CategoryColor.Preset4,
+ ("#CCFBF1", "#115E59") => CategoryColor.Preset5,
+ ("#CFFAFE", "#155E75") => CategoryColor.Preset6,
+ ("#DBEAFE", "#1D4ED8") => CategoryColor.Preset7,
+ ("#E0E7FF", "#4338CA") => CategoryColor.Preset8,
+ ("#F3E8FF", "#7E22CE") => CategoryColor.Preset9,
+ ("#FCE7F3", "#9D174D") => CategoryColor.Preset10,
+ ("#FECACA", "#7F1D1D") => CategoryColor.Preset11,
+ ("#FED7AA", "#7C2D12") => CategoryColor.Preset12,
+ ("#FDE68A", "#78350F") => CategoryColor.Preset13,
+ ("#D9F99D", "#365314") => CategoryColor.Preset14,
+ ("#BBF7D0", "#14532D") => CategoryColor.Preset15,
+ ("#99F6E4", "#134E4A") => CategoryColor.Preset16,
+ ("#A5F3FC", "#164E63") => CategoryColor.Preset17,
+ ("#BFDBFE", "#1E3A8A") => CategoryColor.Preset18,
+ ("#DDD6FE", "#5B21B6") => CategoryColor.Preset19,
+ _ => CategoryColor.Preset0
+ };
+
private async Task TryMapCalendarInvitationAsync(MailCopy mailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken)
{
if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs
index a8f9450d..1ed4fcfa 100644
--- a/Wino.Core/Synchronizers/WinoSynchronizer.cs
+++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs
@@ -20,6 +20,7 @@ using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Calendar;
+using Wino.Core.Requests.Category;
using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail;
using Wino.Messaging.UI;
@@ -63,6 +64,7 @@ public abstract class WinoSynchronizer
protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask;
+ protected virtual Task SynchronizeCategoriesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
///
/// Queues all mail ids for initial synchronization for a specific folder.
@@ -194,6 +196,9 @@ public abstract class WinoSynchronizer())));
break;
+ case MailSynchronizerOperation.UpdateCategories:
+ nativeRequests.AddRange(UpdateCategories(new BatchMailCategoryAssignmentRequest(group.Cast())));
+ break;
default:
break;
}
@@ -221,6 +226,23 @@ public abstract class WinoSynchronizerNew synchronization options with minimal HTTP effort.
private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List requests, Guid existingSynchronizationId)
{
+ if (requests.All(a => a is ICategoryActionRequest or MailCategoryAssignmentRequest))
+ {
+ return new MailSynchronizationOptions
+ {
+ AccountId = Account.Id,
+ Id = existingSynchronizationId,
+ Type = MailSynchronizationType.FoldersOnly
+ };
+ }
+
List synchronizationFolderIds = requests
.Where(a => a is ICustomFolderSynchronizationRequest)
.Cast()
@@ -602,6 +658,10 @@ public abstract class WinoSynchronizer> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List> DeleteFolder(DeleteFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List> CreateSubFolder(CreateSubFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
+ public virtual List> UpdateCategories(BatchMailCategoryAssignmentRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
+ public virtual List> CreateCategory(MailCategoryCreateRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
+ public virtual List> UpdateCategory(MailCategoryUpdateRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
+ public virtual List> DeleteCategory(MailCategoryDeleteRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
#endregion
diff --git a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs
index ac1ceff2..1f659a7a 100644
--- a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs
+++ b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs
@@ -169,6 +169,10 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
private void EditAliases()
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id));
+ [RelayCommand]
+ private void EditCategories()
+ => Messenger.Send(new BreadcrumbNavigationRequested(Translator.MailCategoryManagementPage_Title, WinoPage.MailCategoryManagementPage, Account.Id));
+
[RelayCommand]
private void EditImapCalDavSettings()
=> Messenger.Send(new BreadcrumbNavigationRequested(
diff --git a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs
index 8c218639..388b9e57 100644
--- a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs
+++ b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs
@@ -81,6 +81,10 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingProfile });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders });
+ if (WizardContext.SelectedProvider.Type == MailProviderType.Outlook)
+ {
+ Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingCategories });
+ }
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases });
Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing });
@@ -229,6 +233,16 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel
throw new Exception(Translator.Exception_FailedToSynchronizeFolders);
SetCurrentStepSucceeded();
+ // Step: Categories
+ if (_createdAccount.IsCategorySyncSupported)
+ {
+ SetStepInProgress(Translator.AccountSetup_Step_SyncingCategories);
+ var categoryResult = await SynchronizationManager.Instance.SynchronizeCategoriesAsync(_createdAccount.Id);
+ if (categoryResult.CompletedState != SynchronizationCompletedState.Success)
+ throw new Exception(Translator.Exception_FailedToSynchronizeCategories);
+ SetCurrentStepSucceeded();
+ }
+
// Step: Calendar metadata
SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata);
if (_createdAccount.IsCalendarAccessGranted)
diff --git a/Wino.Mail.ViewModels/MailAppShellViewModel.cs b/Wino.Mail.ViewModels/MailAppShellViewModel.cs
index 1fa4033e..c93578dc 100644
--- a/Wino.Mail.ViewModels/MailAppShellViewModel.cs
+++ b/Wino.Mail.ViewModels/MailAppShellViewModel.cs
@@ -73,6 +73,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
public IMenuItem CreatePrimaryMenuItem => CreateMailMenuItem;
private readonly IFolderService _folderService;
+ private readonly IMailCategoryService _mailCategoryService;
private readonly IConfigurationService _configurationService;
private readonly IStartupBehaviorService _startupBehaviorService;
private readonly IAccountService _accountService;
@@ -99,6 +100,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
IMimeFileService mimeFileService,
INativeAppService nativeAppService,
IMailService mailService,
+ IMailCategoryService mailCategoryService,
IAccountService accountService,
IContextMenuItemService contextMenuItemService,
IStoreRatingService storeRatingService,
@@ -125,6 +127,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
_mimeFileService = mimeFileService;
_nativeAppService = nativeAppService;
_mailService = mailService;
+ _mailCategoryService = mailCategoryService;
_folderService = folderService;
_accountService = accountService;
_contextMenuItemService = contextMenuItemService;
@@ -721,7 +724,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
{
await HandleCreateNewMailAsync();
}
- else if (clickedMenuItem is IBaseFolderMenuItem baseFolderMenuItem && baseFolderMenuItem.HandlingFolders.All(a => a.IsMoveTarget))
+ else if (clickedMenuItem is IBaseFolderMenuItem baseFolderMenuItem &&
+ (clickedMenuItem is IMailCategoryMenuItem or IMergedMailCategoryMenuItem || baseFolderMenuItem.HandlingFolders.All(a => a.IsMoveTarget)))
{
// Don't navigate to base folders that contain non-move target folders.
// Theory: This is a special folder like Categories or More. Don't navigate to it.
@@ -793,11 +797,20 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
{
// Get visible account menu items, ordered by merged accounts at the last.
// We will update the unread counts for all single accounts and trigger UI refresh for merged menu items.
- var accountMenuItems = MenuItems.GetAllAccountMenuItems().OrderBy(a => a.HoldingAccounts.Count());
+ List accountMenuItems = null;
+
+ await ExecuteUIThread(() =>
+ {
+ accountMenuItems = MenuItems
+ .GetAllAccountMenuItems()
+ .OrderBy(a => a.HoldingAccounts.Count())
+ .ToList();
+ });
// Individually get all single accounts' unread counts.
- var accountIds = accountMenuItems.OfType().Select(a => a.AccountId);
+ var accountIds = accountMenuItems.OfType().Select(a => a.AccountId).ToList();
var unreadCountResult = await _folderService.GetUnreadItemCountResultsAsync(accountIds).ConfigureAwait(false);
+ var unreadCategoryCountResult = await _mailCategoryService.GetUnreadCategoryCountResultsAsync(accountIds).ConfigureAwait(false);
// Recursively update all folders' unread counts to 0.
// Query above only returns unread counts that exists. We need to reset the rest to 0 first.
@@ -849,6 +862,29 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
}
}
+ foreach (var unreadCategoryCount in unreadCategoryCountResult)
+ {
+ if (MenuItems.TryGetCategoryMenuItem(unreadCategoryCount.CategoryId, out var categoryMenuItem))
+ {
+ if (categoryMenuItem is IMergedMailCategoryMenuItem mergedCategoryMenuItem)
+ {
+ await ExecuteUIThread(() =>
+ {
+ categoryMenuItem.UnreadItemCount = unreadCategoryCountResult
+ .Where(a => mergedCategoryMenuItem.Categories.Any(b => b.Id == a.CategoryId))
+ .Sum(a => a.UnreadItemCount);
+ });
+ }
+ else
+ {
+ await ExecuteUIThread(() =>
+ {
+ categoryMenuItem.UnreadItemCount = unreadCategoryCount.UnreadItemCount;
+ });
+ }
+ }
+ }
+
// Update unread badge after all unread counts are updated.
await _notificationBuilder.UpdateTaskbarIconBadgeAsync();
}
diff --git a/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs b/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs
new file mode 100644
index 00000000..fb431fbf
--- /dev/null
+++ b/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs
@@ -0,0 +1,251 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Wino.Core.Domain;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.MailItem;
+using Wino.Core.Domain.Models.Navigation;
+using Wino.Core.Domain.Models.Synchronization;
+using Wino.Core.Requests.Category;
+using Wino.Core.Services;
+
+namespace Wino.Mail.ViewModels;
+
+public partial class MailCategoryManagementPageViewModel : MailBaseViewModel
+{
+ private readonly IMailCategoryService _mailCategoryService;
+ private readonly IAccountService _accountService;
+ private readonly IMailDialogService _dialogService;
+ private readonly IWinoRequestDelegator _winoRequestDelegator;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanRefresh))]
+ public partial MailAccount Account { get; set; }
+
+ public ObservableCollection Categories { get; } = [];
+
+ public bool CanRefresh => Account?.ProviderType == MailProviderType.Outlook;
+ public bool HasCategories => Categories.Count > 0;
+
+ public MailCategoryManagementPageViewModel(
+ IMailCategoryService mailCategoryService,
+ IAccountService accountService,
+ IMailDialogService dialogService,
+ IWinoRequestDelegator winoRequestDelegator)
+ {
+ _mailCategoryService = mailCategoryService;
+ _accountService = accountService;
+ _dialogService = dialogService;
+ _winoRequestDelegator = winoRequestDelegator;
+ }
+
+ public override async void OnNavigatedTo(NavigationMode mode, object parameters)
+ {
+ base.OnNavigatedTo(mode, parameters);
+
+ if (parameters is not Guid accountId)
+ return;
+
+ Account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
+
+ if (Account != null)
+ {
+ await LoadCategoriesAsync().ConfigureAwait(false);
+ }
+ }
+
+ [RelayCommand]
+ private Task AddCategoryAsync()
+ => CreateOrUpdateCategoryAsync();
+
+ [RelayCommand]
+ private async Task RefreshCategoriesAsync()
+ {
+ if (!CanRefresh)
+ return;
+
+ var shouldContinue = await _dialogService.ShowConfirmationDialogAsync(
+ Translator.MailCategoryManagementPage_RefreshConfirmationMessage,
+ Translator.Buttons_Refresh,
+ Translator.Buttons_Refresh).ConfigureAwait(false);
+
+ if (!shouldContinue)
+ return;
+
+ await _mailCategoryService.DeleteCategoriesAsync(Account.Id).ConfigureAwait(false);
+ await SynchronizationManager.Instance.SynchronizeCategoriesAsync(Account.Id).ConfigureAwait(false);
+
+ await LoadCategoriesAsync().ConfigureAwait(false);
+ }
+
+ public Task EditCategoryAsync(MailCategory category)
+ => CreateOrUpdateCategoryAsync(category);
+
+ public async Task DeleteCategoryAsync(MailCategory category)
+ {
+ if (category == null)
+ return;
+
+ var shouldDelete = await _dialogService.ShowConfirmationDialogAsync(
+ string.Format(Translator.MailCategoryManagementPage_DeleteConfirmationMessage, category.Name),
+ Translator.MailCategoryManagementPage_DeleteConfirmationTitle,
+ Translator.Buttons_Delete).ConfigureAwait(false);
+
+ if (!shouldDelete)
+ return;
+
+ var deleteRequest = await BuildDeleteCategoryRequestAsync(category).ConfigureAwait(false);
+ await _mailCategoryService.DeleteCategoryAsync(category.Id).ConfigureAwait(false);
+ await QueueOutlookCategoryRequestsAsync(deleteRequest).ConfigureAwait(false);
+ await LoadCategoriesAsync().ConfigureAwait(false);
+ }
+
+ public async Task SetFavoriteAsync(MailCategory category, bool isFavorite)
+ {
+ if (category == null)
+ return;
+
+ await _mailCategoryService.ToggleFavoriteAsync(category.Id, isFavorite).ConfigureAwait(false);
+ await LoadCategoriesAsync().ConfigureAwait(false);
+ }
+
+ private async Task CreateOrUpdateCategoryAsync(MailCategory existingCategory = null)
+ {
+ var dialogResult = await _dialogService.ShowEditMailCategoryDialogAsync(existingCategory).ConfigureAwait(false);
+ if (dialogResult == null)
+ return;
+
+ if (string.IsNullOrWhiteSpace(dialogResult.Name))
+ {
+ await _dialogService.ShowMessageAsync(
+ Translator.MailCategoryDialog_InvalidNameMessage,
+ Translator.MailCategoryDialog_InvalidNameTitle,
+ WinoCustomMessageDialogIcon.Warning).ConfigureAwait(false);
+ return;
+ }
+
+ var normalizedName = dialogResult.Name.Trim();
+ var categoryIdToExclude = existingCategory?.Id;
+ var alreadyExists = await _mailCategoryService.CategoryNameExistsAsync(Account.Id, normalizedName, categoryIdToExclude).ConfigureAwait(false);
+
+ if (alreadyExists)
+ {
+ await _dialogService.ShowMessageAsync(
+ Translator.MailCategoryDialog_DuplicateMessage,
+ Translator.MailCategoryDialog_DuplicateTitle,
+ WinoCustomMessageDialogIcon.Warning).ConfigureAwait(false);
+ return;
+ }
+
+ if (existingCategory == null)
+ {
+ var newCategory = new MailCategory
+ {
+ Id = Guid.NewGuid(),
+ MailAccountId = Account.Id,
+ Name = normalizedName,
+ BackgroundColorHex = dialogResult.BackgroundColorHex,
+ TextColorHex = dialogResult.TextColorHex,
+ Source = Account.ProviderType == MailProviderType.Outlook ? MailCategorySource.Outlook : MailCategorySource.Local
+ };
+
+ await _mailCategoryService.CreateCategoryAsync(newCategory).ConfigureAwait(false);
+
+ if (Account.ProviderType == MailProviderType.Outlook)
+ {
+ await _winoRequestDelegator.ExecuteAsync(Account.Id, [new MailCategoryCreateRequest(newCategory)]).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ var previousName = existingCategory.Name;
+ var previousRemoteId = existingCategory.RemoteId;
+
+ existingCategory.Name = normalizedName;
+ existingCategory.BackgroundColorHex = dialogResult.BackgroundColorHex;
+ existingCategory.TextColorHex = dialogResult.TextColorHex;
+
+ await _mailCategoryService.UpdateCategoryAsync(existingCategory).ConfigureAwait(false);
+
+ if (Account.ProviderType == MailProviderType.Outlook)
+ {
+ if (string.IsNullOrWhiteSpace(previousRemoteId))
+ {
+ await _winoRequestDelegator.ExecuteAsync(Account.Id, [new MailCategoryCreateRequest(existingCategory)]).ConfigureAwait(false);
+ }
+ else
+ {
+ var affectedMessages = await BuildAffectedMessageTargetsAsync(existingCategory.Id).ConfigureAwait(false);
+ var updateRequest = new MailCategoryUpdateRequest(existingCategory, previousName, previousRemoteId, affectedMessages);
+ await _winoRequestDelegator.ExecuteAsync(Account.Id, [updateRequest]).ConfigureAwait(false);
+ }
+ }
+ }
+
+ await LoadCategoriesAsync().ConfigureAwait(false);
+ }
+
+ private async Task BuildDeleteCategoryRequestAsync(MailCategory category)
+ {
+ if (category == null || Account?.ProviderType != MailProviderType.Outlook)
+ return null;
+
+ var mailCopies = await _mailCategoryService.GetMailCopiesForCategoryAsync(category.Id).ConfigureAwait(false);
+ var affectedMessages = new List();
+
+ foreach (var mailCopy in mailCopies.Where(a => !string.IsNullOrWhiteSpace(a.Id)))
+ {
+ var remainingNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailCopy.UniqueId).ConfigureAwait(false);
+ var categoryNames = remainingNames
+ .Where(a => !string.Equals(a, category.Name, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ affectedMessages.Add(new MailCategoryMessageUpdateTarget(mailCopy.Id, categoryNames));
+ }
+
+ return new MailCategoryDeleteRequest(category, category.RemoteId, affectedMessages);
+ }
+
+ private async Task> BuildAffectedMessageTargetsAsync(Guid categoryId)
+ {
+ var mailCopies = await _mailCategoryService.GetMailCopiesForCategoryAsync(categoryId).ConfigureAwait(false);
+ var affectedMessages = new List();
+
+ foreach (var mailCopy in mailCopies.Where(a => !string.IsNullOrWhiteSpace(a.Id)))
+ {
+ var categoryNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailCopy.UniqueId).ConfigureAwait(false);
+ affectedMessages.Add(new MailCategoryMessageUpdateTarget(mailCopy.Id, categoryNames));
+ }
+
+ return affectedMessages;
+ }
+
+ private Task QueueOutlookCategoryRequestsAsync(params IRequestBase[] requests)
+ => Account?.ProviderType == MailProviderType.Outlook && requests.Any(a => a != null)
+ ? _winoRequestDelegator.ExecuteAsync(Account.Id, requests.Where(a => a != null))
+ : Task.CompletedTask;
+
+ private async Task LoadCategoriesAsync()
+ {
+ var categories = await _mailCategoryService.GetCategoriesAsync(Account.Id).ConfigureAwait(false);
+
+ await ExecuteUIThread(() =>
+ {
+ Categories.Clear();
+
+ foreach (var category in categories)
+ {
+ Categories.Add(category);
+ }
+
+ OnPropertyChanged(nameof(HasCategories));
+ });
+ }
+}
diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs
index e0e07540..2631bbc8 100644
--- a/Wino.Mail.ViewModels/MailListPageViewModel.cs
+++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs
@@ -24,6 +24,7 @@ using Wino.Core.Domain.Models.Menus;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Reader;
using Wino.Core.Domain.Models.Synchronization;
+using Wino.Core.Requests.Mail;
using Wino.Core.Services;
using Wino.Mail.ViewModels.Collections;
using Wino.Mail.ViewModels.Data;
@@ -77,6 +78,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
private readonly INotificationBuilder _notificationBuilder;
private readonly IFolderService _folderService;
private readonly IContextMenuItemService _contextMenuItemService;
+ private readonly IMailCategoryService _mailCategoryService;
private readonly IWinoRequestDelegator _winoRequestDelegator;
private readonly IKeyPressService _keyPressService;
private readonly IWinoLogger _winoLogger;
@@ -156,6 +158,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
[NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))]
+ [NotifyPropertyChangedFor(nameof(IsCategoryView))]
+ [NotifyPropertyChangedFor(nameof(IsSyncButtonVisible))]
public partial IBaseFolderMenuItem ActiveFolder { get; set; }
[ObservableProperty]
@@ -172,6 +176,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
INotificationBuilder notificationBuilder,
IFolderService folderService,
IContextMenuItemService contextMenuItemService,
+ IMailCategoryService mailCategoryService,
IWinoRequestDelegator winoRequestDelegator,
IKeyPressService keyPressService,
IPreferencesService preferencesService,
@@ -185,6 +190,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
_mimeFileService = mimeFileService;
_folderService = folderService;
_contextMenuItemService = contextMenuItemService;
+ _mailCategoryService = mailCategoryService;
_winoRequestDelegator = winoRequestDelegator;
_keyPressService = keyPressService;
@@ -277,9 +283,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
}
}
- public bool CanSynchronize => !IsAccountSynchronizerInSynchronization && IsFolderSynchronizationEnabled;
+ public bool CanSynchronize => !IsCategoryView && !IsAccountSynchronizerInSynchronization && IsFolderSynchronizationEnabled;
public bool IsFolderSynchronizationEnabled => ActiveFolder?.IsSynchronizationEnabled ?? false;
public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive;
+ public bool IsCategoryView => ActiveFolder is IMailCategoryMenuItem or IMergedMailCategoryMenuItem;
+ public bool IsSyncButtonVisible => !IsCategoryView;
public string SelectedMessageText => IsDragInProgress
? string.Format(Translator.MailsDragging, DraggingItemsCount)
@@ -396,9 +404,12 @@ public partial class MailListPageViewModel : MailBaseViewModel,
}
else
{
+ if (IsCategoryView)
+ {
+ PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
+ }
// Merged folders don't support focused feature.
-
- if (ActiveFolder is IMergedAccountFolderMenuItem)
+ else if (ActiveFolder is IMergedAccountFolderMenuItem)
{
PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null));
}
@@ -545,7 +556,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
[RelayCommand]
private async Task EnableFolderSynchronizationAsync()
{
- if (ActiveFolder == null) return;
+ if (ActiveFolder == null || IsCategoryView) return;
foreach (var folder in ActiveFolder.HandlingFolders)
{
@@ -561,13 +572,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
Debug.WriteLine("Loading more...");
await ExecuteUIThread(() => { IsInitializingFolder = true; });
- var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
- SelectedFilterOption.Type,
- SelectedSortingOption.Type,
- PreferencesService.IsThreadingEnabled,
- SelectedFolderPivot.IsFocused,
- IsInSearchMode ? SearchQuery : string.Empty,
- MailCollection.MailCopyIdHashSet);
+ var initializationOptions = CreateInitializationOptions(
+ IsInSearchMode ? SearchQuery : string.Empty,
+ MailCollection.MailCopyIdHashSet);
var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false);
@@ -674,6 +681,60 @@ public partial class MailListPageViewModel : MailBaseViewModel,
public IEnumerable GetAvailableMailActions(IEnumerable contextMailItems)
=> _contextMenuItemService.GetMailItemContextMenuActions(contextMailItems.Select(a => a.MailCopy));
+ public async Task<(IReadOnlyList Categories, IReadOnlyCollection AssignedCategoryIds)> GetAvailableCategoriesAsync(IEnumerable targetItems)
+ {
+ var targetList = targetItems?.Where(a => a?.MailCopy?.AssignedAccount != null).ToList() ?? [];
+ if (targetList.Count == 0)
+ return ([], []);
+
+ var accountIds = targetList.Select(a => a.MailCopy.AssignedAccount.Id).Distinct().ToList();
+ if (accountIds.Count != 1)
+ return ([], []);
+
+ var accountId = accountIds[0];
+ var uniqueIds = targetList.Select(a => a.MailCopy.UniqueId).Distinct().ToList();
+
+ var categories = await _mailCategoryService.GetCategoriesAsync(accountId).ConfigureAwait(false);
+ var assignedCategoryIds = await _mailCategoryService.GetAssignedCategoryIdsForAllAsync(uniqueIds).ConfigureAwait(false);
+
+ return (categories, assignedCategoryIds);
+ }
+
+ public async Task ToggleCategoryAssignmentAsync(MailCategory category, IEnumerable targetItems, bool isAssignedToAll)
+ {
+ var targetList = targetItems?.Where(a => a?.MailCopy?.AssignedAccount != null).ToList() ?? [];
+ if (category == null || targetList.Count == 0)
+ return;
+
+ var accountIds = targetList.Select(a => a.MailCopy.AssignedAccount.Id).Distinct().ToList();
+ if (accountIds.Count != 1)
+ return;
+
+ var accountId = accountIds[0];
+ var uniqueIds = targetList.Select(a => a.MailCopy.UniqueId).Distinct().ToList();
+
+ if (isAssignedToAll)
+ {
+ await _mailCategoryService.UnassignCategoryAsync(category.Id, uniqueIds).ConfigureAwait(false);
+ }
+ else
+ {
+ await _mailCategoryService.AssignCategoryAsync(category.Id, uniqueIds).ConfigureAwait(false);
+ }
+
+ if (targetList.First().MailCopy.AssignedAccount.ProviderType != MailProviderType.Outlook)
+ return;
+
+ var requests = new List();
+ foreach (var mailItem in targetList.Select(a => a.MailCopy).DistinctBy(a => a.UniqueId))
+ {
+ var categoryNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailItem.UniqueId).ConfigureAwait(false);
+ requests.Add(new MailCategoryAssignmentRequest(mailItem, category.Id, category.Name, categoryNames, !isAssignedToAll));
+ }
+
+ await _winoRequestDelegator.ExecuteAsync(accountId, requests).ConfigureAwait(false);
+ }
+
private bool ShouldPreventItemAdd(MailCopy mailItem)
{
bool condition = mailItem.IsRead
@@ -691,7 +752,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
=> ActiveFolder?.SpecialFolderType == SpecialFolderType.Draft;
private bool BelongsToActiveFolder(MailCopy mailItem)
- => mailItem?.AssignedFolder != null && ActiveFolder?.HandlingFolders?.Any(a => a.Id == mailItem.AssignedFolder.Id) == true;
+ => !IsCategoryView && mailItem?.AssignedFolder != null && ActiveFolder?.HandlingFolders?.Any(a => a.Id == mailItem.AssignedFolder.Id) == true;
private bool ShouldIncludeByThread(MailCopy mailItem)
=> PreferencesService.IsThreadingEnabled
@@ -1069,6 +1130,38 @@ public partial class MailListPageViewModel : MailBaseViewModel,
}
}
+ private MailListInitializationOptions CreateInitializationOptions(
+ string searchQuery,
+ System.Collections.Concurrent.ConcurrentDictionary existingUniqueIds,
+ List preFetchedMailCopies = null,
+ bool deduplicateByServerId = false)
+ {
+ var options = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
+ SelectedFilterOption.Type,
+ SelectedSortingOption.Type,
+ PreferencesService.IsThreadingEnabled,
+ SelectedFolderPivot.IsFocused,
+ searchQuery,
+ existingUniqueIds,
+ preFetchedMailCopies,
+ DeduplicateByServerId: deduplicateByServerId);
+
+ if (!IsCategoryView)
+ return options;
+
+ var categoryIds = ActiveFolder switch
+ {
+ IMailCategoryMenuItem singleCategoryMenuItem => new List { singleCategoryMenuItem.MailCategory.Id },
+ IMergedMailCategoryMenuItem mergedCategoryMenuItem => mergedCategoryMenuItem.Categories.Select(a => a.Id).ToList(),
+ _ => []
+ };
+
+ return options with
+ {
+ CategoryIds = categoryIds
+ };
+ }
+
[RelayCommand]
private async Task PerformOnlineSearchAsync()
{
@@ -1218,15 +1311,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
}
}
- var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
- SelectedFilterOption.Type,
- SelectedSortingOption.Type,
- PreferencesService.IsThreadingEnabled,
- SelectedFolderPivot.IsFocused,
- isDoingOnlineSearch ? string.Empty : SearchQuery,
- MailCollection.MailCopyIdHashSet,
- onlineSearchItems,
- DeduplicateByServerId: isDoingOnlineSearch);
+ var initializationOptions = CreateInitializationOptions(
+ isDoingOnlineSearch ? string.Empty : SearchQuery,
+ MailCollection.MailCopyIdHashSet,
+ onlineSearchItems,
+ isDoingOnlineSearch);
items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false);
diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs
index 4df1e294..b3b7189a 100644
--- a/Wino.Mail.WinUI/App.xaml.cs
+++ b/Wino.Mail.WinUI/App.xaml.cs
@@ -364,6 +364,7 @@ public partial class App : WinoApplication,
services.AddTransient(typeof(StoragePageViewModel));
services.AddTransient(typeof(WinoAccountManagementPageViewModel));
services.AddTransient(typeof(AliasManagementPageViewModel));
+ services.AddTransient(typeof(MailCategoryManagementPageViewModel));
services.AddTransient(typeof(ContactsPageViewModel));
services.AddTransient(typeof(SignatureAndEncryptionPageViewModel));
services.AddTransient(typeof(EmailTemplatesPageViewModel));
@@ -1321,6 +1322,7 @@ public partial class App : WinoApplication,
return synchronizationType switch
{
MailSynchronizationType.Alias => Translator.Exception_FailedToSynchronizeAliases,
+ MailSynchronizationType.Categories => Translator.Exception_FailedToSynchronizeCategories,
MailSynchronizationType.UpdateProfile => Translator.Exception_FailedToSynchronizeProfileInformation,
_ => Translator.Exception_FailedToSynchronizeFolders
};
diff --git a/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml b/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml
new file mode 100644
index 00000000..d14d242d
--- /dev/null
+++ b/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml
@@ -0,0 +1,60 @@
+
+
+
+ 520
+ 520
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml.cs
new file mode 100644
index 00000000..074ac9fd
--- /dev/null
+++ b/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml.cs
@@ -0,0 +1,59 @@
+using System.Linq;
+using Microsoft.UI.Xaml.Controls;
+using Wino.Core.Domain;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Models.MailItem;
+
+namespace Wino.Dialogs;
+
+public sealed partial class EditMailCategoryDialog : ContentDialog
+{
+ public MailCategoryDialogResult? Result { get; private set; }
+ public string CategoryName { get; set; }
+ public MailCategoryColorOption? SelectedColor { get; set; }
+
+ public System.Collections.Generic.IReadOnlyList AvailableColors => MailCategoryPalette.DefaultOptions;
+
+ public EditMailCategoryDialog(MailCategory? category = null)
+ {
+ InitializeComponent();
+
+ Title = category == null ? Translator.MailCategoryDialog_CreateTitle : Translator.MailCategoryDialog_EditTitle;
+ CategoryName = category?.Name ?? string.Empty;
+ SelectedColor = MailCategoryPalette.DefaultOptions.FirstOrDefault(a =>
+ a.BackgroundColorHex == category?.BackgroundColorHex &&
+ a.TextColorHex == category?.TextColorHex) ?? MailCategoryPalette.DefaultOptions.First();
+
+ IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(CategoryName);
+ }
+
+ private void CategoryNameTextChanged(object sender, TextChangedEventArgs e)
+ => IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(CategoryNameTextBox.Text) && SelectedColor != null;
+
+ private void ColorOptionClicked(object sender, ItemClickEventArgs e)
+ {
+ if (e.ClickedItem is MailCategoryColorOption option)
+ {
+ SelectedColor = option;
+ ColorsGridView.SelectedItem = option;
+ IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(CategoryNameTextBox.Text);
+ }
+ }
+
+ private void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ if (SelectedColor == null)
+ {
+ args.Cancel = true;
+ return;
+ }
+
+ Result = new MailCategoryDialogResult(CategoryNameTextBox.Text?.Trim(), SelectedColor.BackgroundColorHex, SelectedColor.TextColorHex);
+ Hide();
+ }
+
+ private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ Hide();
+ }
+}
diff --git a/Wino.Mail.WinUI/Selectors/NavigationMenuTemplateSelector.cs b/Wino.Mail.WinUI/Selectors/NavigationMenuTemplateSelector.cs
index 339fd155..6bfa1bbf 100644
--- a/Wino.Mail.WinUI/Selectors/NavigationMenuTemplateSelector.cs
+++ b/Wino.Mail.WinUI/Selectors/NavigationMenuTemplateSelector.cs
@@ -26,6 +26,7 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector
public DataTemplate NewMailTemplate { get; set; } = null!;
public DataTemplate CalendarNewEventTemplate { get; set; } = null!;
public DataTemplate CategoryItemsTemplate { get; set; } = null!;
+ public DataTemplate MergedCategoryItemsTemplate { get; set; } = null!;
public DataTemplate FixAuthenticationIssueTemplate { get; set; } = null!;
public DataTemplate FixMissingFolderConfigTemplate { get; set; } = null!;
@@ -58,6 +59,10 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector
return MergedAccountTemplate;
else if (item is MergedAccountMoreFolderMenuItem)
return MergedAccountMoreExpansionItemTemplate;
+ else if (item is MailCategoryMenuItem)
+ return CategoryItemsTemplate;
+ else if (item is MergedMailCategoryMenuItem)
+ return MergedCategoryItemsTemplate;
else if (item is MergedAccountFolderMenuItem)
return MergedAccountFolderTemplate;
else if (item is FolderMenuItem)
diff --git a/Wino.Mail.WinUI/Services/DialogService.cs b/Wino.Mail.WinUI/Services/DialogService.cs
index 37a70557..b287ab38 100644
--- a/Wino.Mail.WinUI/Services/DialogService.cs
+++ b/Wino.Mail.WinUI/Services/DialogService.cs
@@ -6,7 +6,6 @@ using CommunityToolkit.Mvvm.Messaging;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain;
-using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
@@ -15,11 +14,12 @@ using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Folders;
+using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
-using Wino.Mail.WinUI.Extensions;
-using Wino.Mail.WinUI.Services;
using Wino.Dialogs;
using Wino.Mail.Dialogs;
+using Wino.Mail.WinUI.Extensions;
+using Wino.Mail.WinUI.Services;
using Wino.Messaging.Server;
using Wino.Messaging.UI;
@@ -52,6 +52,19 @@ public class DialogService : DialogServiceBase, IMailDialogService
return createAccountAliasDialog;
}
+#pragma warning disable CS8625
+ public async Task ShowEditMailCategoryDialogAsync(MailCategory category = null)
+#pragma warning restore CS8625
+ {
+ var dialog = new EditMailCategoryDialog(category)
+ {
+ RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme()
+ };
+
+ await HandleDialogPresentationAsync(dialog);
+ return dialog.Result;
+ }
+
public async Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService)
{
try
diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs
index 7c4688ed..cdf45cca 100644
--- a/Wino.Mail.WinUI/Services/NavigationService.cs
+++ b/Wino.Mail.WinUI/Services/NavigationService.cs
@@ -82,6 +82,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.ReadComposePanePage,
WinoPage.AppPreferencesPage,
WinoPage.AliasManagementPage,
+ WinoPage.MailCategoryManagementPage,
WinoPage.ImapCalDavSettingsPage,
WinoPage.KeyboardShortcutsPage,
WinoPage.SignatureAndEncryptionPage,
@@ -150,6 +151,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.SettingOptionsPage => typeof(SettingOptionsPage),
WinoPage.AppPreferencesPage => typeof(AppPreferencesPage),
WinoPage.AliasManagementPage => typeof(AliasManagementPage),
+ WinoPage.MailCategoryManagementPage => typeof(MailCategoryManagementPage),
WinoPage.ImapCalDavSettingsPage => typeof(ImapCalDavSettingsPage),
WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage),
WinoPage.ContactsPage => typeof(ContactsPage),
diff --git a/Wino.Mail.WinUI/Views/Abstract/MailCategoryManagementPageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/MailCategoryManagementPageAbstract.cs
new file mode 100644
index 00000000..ebf45d57
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Abstract/MailCategoryManagementPageAbstract.cs
@@ -0,0 +1,6 @@
+using Wino.Mail.ViewModels;
+using Wino.Mail.WinUI;
+
+namespace Wino.Views.Abstract;
+
+public abstract class MailCategoryManagementPageAbstract : BasePage { }
diff --git a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml
index b0572554..5c460e21 100644
--- a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml
+++ b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml
@@ -228,6 +228,16 @@
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml
index 16283c00..a506237f 100644
--- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml
+++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml
@@ -253,7 +253,8 @@
BorderThickness="0"
Command="{x:Bind ViewModel.SyncFolderCommand}"
IsEnabled="{x:Bind ViewModel.CanSynchronize, Mode=OneWay}"
- ToolTipService.ToolTip="{x:Bind domain:Translator.Buttons_Sync}">
+ ToolTipService.ToolTip="{x:Bind domain:Translator.Buttons_Sync}"
+ Visibility="{x:Bind ViewModel.IsSyncButtonVisible, Mode=OneWay}">
a.MailCopy));
+ if (clickedAction.Category != null)
+ {
+ await ViewModel.ToggleCategoryAssignmentAsync(clickedAction.Category, targetItems, clickedAction.IsCategoryAssignedToAll);
+ return;
+ }
+
+ var prepRequest = new MailOperationPreperationRequest(clickedAction.Operation.Operation, targetItems.Select(a => a.MailCopy));
await ViewModel.ExecuteMailOperationAsync(prepRequest);
}
@@ -296,14 +309,68 @@ public sealed partial class MailListPage : MailListPageAbstract,
});
}
- private async Task GetMailOperationFromFlyoutAsync(IEnumerable availableActions,
- UIElement showAtElement,
- double x,
- double y)
+ private async Task GetMailContextActionFromFlyoutAsync(
+ IEnumerable availableActions,
+ IReadOnlyList availableCategories,
+ IReadOnlyCollection assignedCategoryIds,
+ UIElement showAtElement,
+ double x,
+ double y)
{
- var source = new TaskCompletionSource();
+ var source = new TaskCompletionSource();
+ var flyout = new MenuFlyout();
- var flyout = new MailOperationFlyout(availableActions, source);
+ foreach (var action in availableActions)
+ {
+ if (action.Operation == MailOperation.Seperator)
+ {
+ flyout.Items.Add(new MenuFlyoutSeparator());
+ continue;
+ }
+
+ var menuFlyoutItem = new MailOperationMenuFlyoutItem(action, clicked =>
+ {
+ source.TrySetResult(new MailContextAction(clicked));
+ flyout.Hide();
+ });
+
+ flyout.Items.Add(menuFlyoutItem);
+ }
+
+ if (availableCategories?.Count > 0)
+ {
+ if (flyout.Items.LastOrDefault() is not MenuFlyoutSeparator)
+ {
+ flyout.Items.Add(new MenuFlyoutSeparator());
+ }
+
+ var categorySubItem = new MenuFlyoutSubItem
+ {
+ Text = Translator.MailCategoryMenuItem
+ };
+
+ foreach (var category in availableCategories)
+ {
+ var wasAssignedToAll = assignedCategoryIds.Contains(category.Id);
+ var categoryItem = new ToggleMenuFlyoutItem
+ {
+ Text = category.Name,
+ IsChecked = wasAssignedToAll
+ };
+
+ categoryItem.Click += (_, _) =>
+ {
+ source.TrySetResult(new MailContextAction(category, wasAssignedToAll));
+ flyout.Hide();
+ };
+
+ categorySubItem.Items.Add(categoryItem);
+ }
+
+ flyout.Items.Add(categorySubItem);
+ }
+
+ flyout.Closing += (_, _) => source.TrySetResult(null);
flyout.ShowAt(showAtElement, new FlyoutShowOptions()
{
@@ -314,6 +381,13 @@ public sealed partial class MailListPage : MailListPageAbstract,
return await source.Task;
}
+ private sealed record MailContextAction(MailOperationMenuItem Operation, MailCategory Category = null, bool IsCategoryAssignedToAll = false)
+ {
+ public MailContextAction(MailCategory category, bool isCategoryAssignedToAll) : this(null, category, isCategoryAssignedToAll)
+ {
+ }
+ }
+
async void IRecipient.Receive(ClearMailSelectionsRequested message)
{
await ViewModel.MailCollection.UnselectAllAsync();
diff --git a/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml b/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml
new file mode 100644
index 00000000..375a9e0c
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml.cs b/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml.cs
new file mode 100644
index 00000000..4bc6fb5b
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml.cs
@@ -0,0 +1,47 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Views.Abstract;
+
+namespace Wino.Views.Settings;
+
+public sealed partial class MailCategoryManagementPage : MailCategoryManagementPageAbstract
+{
+ public MailCategoryManagementPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void FavoriteCategoryChecked(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleButton toggleButton && toggleButton.Tag is MailCategory category)
+ {
+ await ViewModel.SetFavoriteAsync(category, true);
+ }
+ }
+
+ private async void FavoriteCategoryUnchecked(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleButton toggleButton && toggleButton.Tag is MailCategory category)
+ {
+ await ViewModel.SetFavoriteAsync(category, false);
+ }
+ }
+
+ private async void EditCategoryClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.Tag is MailCategory category)
+ {
+ await ViewModel.EditCategoryAsync(category);
+ }
+ }
+
+ private async void DeleteCategoryClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.Tag is MailCategory category)
+ {
+ await ViewModel.DeleteCategoryAsync(category);
+ }
+ }
+}
diff --git a/Wino.Mail.WinUI/Views/WinoAppShell.xaml b/Wino.Mail.WinUI/Views/WinoAppShell.xaml
index a6c759be..d20c551c 100644
--- a/Wino.Mail.WinUI/Views/WinoAppShell.xaml
+++ b/Wino.Mail.WinUI/Views/WinoAppShell.xaml
@@ -240,6 +240,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
Connection.CreateTableAsync(),
Connection.CreateTableAsync(),
Connection.CreateTableAsync(),
@@ -219,6 +221,12 @@ SET {nameof(KeyboardShortcut.Action)} =
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_MessageId ON MailCopy(MessageId)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_FolderId_IsRead ON MailCopy(FolderId, IsRead)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_CreationDate ON MailCopy(CreationDate)").ConfigureAwait(false);
+ await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategory_MailAccountId ON MailCategory(MailAccountId)").ConfigureAwait(false);
+ await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategory_MailAccountId_Name ON MailCategory(MailAccountId, Name)").ConfigureAwait(false);
+ await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategory_MailAccountId_IsFavorite ON MailCategory(MailAccountId, IsFavorite)").ConfigureAwait(false);
+ await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategoryAssignment_MailCategoryId ON MailCategoryAssignment(MailCategoryId)").ConfigureAwait(false);
+ await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategoryAssignment_MailCopyUniqueId ON MailCategoryAssignment(MailCopyUniqueId)").ConfigureAwait(false);
+ await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MailCategoryAssignment_Category_MailCopy ON MailCategoryAssignment(MailCategoryId, MailCopyUniqueId)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailItemFolder_MailAccountId ON MailItemFolder(MailAccountId)").ConfigureAwait(false);
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailItemFolder_MailAccountId_RemoteFolderId ON MailItemFolder(MailAccountId, RemoteFolderId)").ConfigureAwait(false);
diff --git a/Wino.Services/FolderService.cs b/Wino.Services/FolderService.cs
index 2728377e..dd90facf 100644
--- a/Wino.Services/FolderService.cs
+++ b/Wino.Services/FolderService.cs
@@ -22,6 +22,7 @@ namespace Wino.Services;
public class FolderService : BaseDatabaseService, IFolderService
{
private readonly IAccountService _accountService;
+ private readonly IMailCategoryService _mailCategoryService;
private readonly ILogger _logger = Log.ForContext();
private readonly SpecialFolderType[] gmailCategoryFolderTypes =
@@ -34,9 +35,11 @@ public class FolderService : BaseDatabaseService, IFolderService
];
public FolderService(IDatabaseService databaseService,
- IAccountService accountService) : base(databaseService)
+ IAccountService accountService,
+ IMailCategoryService mailCategoryService) : base(databaseService)
{
_accountService = accountService;
+ _mailCategoryService = mailCategoryService;
}
public async Task ChangeStickyStatusAsync(Guid folderId, bool isSticky)
@@ -269,6 +272,9 @@ public class FolderService : BaseDatabaseService, IFolderService
}
}
+ var favoriteCategories = await GetFavoriteCategoryMenuItemsAsync(mailAccount, folders, accountMenuItem).ConfigureAwait(false);
+ preparedFolderMenuItems.AddRange(favoriteCategories);
+
// Only add category folder if it's Gmail.
if (mailAccount.ProviderType == MailProviderType.Gmail) preparedFolderMenuItems.Add(categoryFolderMenuItem);
@@ -309,9 +315,62 @@ public class FolderService : BaseDatabaseService, IFolderService
preparedFolderMenuItems.Add(menuItem);
}
+ var favoriteCategories = await GetMergedFavoriteCategoryMenuItemsAsync(holdingAccounts, allAccountFolders, mergedAccountFolderMenuItem.Parameter).ConfigureAwait(false);
+ preparedFolderMenuItems.AddRange(favoriteCategories);
+
return preparedFolderMenuItems;
}
+ private async Task> GetFavoriteCategoryMenuItemsAsync(MailAccount account, IEnumerable handlingFolders, IMenuItem parentMenuItem)
+ {
+ var favoriteCategories = await _mailCategoryService.GetFavoriteCategoriesAsync(account.Id).ConfigureAwait(false);
+
+ if (!favoriteCategories.Any())
+ return [];
+
+ var availableFolders = handlingFolders
+ .Where(a => a.IsMoveTarget)
+ .Cast()
+ .ToList();
+
+ return favoriteCategories
+ .Select(category => (IMenuItem)new MailCategoryMenuItem(category, account, availableFolders, parentMenuItem))
+ .ToList();
+ }
+
+ private async Task> GetMergedFavoriteCategoryMenuItemsAsync(IEnumerable holdingAccounts, IEnumerable> allAccountFolders, MergedInbox mergedInbox)
+ {
+ var categoriesByAccount = new List<(MailAccount Account, List Categories)>();
+
+ foreach (var account in holdingAccounts)
+ {
+ var categories = await _mailCategoryService.GetFavoriteCategoriesAsync(account.Id).ConfigureAwait(false);
+ if (categories.Any())
+ {
+ categoriesByAccount.Add((account, categories));
+ }
+ }
+
+ if (!categoriesByAccount.Any())
+ return [];
+
+ var handlingFolders = allAccountFolders
+ .SelectMany(a => a)
+ .Where(a => a.IsMoveTarget)
+ .Cast()
+ .ToList();
+
+ return categoriesByAccount
+ .SelectMany(a => a.Categories)
+ .GroupBy(a => NormalizeCategoryName(a.Name), StringComparer.OrdinalIgnoreCase)
+ .Select(group => (IMenuItem)new MergedMailCategoryMenuItem(group.ToList(), handlingFolders, mergedInbox))
+ .OrderBy(item => ((MergedMailCategoryMenuItem)item).FolderName, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+ }
+
+ private static string NormalizeCategoryName(string name)
+ => name?.Trim() ?? string.Empty;
+
private HashSet FindCommonFolders(List> lists)
{
var allSpecialTypesExceptOther = Enum.GetValues().Cast().Where(a => a != SpecialFolderType.Other).ToList();
diff --git a/Wino.Services/MailCategoryService.cs b/Wino.Services/MailCategoryService.cs
new file mode 100644
index 00000000..0d6ad97b
--- /dev/null
+++ b/Wino.Services/MailCategoryService.cs
@@ -0,0 +1,358 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.Messaging;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Accounts;
+using Wino.Messaging.Client.Accounts;
+using Wino.Messaging.UI;
+
+namespace Wino.Services;
+
+public class MailCategoryService : BaseDatabaseService, IMailCategoryService
+{
+ public MailCategoryService(IDatabaseService databaseService) : base(databaseService)
+ {
+ }
+
+ public Task> GetCategoriesAsync(Guid accountId)
+ => Connection.QueryAsync(
+ $"SELECT * FROM {nameof(MailCategory)} WHERE {nameof(MailCategory.MailAccountId)} = ? ORDER BY {nameof(MailCategory.IsFavorite)} DESC, {nameof(MailCategory.Name)} COLLATE NOCASE",
+ accountId);
+
+ public Task> GetFavoriteCategoriesAsync(Guid accountId)
+ => Connection.QueryAsync(
+ $"SELECT * FROM {nameof(MailCategory)} WHERE {nameof(MailCategory.MailAccountId)} = ? AND {nameof(MailCategory.IsFavorite)} = 1 ORDER BY {nameof(MailCategory.Name)} COLLATE NOCASE",
+ accountId);
+
+ public Task GetCategoryAsync(Guid categoryId)
+ => Connection.FindAsync(categoryId);
+
+ public async Task CategoryNameExistsAsync(Guid accountId, string name, Guid? excludedCategoryId = null)
+ {
+ var normalizedName = NormalizeCategoryName(name);
+ if (string.IsNullOrWhiteSpace(normalizedName))
+ return false;
+
+ var sql = $"SELECT COUNT(*) FROM {nameof(MailCategory)} WHERE {nameof(MailCategory.MailAccountId)} = ? AND lower(trim({nameof(MailCategory.Name)})) = ?";
+ var parameters = new List