diff --git a/Wino.Core.Domain/Enums/MailOperation.cs b/Wino.Core.Domain/Enums/MailOperation.cs index 3e6f285f..9d482958 100644 --- a/Wino.Core.Domain/Enums/MailOperation.cs +++ b/Wino.Core.Domain/Enums/MailOperation.cs @@ -19,6 +19,8 @@ public enum FolderSynchronizerOperation RenameFolder, EmptyFolder, MarkFolderRead, + DeleteFolder, + CreateSubFolder, } public enum CalendarSynchronizerOperation diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs index 3e34cb73..56bc5ce3 100644 --- a/Wino.Core.Domain/Enums/WinoPage.cs +++ b/Wino.Core.Domain/Enums/WinoPage.cs @@ -32,5 +32,6 @@ public enum WinoPage CalendarSettingsPage, CalendarAccountSettingsPage, EventDetailsPage, - SignatureAndEncryptionPage + SignatureAndEncryptionPage, + StoragePage } diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs index 317edbc0..ced42c2e 100644 --- a/Wino.Core.Domain/Interfaces/IMailService.cs +++ b/Wino.Core.Domain/Interfaces/IMailService.cs @@ -170,4 +170,9 @@ public interface IMailService /// Folder ID. /// Number of recent mails to return. Task> GetRecentMailIdsForFolderAsync(Guid folderId, int count); + + /// + /// Returns all mail copies for the account created before the given UTC date. + /// + Task> GetMailCopiesBeforeDateAsync(Guid accountId, DateTime cutoffDateUtc); } diff --git a/Wino.Core.Domain/Interfaces/IMimeStorageService.cs b/Wino.Core.Domain/Interfaces/IMimeStorageService.cs new file mode 100644 index 00000000..9c67ebea --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IMimeStorageService.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Wino.Core.Domain.Interfaces; + +public interface IMimeStorageService +{ + Task GetMimeRootPathAsync(); + Task> GetAccountsMimeStorageSizesAsync(IEnumerable accountIds); + Task DeleteAccountMimeStorageAsync(Guid accountId); + Task DeleteAccountMimeStorageOlderThanAsync(Guid accountId, DateTime cutoffDateUtc); +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 4ba0ed11..b93d0b56 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -739,6 +739,33 @@ "SettingsStartupItem_Title": "Startup Item", "SettingsStore_Description": "Show some love ❤️", "SettingsStore_Title": "Rate in Store", + "SettingsStorage_Title": "Storage", + "SettingsStorage_Description": "Scan and manage MIME cache stored in your local data folder.", + "SettingsStorage_ScanFolder": "Scan local data folder", + "SettingsStorage_NoLocalMimeDataFound": "No local MIME data found.", + "SettingsStorage_NoAccountsFound": "No accounts found.", + "SettingsStorage_TotalUsage": "Total local MIME usage: {0}", + "SettingsStorage_AccountUsageDescription": "{0} used in local MIME cache", + "SettingsStorage_DeleteAll_Title": "Delete all MIME content", + "SettingsStorage_DeleteAll_Description": "Delete this account's entire MIME cache folder.", + "SettingsStorage_DeleteAll_Button": "Delete all", + "SettingsStorage_DeleteAll_Confirm_Title": "Delete all MIME content", + "SettingsStorage_DeleteAll_Confirm_Message": "Delete all local MIME data for {0}?", + "SettingsStorage_DeleteAll_Success": "All MIME content was deleted.", + "SettingsStorage_DeleteOld_Title": "Delete old MIME content", + "SettingsStorage_DeleteOld_Description": "Delete MIME files based on mail creation date in local database.", + "SettingsStorage_DeleteOld_1Month": "> 1 month", + "SettingsStorage_DeleteOld_3Months": "> 3 months", + "SettingsStorage_DeleteOld_6Months": "> 6 months", + "SettingsStorage_DeleteOld_1Year": "> 1 year", + "SettingsStorage_DeleteOld_Confirm_Title": "Delete old MIME content", + "SettingsStorage_DeleteOld_Confirm_Message": "Delete local MIME data older than {0} for {1}?", + "SettingsStorage_DeleteOld_Success": "Deleted {0} MIME folder(s) older than {1}.", + "SettingsStorage_1Month": "1 month", + "SettingsStorage_3Months": "3 months", + "SettingsStorage_6Months": "6 months", + "SettingsStorage_1Year": "1 year", + "SettingsStorage_Months": "{0} months", "SettingsTaskbarBadge_Description": "Include unread mail count in taskbar icon.", "SettingsTaskbarBadge_Title": "Taskbar Badge", "SettingsThreads_Description": "Organize messages into conversation threads.", diff --git a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs index 97cdf782..df4b5ce4 100644 --- a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs +++ b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs @@ -34,6 +34,7 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel WinoPage.CalendarSettingsPage => Translator.SettingsCalendarSettings_Title, WinoPage.SignatureAndEncryptionPage => Translator.SettingsSignatureAndEncryption_Title, WinoPage.KeyboardShortcutsPage => Translator.Settings_KeyboardShortcuts_Title, + WinoPage.StoragePage => Translator.SettingsStorage_Title, _ => throw new NotImplementedException() }; diff --git a/Wino.Core/Helpers/SynchronizationActionHelper.cs b/Wino.Core/Helpers/SynchronizationActionHelper.cs index 87985236..e431b512 100644 --- a/Wino.Core/Helpers/SynchronizationActionHelper.cs +++ b/Wino.Core/Helpers/SynchronizationActionHelper.cs @@ -102,6 +102,8 @@ public static class SynchronizationActionHelper RenameFolderRequest => Translator.SyncAction_RenamingFolder, EmptyFolderRequest => Translator.SyncAction_EmptyingFolder, MarkFolderAsReadRequest => Translator.SyncAction_MarkingFolderAsRead, + DeleteFolderRequest => Translator.FolderOperation_Delete, + CreateSubFolderRequest => Translator.FolderOperation_CreateSubFolder, _ => null }; } diff --git a/Wino.Core/Requests/Folder/CreateSubFolderRequest.cs b/Wino.Core/Requests/Folder/CreateSubFolderRequest.cs new file mode 100644 index 00000000..a655cf49 --- /dev/null +++ b/Wino.Core/Requests/Folder/CreateSubFolderRequest.cs @@ -0,0 +1,11 @@ +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; + +namespace Wino.Core.Requests.Folder; + +public record CreateSubFolderRequest(MailItemFolder Folder, string NewFolderName) : FolderRequestBase(Folder, FolderSynchronizerOperation.CreateSubFolder) +{ + public override void ApplyUIChanges() { } + public override void RevertUIChanges() { } +} diff --git a/Wino.Core/Requests/Folder/DeleteFolderRequest.cs b/Wino.Core/Requests/Folder/DeleteFolderRequest.cs new file mode 100644 index 00000000..d865bee1 --- /dev/null +++ b/Wino.Core/Requests/Folder/DeleteFolderRequest.cs @@ -0,0 +1,11 @@ +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; + +namespace Wino.Core.Requests.Folder; + +public record DeleteFolderRequest(MailItemFolder Folder) : FolderRequestBase(Folder, FolderSynchronizerOperation.DeleteFolder) +{ + public override void ApplyUIChanges() { } + public override void RevertUIChanges() { } +} diff --git a/Wino.Core/Services/WinoRequestDelegator.cs b/Wino.Core/Services/WinoRequestDelegator.cs index 35ca5edc..4881383d 100644 --- a/Wino.Core/Services/WinoRequestDelegator.cs +++ b/Wino.Core/Services/WinoRequestDelegator.cs @@ -130,6 +130,11 @@ public class WinoRequestDelegator : IWinoRequestDelegator await QueueRequestAsync(request, accountId); await SendSyncActionsAddedAsync([request], accountId); await QueueSynchronizationAsync(accountId); + + if (folderRequest.Action is FolderOperation.Delete or FolderOperation.CreateSubFolder) + { + await QueueFoldersOnlySynchronizationAsync(accountId); + } } public async Task ExecuteAsync(DraftPreparationRequest draftPreperationRequest) @@ -203,6 +208,18 @@ public class WinoRequestDelegator : IWinoRequestDelegator return Task.CompletedTask; } + private Task QueueFoldersOnlySynchronizationAsync(Guid accountId) + { + var options = new MailSynchronizationOptions() + { + AccountId = accountId, + Type = MailSynchronizationType.FoldersOnly + }; + + WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options)); + return Task.CompletedTask; + } + private async Task SendSyncActionsAddedAsync(IEnumerable requests, Guid accountId, string accountName = null) { if (accountName == null) diff --git a/Wino.Core/Services/WinoRequestProcessor.cs b/Wino.Core/Services/WinoRequestProcessor.cs index 04f1fc17..87af29a0 100644 --- a/Wino.Core/Services/WinoRequestProcessor.cs +++ b/Wino.Core/Services/WinoRequestProcessor.cs @@ -255,15 +255,29 @@ public class WinoRequestProcessor : IWinoRequestProcessor change = new MarkFolderAsReadRequest(folder, unreadItems); break; - //case FolderOperation.Delete: - // var isConfirmed = await _dialogService.ShowConfirmationDialogAsync($"'{folderStructure.FolderName}' is going to be deleted. Do you want to continue?", "Are you sure?", "Yes delete."); + case FolderOperation.Delete: + var deleteQuestion = string.Format(Translator.DialogMessage_DeleteAccountConfirmationMessage, folder.FolderName); + var shouldDelete = await _dialogService.ShowConfirmationDialogAsync(deleteQuestion, Translator.FolderOperation_Delete, Translator.FolderOperation_Delete); - // if (isConfirmed) - // change = new DeleteFolderRequest(accountId, folderStructure.RemoteFolderId, folderStructure.FolderId); + if (shouldDelete) + { + change = new DeleteFolderRequest(folder); + } - // break; - //default: - // throw new NotImplementedException(); + break; + case FolderOperation.CreateSubFolder: + var subFolderName = await _dialogService.ShowTextInputDialogAsync( + string.Empty, + Translator.FolderOperation_CreateSubFolder, + Translator.DialogMessage_RenameFolderMessage, + Translator.FolderOperation_CreateSubFolder); + + if (!string.IsNullOrWhiteSpace(subFolderName)) + { + change = new CreateSubFolderRequest(folder, subFolderName.Trim()); + } + + break; } return change; diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 43bd7938..26eadbd4 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -1253,40 +1253,16 @@ public class GmailSynchronizer : WinoSynchronizer> MarkFolderAsRead(MarkFolderAsReadRequest request) => MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true)))); + public override List> DeleteFolder(DeleteFolderRequest request) + { + var networkCall = _gmailService.Users.Labels.Delete("me", request.Folder.RemoteFolderId); + return [new HttpRequestBundle(networkCall, request, request)]; + } + + public override List> CreateSubFolder(CreateSubFolderRequest request) + { + var parentLabelName = request.Folder.FolderName; + + try + { + var parentLabel = _gmailService.Users.Labels.Get("me", request.Folder.RemoteFolderId).Execute(); + if (!string.IsNullOrWhiteSpace(parentLabel?.Name)) + { + parentLabelName = parentLabel.Name; + } + } + catch (Exception ex) + { + _logger.Warning(ex, "Failed to resolve full parent label name for {FolderId}. Falling back to local folder name.", request.Folder.RemoteFolderId); + } + + var label = new Label() + { + Name = $"{parentLabelName}/{request.NewFolderName}" + }; + + var networkCall = _gmailService.Users.Labels.Create(label, "me"); + return [new HttpRequestBundle(networkCall, request, request)]; + } + #endregion #region Request Execution @@ -1834,8 +1842,7 @@ public class GmailSynchronizer : WinoSynchronizer /// Creates new mail packages for the given message. /// AssignedFolder is null since the LabelId is parsed out of the Message. - /// NOTE: This method does NOT download MIME content during synchronization. - /// MIME is only downloaded when user explicitly reads the message. + /// If Gmail Message includes Raw payload, MIME is parsed and attached to packages. /// /// Gmail message to create package for (must have Metadata format). /// Null, not used. @@ -1846,24 +1853,74 @@ public class GmailSynchronizer : WinoSynchronizer(); + MimeMessage mimeMessage = null; + + // Raw format is used in delta sync and does not populate Payload.Headers. + // Parse MIME from Raw so we can resolve draft mapping header and persist mime content. + if (!string.IsNullOrEmpty(message?.Raw)) + { + try + { + mimeMessage = message.GetGmailMimeMessage(); + } + catch (Exception ex) + { + _logger.Warning(ex, "Failed to parse MIME from raw Gmail message {MessageId}", message?.Id); + } + } // Create base MailCopy from metadata only - NO MIME download var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken); - // Check for local draft mapping using X-Wino-Draft-Id header from metadata. - // If this is a Wino-created draft, the local copy was already mapped by the CreateDraft response handler - // with the correct Gmail Draft ID. We must NOT call MapLocalDraftAsync here because - // baseMailCopy.DraftId is derived from CreateMinimalMailCopyAsync (not the real Draft resource ID), - // which would overwrite the correctly mapped DraftId and break SendDraft. + if (mimeMessage != null) + { + // Raw responses don't include metadata headers. Backfill important fields from MIME. + EnrichMailCopyFromMime(baseMailCopy, mimeMessage); + } + + // Check for local draft mapping using X-Wino-Draft-Id header. + // For Metadata format we read from Payload.Headers. + // For Raw format (Payload is null), we read from parsed MIME headers. if (baseMailCopy.IsDraft) { - var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value; + var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value + ?? mimeMessage?.Headers?.FirstOrDefault(h => h.Field.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value; if (!string.IsNullOrEmpty(draftIdHeader) && Guid.TryParse(draftIdHeader, out _)) { - // This message belongs to an existing local draft copy. - // Skip creating a new mail copy - the local copy was already mapped by the response handler. - return null; + if (Guid.TryParse(draftIdHeader, out Guid localDraftCopyUniqueId)) + { + // This message belongs to existing local draft copy. + // Map remote ids to local copy and skip creating duplicate rows. + bool isMappingSuccessful = await _gmailChangeProcessor.MapLocalDraftAsync( + Account.Id, + localDraftCopyUniqueId, + baseMailCopy.Id, + baseMailCopy.DraftId, + baseMailCopy.ThreadId).ConfigureAwait(false); + + if (isMappingSuccessful) + { + // Keep local draft MIME in sync with the fetched remote raw MIME if available. + if (mimeMessage != null) + { + var mappedDraftCopies = await _gmailChangeProcessor.GetMailCopiesAsync([baseMailCopy.Id]).ConfigureAwait(false); + if (mappedDraftCopies != null) + { + var savedFileIds = new HashSet(); + foreach (var mappedCopy in mappedDraftCopies) + { + if (mappedCopy.FileId == Guid.Empty || !savedFileIds.Add(mappedCopy.FileId)) + continue; + + await _gmailChangeProcessor.SaveMimeFileAsync(mappedCopy.FileId, mimeMessage, Account.Id).ConfigureAwait(false); + } + } + } + + return null; + } + } } } @@ -1887,12 +1944,16 @@ public class GmailSynchronizer : WinoSynchronizer> DeleteFolder(DeleteFolderRequest request) + { + return CreateSingleTaskBundle(async (client, item) => + { + var folder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false); + await folder.DeleteAsync().ConfigureAwait(false); + }, request, request); + } + + public override List> CreateSubFolder(CreateSubFolderRequest request) + { + return CreateSingleTaskBundle(async (client, item) => + { + var parentFolder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false); + await parentFolder.CreateAsync(request.NewFolderName, true).ConfigureAwait(false); + }, request, request); + } + #endregion public override async Task> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index c1b73098..aad1bef1 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -1507,6 +1507,23 @@ public class OutlookSynchronizer : WinoSynchronizer> MarkFolderAsRead(MarkFolderAsReadRequest request) => MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true)))); + public override List> DeleteFolder(DeleteFolderRequest request) + { + var networkCall = _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ToDeleteRequestInformation(); + return [new HttpRequestBundle(networkCall, request)]; + } + + public override List> CreateSubFolder(CreateSubFolderRequest request) + { + var requestBody = new MailFolder + { + DisplayName = request.NewFolderName + }; + + var networkCall = _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ChildFolders.ToPostRequestInformation(requestBody); + return [new HttpRequestBundle(networkCall, request)]; + } + #endregion public override async Task ExecuteNativeRequestsAsync(List> batchedRequests, CancellationToken cancellationToken = default) diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs index ecd96502..17a4a7d5 100644 --- a/Wino.Core/Synchronizers/WinoSynchronizer.cs +++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs @@ -203,6 +203,12 @@ public abstract class WinoSynchronizer> RenameFolder(RenameFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> EmptyFolder(EmptyFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> 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())); #endregion diff --git a/Wino.Mail.ViewModels/Data/AccountStorageItemViewModel.cs b/Wino.Mail.ViewModels/Data/AccountStorageItemViewModel.cs new file mode 100644 index 00000000..b82046a1 --- /dev/null +++ b/Wino.Mail.ViewModels/Data/AccountStorageItemViewModel.cs @@ -0,0 +1,40 @@ +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Extensions; + +namespace Wino.Mail.ViewModels.Data; + +public partial class AccountStorageItemViewModel(MailAccount account, long sizeBytes, ICommand deleteAllCommand, ICommand deleteOneMonthCommand, ICommand deleteThreeMonthsCommand, ICommand deleteSixMonthsCommand, ICommand deleteYearCommand) : ObservableObject +{ + public MailAccount Account { get; } = account; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeText))] + public partial long SizeBytes { get; set; } = sizeBytes; + + [ObservableProperty] + public partial string SizeDescription { get; set; } = string.Empty; + + [ObservableProperty] + public partial ICommand DeleteAllCommand { get; set; } = deleteAllCommand; + + [ObservableProperty] + public partial ICommand DeleteOneMonthCommand { get; set; } = deleteOneMonthCommand; + + [ObservableProperty] + public partial ICommand DeleteThreeMonthsCommand { get; set; } = deleteThreeMonthsCommand; + + [ObservableProperty] + public partial ICommand DeleteSixMonthsCommand { get; set; } = deleteSixMonthsCommand; + + [ObservableProperty] + public partial ICommand DeleteYearCommand { get; set; } = deleteYearCommand; + + public string AccountName => string.IsNullOrWhiteSpace(Account.Name) ? Account.Address ?? string.Empty : Account.Name; + public string AccountAddress => Account.Address ?? string.Empty; + public string SizeText => SizeBytes.GetBytesReadable(); +} diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 38597738..2e37769e 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -1074,6 +1074,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, case SynchronizationCompletedState.Success: UpdateBarMessage(InfoBarMessageType.Success, ActiveFolder.FolderName, Translator.SynchronizationFolderReport_Success); break; + case SynchronizationCompletedState.PartiallyCompleted: + UpdateBarMessage(InfoBarMessageType.Warning, ActiveFolder.FolderName, Translator.SynchronizationFolderReport_Failed); + break; case SynchronizationCompletedState.Failed: UpdateBarMessage(InfoBarMessageType.Error, ActiveFolder.FolderName, Translator.SynchronizationFolderReport_Failed); break; diff --git a/Wino.Mail.ViewModels/StoragePageViewModel.cs b/Wino.Mail.ViewModels/StoragePageViewModel.cs new file mode 100644 index 00000000..58226a7c --- /dev/null +++ b/Wino.Mail.ViewModels/StoragePageViewModel.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Serilog; +using Wino.Core.Domain; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Extensions; +using Wino.Mail.ViewModels.Data; + +namespace Wino.Mail.ViewModels; + +public partial class StoragePageViewModel( + IAccountService accountService, + IMimeStorageService mimeStorageService, + IMailDialogService dialogService) : MailBaseViewModel +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly IAccountService _accountService = accountService; + private readonly IMimeStorageService _mimeStorageService = mimeStorageService; + private readonly IMailDialogService _dialogService = dialogService; + + public ObservableCollection AccountStorageItems { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBusy))] + public partial bool IsLoading { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBusy))] + public partial bool IsCleaning { get; set; } + + [ObservableProperty] + public partial string MimeRootPath { get; set; } = string.Empty; + + [ObservableProperty] + public partial string SummaryText { get; set; } = ""; + + public bool IsBusy => IsLoading || IsCleaning; + + public override async void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + await ExecuteUIThread(() => { SummaryText = Translator.SettingsStorage_NoLocalMimeDataFound; }); + + await RefreshStorageAsync(); + } + + partial void OnIsLoadingChanged(bool value) + { + UpdateAccountBusyState(); + } + + partial void OnIsCleaningChanged(bool value) + { + UpdateAccountBusyState(); + } + + private void UpdateAccountBusyState() + { + Dispatcher.ExecuteOnUIThread(() => + { + foreach (var item in AccountStorageItems) + { + item.IsBusy = IsBusy; + } + }); + } + + [RelayCommand] + private async Task RefreshStorageAsync() + { + if (IsBusy) return; + + await ExecuteUIThread(() => { IsLoading = true; }); + + try + { + var mimeRootPath = await _mimeStorageService.GetMimeRootPathAsync().ConfigureAwait(false); + var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + var sizeMap = await _mimeStorageService.GetAccountsMimeStorageSizesAsync(accounts.Select(a => a.Id)).ConfigureAwait(false); + + var storageItems = accounts + .Select(account => + { + sizeMap.TryGetValue(account.Id, out var accountSize); + var viewModel = new AccountStorageItemViewModel(account, accountSize, DeleteAllCommand, DeleteOlderThanOneMonthCommand, DeleteOlderThanThreeMonthsCommand, DeleteOlderThanSixMonthsCommand, DeleteOlderThanOneYearCommand); + viewModel.SizeDescription = string.Format(Translator.SettingsStorage_AccountUsageDescription, viewModel.SizeText); + return viewModel; + }) + .OrderByDescending(a => a.SizeBytes) + .ToList(); + + await ExecuteUIThread(() => + { + MimeRootPath = mimeRootPath; + AccountStorageItems.Clear(); + + foreach (var item in storageItems) + { + AccountStorageItems.Add(item); + } + + var total = storageItems.Sum(a => a.SizeBytes); + SummaryText = storageItems.Count == 0 + ? Translator.SettingsStorage_NoAccountsFound + : string.Format(Translator.SettingsStorage_TotalUsage, total.GetBytesReadable()); + }); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to refresh storage data."); + await ExecuteUIThread(() => + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error); + }); + } + finally + { + await ExecuteUIThread(() => { IsLoading = false; }); + } + } + + [RelayCommand] + private async Task DeleteAllAsync(AccountStorageItemViewModel accountItem) + { + if (accountItem == null || IsBusy) return; + + bool approved = await _dialogService.ShowConfirmationDialogAsync( + string.Format(Translator.SettingsStorage_DeleteAll_Confirm_Message, accountItem.AccountName), + Translator.SettingsStorage_DeleteAll_Confirm_Title, + Translator.Buttons_Delete); + + if (!approved) return; + + await ExecuteUIThread(() => { IsCleaning = true; }); + + try + { + await _mimeStorageService.DeleteAccountMimeStorageAsync(accountItem.Account.Id).ConfigureAwait(false); + await ExecuteUIThread(() => + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Info, Translator.SettingsStorage_DeleteAll_Success, Core.Domain.Enums.InfoBarMessageType.Success); + }); + await RefreshStorageAsync(); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to delete all MIME content for account {AccountId}", accountItem.Account.Id); + await ExecuteUIThread(() => + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error); + }); + } + finally + { + await ExecuteUIThread(() => { IsCleaning = false; }); + } + } + + [RelayCommand] + private Task DeleteOlderThanOneMonthAsync(AccountStorageItemViewModel accountItem) + => DeleteOlderThanAsync(accountItem, 1); + + [RelayCommand] + private Task DeleteOlderThanThreeMonthsAsync(AccountStorageItemViewModel accountItem) + => DeleteOlderThanAsync(accountItem, 3); + + [RelayCommand] + private Task DeleteOlderThanSixMonthsAsync(AccountStorageItemViewModel accountItem) + => DeleteOlderThanAsync(accountItem, 6); + + [RelayCommand] + private Task DeleteOlderThanOneYearAsync(AccountStorageItemViewModel accountItem) + => DeleteOlderThanAsync(accountItem, 12); + + private async Task DeleteOlderThanAsync(AccountStorageItemViewModel accountItem, int months) + { + if (accountItem == null || IsBusy) return; + + string rangeText = GetRangeText(months); + + bool approved = await _dialogService.ShowConfirmationDialogAsync( + string.Format(Translator.SettingsStorage_DeleteOld_Confirm_Message, rangeText, accountItem.AccountName), + Translator.SettingsStorage_DeleteOld_Confirm_Title, + Translator.Buttons_Delete); + + if (!approved) return; + + await ExecuteUIThread(() => { IsCleaning = true; }); + + try + { + var cutoffDateUtc = DateTime.UtcNow.AddMonths(-months); + var deletedDirectoryCount = await _mimeStorageService + .DeleteAccountMimeStorageOlderThanAsync(accountItem.Account.Id, cutoffDateUtc) + .ConfigureAwait(false); + + await ExecuteUIThread(() => + { + _dialogService.InfoBarMessage( + Translator.GeneralTitle_Info, + string.Format(Translator.SettingsStorage_DeleteOld_Success, deletedDirectoryCount, rangeText), + Core.Domain.Enums.InfoBarMessageType.Success); + }); + + await RefreshStorageAsync(); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to delete MIME content by cutoff for account {AccountId}", accountItem.Account.Id); + await ExecuteUIThread(() => + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error); + }); + } + finally + { + await ExecuteUIThread(() => { IsCleaning = false; }); + } + } + + private static string GetRangeText(int months) + { + return months switch + { + 1 => Translator.SettingsStorage_1Month, + 3 => Translator.SettingsStorage_3Months, + 6 => Translator.SettingsStorage_6Months, + 12 => Translator.SettingsStorage_1Year, + _ => string.Format(Translator.SettingsStorage_Months, months) + }; + } +} diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index c57b2679..1309dc8c 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -21,6 +21,7 @@ using Wino.Mail.Services; using Wino.Mail.ViewModels; using Wino.Mail.WinUI.Interfaces; using Wino.Mail.WinUI.Services; +using Wino.Messaging.UI; using Wino.Messaging.Client.Accounts; using Wino.Messaging.Server; using Wino.Services; @@ -92,7 +93,6 @@ public partial class App : WinoApplication, services.AddTransient(typeof(AliasManagementPageViewModel)); services.AddTransient(typeof(ContactsPageViewModel)); services.AddTransient(typeof(SignatureAndEncryptionPageViewModel)); - services.AddTransient(typeof(CalendarPageViewModel)); services.AddTransient(typeof(CalendarSettingsPageViewModel)); services.AddTransient(typeof(CalendarAccountSettingsPageViewModel)); @@ -362,8 +362,70 @@ public partial class App : WinoApplication, WeakReferenceMessenger.Default.Register(this); } - public void Receive(NewMailSynchronizationRequested message) => _synchronizationManager?.SynchronizeMailAsync(message.Options); - public void Receive(NewCalendarSynchronizationRequested message) => _synchronizationManager?.SynchronizeCalendarAsync(message.Options); + public async void Receive(NewMailSynchronizationRequested message) + { + if (_synchronizationManager == null) return; + + MailSynchronizationResult syncResult; + + try + { + syncResult = await _synchronizationManager.SynchronizeMailAsync(message.Options); + } + catch (Exception ex) + { + // Defensive fallback to guarantee completion message emission. + syncResult = MailSynchronizationResult.Failed(ex); + } + + WeakReferenceMessenger.Default.Send(new AccountSynchronizationCompleted( + message.Options.AccountId, + syncResult.CompletedState, + message.Options.GroupedSynchronizationTrackingId)); + + if (syncResult.CompletedState == SynchronizationCompletedState.Failed || + syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted) + { + var dialogService = Services.GetRequiredService(); + var errorMessage = GetSynchronizationFailureMessage(message.Options.Type, syncResult.Exception?.Message); + var severity = syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted + ? InfoBarMessageType.Warning + : InfoBarMessageType.Error; + + dialogService.InfoBarMessage(Translator.Info_SyncFailedTitle, errorMessage, severity); + } + } + + public async void Receive(NewCalendarSynchronizationRequested message) + { + if (_synchronizationManager == null) return; + + var calendarSyncResult = await _synchronizationManager.SynchronizeCalendarAsync(message.Options); + + if (calendarSyncResult.CompletedState == SynchronizationCompletedState.Failed) + { + var dialogService = Services.GetRequiredService(); + dialogService.InfoBarMessage( + Translator.Info_SyncFailedTitle, + Translator.Exception_FailedToSynchronizeFolders, + InfoBarMessageType.Error); + } + } + + private static string GetSynchronizationFailureMessage(MailSynchronizationType synchronizationType, string? exceptionMessage) + { + if (!string.IsNullOrWhiteSpace(exceptionMessage)) + { + return exceptionMessage; + } + + return synchronizationType switch + { + MailSynchronizationType.Alias => Translator.Exception_FailedToSynchronizeAliases, + MailSynchronizationType.UpdateProfile => Translator.Exception_FailedToSynchronizeProfileInformation, + _ => Translator.Exception_FailedToSynchronizeFolders + }; + } /// /// Handles activation redirected from another instance (single-instancing). diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs index 6915420f..ecfc435d 100644 --- a/Wino.Mail.WinUI/Services/NavigationService.cs +++ b/Wino.Mail.WinUI/Services/NavigationService.cs @@ -66,6 +66,7 @@ public class NavigationService : NavigationServiceBase, INavigationService WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage), WinoPage.ContactsPage => typeof(ContactsPage), WinoPage.SignatureAndEncryptionPage => typeof(SignatureAndEncryptionPage), + WinoPage.StoragePage => typeof(StoragePage), WinoPage.CalendarPage => typeof(CalendarPage), WinoPage.EventDetailsPage => typeof(EventDetailsPage), WinoPage.CalendarSettingsPage => typeof(CalendarSettingsPage), diff --git a/Wino.Mail.WinUI/Styles/CustomMessageDialogStyles.xaml b/Wino.Mail.WinUI/Styles/CustomMessageDialogStyles.xaml index 8bb40c59..a888b8f1 100644 --- a/Wino.Mail.WinUI/Styles/CustomMessageDialogStyles.xaml +++ b/Wino.Mail.WinUI/Styles/CustomMessageDialogStyles.xaml @@ -15,14 +15,14 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Data="F1 M 0 9.375 C 0 8.509115 0.110677 7.677409 0.332031 6.879883 C 0.553385 6.082357 0.867513 5.335287 1.274414 4.638672 C 1.681315 3.942059 2.169596 3.30892 2.739258 2.739258 C 3.308919 2.169598 3.942057 1.681316 4.638672 1.274414 C 5.335286 0.867514 6.082356 0.553387 6.879883 0.332031 C 7.677409 0.110678 8.509114 0 9.375 0 C 10.240885 0 11.072591 0.110678 11.870117 0.332031 C 12.667643 0.553387 13.414713 0.867514 14.111328 1.274414 C 14.807942 1.681316 15.44108 2.169598 16.010742 2.739258 C 16.580402 3.30892 17.068684 3.942059 17.475586 4.638672 C 17.882486 5.335287 18.196613 6.082357 18.417969 6.879883 C 18.639322 7.677409 18.75 8.509115 18.75 9.375 C 18.75 10.240886 18.637695 11.072592 18.413086 11.870117 C 18.188477 12.667644 17.872721 13.413086 17.46582 14.106445 C 17.058918 14.799805 16.570637 15.431315 16.000977 16.000977 C 15.431314 16.570639 14.799804 17.05892 14.106445 17.46582 C 13.413085 17.872721 12.666015 18.188477 11.865234 18.413086 C 11.064453 18.637695 10.234375 18.75 9.375 18.75 C 8.509114 18.75 7.675781 18.639322 6.875 18.417969 C 6.074219 18.196615 5.327148 17.882486 4.633789 17.475586 C 3.94043 17.068686 3.308919 16.580404 2.739258 16.010742 C 2.169596 15.441081 1.681315 14.80957 1.274414 14.116211 C 0.867513 13.422852 0.553385 12.675781 0.332031 11.875 C 0.110677 11.074219 0 10.240886 0 9.375 Z M 17.5 9.375 C 17.5 8.626303 17.403971 7.905273 17.211914 7.211914 C 17.019855 6.518556 16.746418 5.87077 16.391602 5.268555 C 16.036783 4.666342 15.613606 4.119467 15.12207 3.62793 C 14.630533 3.136395 14.083658 2.713217 13.481445 2.358398 C 12.879231 2.003582 12.231445 1.730145 11.538086 1.538086 C 10.844727 1.346029 10.123697 1.25 9.375 1.25 C 8.626302 1.25 7.905273 1.346029 7.211914 1.538086 C 6.518555 1.730145 5.870768 2.003582 5.268555 2.358398 C 4.666341 2.713217 4.119466 3.136395 3.62793 3.62793 C 3.136393 4.119467 2.713216 4.666342 2.358398 5.268555 C 2.003581 5.87077 1.730143 6.518556 1.538086 7.211914 C 1.346029 7.905273 1.25 8.626303 1.25 9.375 C 1.25 10.123698 1.346029 10.844727 1.538086 11.538086 C 1.730143 12.231445 2.001953 12.879232 2.353516 13.481445 C 2.705078 14.083659 3.128255 14.632162 3.623047 15.126953 C 4.117838 15.621745 4.666341 16.044922 5.268555 16.396484 C 5.870768 16.748047 6.518555 17.019857 7.211914 17.211914 C 7.905273 17.403971 8.626302 17.5 9.375 17.5 C 10.123697 17.5 10.844727 17.403971 11.538086 17.211914 C 12.231445 17.019857 12.879231 16.748047 13.481445 16.396484 C 14.083658 16.044922 14.63216 15.621745 15.126953 15.126953 C 15.621744 14.632162 16.044922 14.083659 16.396484 13.481445 C 16.748047 12.879232 17.019855 12.231445 17.211914 11.538086 C 17.403971 10.844727 17.5 10.123698 17.5 9.375 Z M 8.4375 5.625 C 8.4375 5.364584 8.528646 5.14323 8.710938 4.960938 C 8.893229 4.778646 9.114583 4.6875 9.375 4.6875 C 9.635416 4.6875 9.856771 4.778646 10.039062 4.960938 C 10.221354 5.14323 10.3125 5.364584 10.3125 5.625 C 10.3125 5.885417 10.221354 6.106771 10.039062 6.289062 C 9.856771 6.471354 9.635416 6.5625 9.375 6.5625 C 9.114583 6.5625 8.893229 6.471354 8.710938 6.289062 C 8.528646 6.106771 8.4375 5.885417 8.4375 5.625 Z M 8.75 13.125 L 8.75 8.125 C 8.75 7.95573 8.811849 7.809246 8.935547 7.685547 C 9.059244 7.56185 9.205729 7.5 9.375 7.5 C 9.544271 7.5 9.690755 7.56185 9.814453 7.685547 C 9.93815 7.809246 10 7.95573 10 8.125 L 10 13.125 C 10 13.294271 9.93815 13.440756 9.814453 13.564453 C 9.690755 13.688151 9.544271 13.75 9.375 13.75 C 9.205729 13.75 9.059244 13.688151 8.935547 13.564453 C 8.811849 13.440756 8.75 13.294271 8.75 13.125 Z " - Fill="#0f80d7" /> + Fill="{ThemeResource SystemAccentColor}" /> + Fill="{ThemeResource SystemAccentColor}" /> - - + + diff --git a/Wino.Mail.WinUI/Views/Abstract/StoragePageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/StoragePageAbstract.cs new file mode 100644 index 00000000..e3fee54a --- /dev/null +++ b/Wino.Mail.WinUI/Views/Abstract/StoragePageAbstract.cs @@ -0,0 +1,6 @@ +using Wino.Mail.ViewModels; +using Wino.Mail.WinUI; + +namespace Wino.Views.Abstract; + +public abstract class StoragePageAbstract : BasePage { } diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml index 00593681..a0f48fb5 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml @@ -210,14 +210,14 @@ + Translation="0,0,20"> @@ -421,6 +421,7 @@ HorizontalContentAlignment="Stretch" toolkitExt:ListViewExtensions.ItemContainerStretchDirection="Horizontal" toolkitExt:ScrollViewerExtensions.VerticalScrollBarMargin="0" + CanDragItems="True" ChoosingItemContainer="WinoListViewChoosingItemContainer" IsItemClickEnabled="True" ItemClick="WinoListViewItemClicked" diff --git a/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml b/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml index ea62099e..f8a98333 100644 --- a/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml +++ b/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml @@ -409,6 +409,38 @@ Glyph="" /> + + diff --git a/Wino.Mail.WinUI/Views/Settings/StoragePage.xaml b/Wino.Mail.WinUI/Views/Settings/StoragePage.xaml new file mode 100644 index 00000000..fa0254ff --- /dev/null +++ b/Wino.Mail.WinUI/Views/Settings/StoragePage.xaml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + M5 7h14a3 3 0 0 1 2.995 2.824L22 10v4a3 3 0 0 1-2.824 2.995L19 17H5a3 3 0 0 1-2.995-2.824L2 14v-4a3 3 0 0 1 2.824-2.995L5 7h14H5Zm14 1.5H5A1.5 1.5 0 0 0 3.5 10v4A1.5 1.5 0 0 0 5 15.5h14a1.5 1.5 0 0 0 1.5-1.5v-4A1.5 1.5 0 0 0 19 8.5ZM18 10a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm-4 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z + + + + + + + + + + + + + + + + +