From 05ddc0660a5bebeb5a5227cc210e6127ea1bac0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 15 Aug 2024 16:02:02 +0200 Subject: [PATCH 01/28] Creating MailAccountAlias entity. --- Wino.Core.Domain/Entities/MailAccount.cs | 9 ++++++ Wino.Core.Domain/Entities/MailAccountAlias.cs | 15 ++++++++++ Wino.Core/Services/AccountService.cs | 28 +++++++++++++++++++ Wino.Core/Services/DatabaseService.cs | 3 +- 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 Wino.Core.Domain/Entities/MailAccountAlias.cs diff --git a/Wino.Core.Domain/Entities/MailAccount.cs b/Wino.Core.Domain/Entities/MailAccount.cs index dbeac65c..ecd8fb64 100644 --- a/Wino.Core.Domain/Entities/MailAccount.cs +++ b/Wino.Core.Domain/Entities/MailAccount.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using SQLite; using Wino.Core.Domain.Enums; @@ -73,6 +74,14 @@ namespace Wino.Core.Domain.Entities [Ignore] public CustomServerInformation ServerInformation { get; set; } + /// + /// Gets or sets the aliases of the account. + /// It's only synchronized for Gmail right now. + /// Other provider types are manually added by users and not verified. + /// + [Ignore] + public List Aliases { get; set; } + /// /// Account preferences. /// diff --git a/Wino.Core.Domain/Entities/MailAccountAlias.cs b/Wino.Core.Domain/Entities/MailAccountAlias.cs new file mode 100644 index 00000000..97e5257e --- /dev/null +++ b/Wino.Core.Domain/Entities/MailAccountAlias.cs @@ -0,0 +1,15 @@ +using System; +using SQLite; + +namespace Wino.Core.Domain.Entities +{ + public class MailAccountAlias + { + [PrimaryKey] + public Guid Id { get; set; } + public Guid AccountId { get; set; } + public string AliasAddress { get; set; } + public bool IsPrimary { get; set; } + public bool IsVerified { get; set; } + } +} diff --git a/Wino.Core/Services/AccountService.cs b/Wino.Core/Services/AccountService.cs index 63e1055b..afd992e8 100644 --- a/Wino.Core/Services/AccountService.cs +++ b/Wino.Core/Services/AccountService.cs @@ -227,12 +227,40 @@ namespace Wino.Core.Services if (account.MergedInboxId != null) account.MergedInbox = await GetMergedInboxInformationAsync(account.MergedInboxId.Value); + // Load aliases + account.Aliases = await GetAccountAliases(account.Id, account.Address); + account.Preferences = await GetAccountPreferencesAsync(account.Id); } return accounts; } + private async Task> GetAccountAliases(Guid accountId, string primaryAccountAddress) + { + // By default all accounts must have at least 1 primary alias to create drafts for. + // If there's no alias, create one from the existing account address. Migration doesn't exists to create one for older messages. + + var aliases = await Connection.Table().ToListAsync().ConfigureAwait(false); + + if (!aliases.Any()) + { + var primaryAccountAlias = new MailAccountAlias() + { + Id = Guid.NewGuid(), + AccountId = accountId, + IsPrimary = true, + AliasAddress = primaryAccountAddress, + IsVerified = true, + }; + + await Connection.InsertAsync(primaryAccountAlias).ConfigureAwait(false); + aliases.Add(primaryAccountAlias); + } + + return aliases; + } + private Task GetMergedInboxInformationAsync(Guid mergedInboxId) => Connection.Table().FirstOrDefaultAsync(a => a.Id == mergedInboxId); diff --git a/Wino.Core/Services/DatabaseService.cs b/Wino.Core/Services/DatabaseService.cs index 246f9a63..e21a88eb 100644 --- a/Wino.Core/Services/DatabaseService.cs +++ b/Wino.Core/Services/DatabaseService.cs @@ -61,7 +61,8 @@ namespace Wino.Core.Services typeof(CustomServerInformation), typeof(AccountSignature), typeof(MergedInbox), - typeof(MailAccountPreferences) + typeof(MailAccountPreferences), + typeof(MailAccountAlias) ); } } From 34d6d951860c0a58f02bebe725cb47fd4fb146a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 15 Aug 2024 16:11:12 +0200 Subject: [PATCH 02/28] Including ReplyToAddress for alias. --- Wino.Core.Domain/Entities/MailAccountAlias.cs | 1 + Wino.Core/Services/AccountService.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/Wino.Core.Domain/Entities/MailAccountAlias.cs b/Wino.Core.Domain/Entities/MailAccountAlias.cs index 97e5257e..75f2c729 100644 --- a/Wino.Core.Domain/Entities/MailAccountAlias.cs +++ b/Wino.Core.Domain/Entities/MailAccountAlias.cs @@ -9,6 +9,7 @@ namespace Wino.Core.Domain.Entities public Guid Id { get; set; } public Guid AccountId { get; set; } public string AliasAddress { get; set; } + public string ReplyToAddress { get; set; } public bool IsPrimary { get; set; } public bool IsVerified { get; set; } } diff --git a/Wino.Core/Services/AccountService.cs b/Wino.Core/Services/AccountService.cs index afd992e8..d7c9ab67 100644 --- a/Wino.Core/Services/AccountService.cs +++ b/Wino.Core/Services/AccountService.cs @@ -251,6 +251,7 @@ namespace Wino.Core.Services AccountId = accountId, IsPrimary = true, AliasAddress = primaryAccountAddress, + ReplyToAddress = primaryAccountAddress, IsVerified = true, }; From fe449ee1f39ee2823f89d8fc181711ed2e72dd4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 15 Aug 2024 16:13:18 +0200 Subject: [PATCH 03/28] Comments for alias entity. --- Wino.Core.Domain/Entities/MailAccountAlias.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Wino.Core.Domain/Entities/MailAccountAlias.cs b/Wino.Core.Domain/Entities/MailAccountAlias.cs index 75f2c729..a97efeb7 100644 --- a/Wino.Core.Domain/Entities/MailAccountAlias.cs +++ b/Wino.Core.Domain/Entities/MailAccountAlias.cs @@ -5,12 +5,38 @@ namespace Wino.Core.Domain.Entities { public class MailAccountAlias { + /// + /// Unique Id for the alias. + /// [PrimaryKey] public Guid Id { get; set; } + + /// + /// Account id that this alias is attached to. + /// public Guid AccountId { get; set; } + + /// + /// Display address of the alias. + /// public string AliasAddress { get; set; } + + /// + /// Address to be included in Reply-To header when alias is used for sending messages. + /// public string ReplyToAddress { get; set; } + + /// + /// Whether this alias is the primary alias for the account. + /// public bool IsPrimary { get; set; } + + /// + /// Whether the alias is verified by the server. + /// Non-verified aliases will show an info tip to users during sending. + /// Only Gmail aliases are verified for now. + /// Non-verified alias messages might be rejected by SMTP server. + /// public bool IsVerified { get; set; } } } From 8f66fcbb00b2ca335e6b4a9d9f715dfb20bb9c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 15 Aug 2024 23:57:45 +0200 Subject: [PATCH 04/28] Activated contact service for Gmail to retrieve profile picture and sender name. --- Wino.Core.Domain/Entities/MailAccount.cs | 5 ++ .../GoogleAuthorizationRequest.cs | 2 +- .../Processors/DefaultChangeProcessor.cs | 4 ++ Wino.Core/MenuItems/AccountMenuItem.cs | 7 +++ Wino.Core/Synchronizers/BaseSynchronizer.cs | 49 ++++++++++++++- Wino.Core/Synchronizers/GmailSynchronizer.cs | 60 +++++++++++++++++-- Wino.Core/Synchronizers/ImapSynchronizer.cs | 2 +- .../Synchronizers/OutlookSynchronizer.cs | 2 +- Wino.Core/Wino.Core.csproj | 1 + 9 files changed, 124 insertions(+), 8 deletions(-) diff --git a/Wino.Core.Domain/Entities/MailAccount.cs b/Wino.Core.Domain/Entities/MailAccount.cs index ecd8fb64..60568d32 100644 --- a/Wino.Core.Domain/Entities/MailAccount.cs +++ b/Wino.Core.Domain/Entities/MailAccount.cs @@ -45,6 +45,11 @@ namespace Wino.Core.Domain.Entities /// public string AccountColorHex { get; set; } + /// + /// Base64 encoded profile picture of the account. + /// + public string ProfilePictureBase64 { get; set; } + /// /// Gets or sets the listing order of the account in the accounts list. /// diff --git a/Wino.Core.Domain/Models/Authorization/GoogleAuthorizationRequest.cs b/Wino.Core.Domain/Models/Authorization/GoogleAuthorizationRequest.cs index 455fff47..bef11d59 100644 --- a/Wino.Core.Domain/Models/Authorization/GoogleAuthorizationRequest.cs +++ b/Wino.Core.Domain/Models/Authorization/GoogleAuthorizationRequest.cs @@ -33,7 +33,7 @@ namespace Wino.Core.Domain.Models.Authorization ClientId = clientId; // Creates the OAuth 2.0 authorization request. - return string.Format("{0}?response_type=code&scope=https://mail.google.com/ https://www.googleapis.com/auth/gmail.labels&redirect_uri={1}&client_id={2}&state={3}&code_challenge={4}&code_challenge_method={5}", + return string.Format("{0}?response_type=code&scope=https://mail.google.com/ https://www.googleapis.com/auth/gmail.labels https://www.googleapis.com/auth/userinfo.profile&redirect_uri={1}&client_id={2}&state={3}&code_challenge={4}&code_challenge_method={5}", authorizationEndpoint, Uri.EscapeDataString(RedirectUri), ClientId, diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index 14ebc1de..4028dc9c 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -19,6 +19,7 @@ namespace Wino.Core.Integration.Processors /// public interface IDefaultChangeProcessor { + Task UpdateAccountAsync(MailAccount account); Task UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string deltaSynchronizationIdentifier); Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); @@ -172,5 +173,8 @@ namespace Wino.Core.Integration.Processors public Task UpdateFolderLastSyncDateAsync(Guid folderId) => FolderService.UpdateFolderLastSyncDateAsync(folderId); + + public Task UpdateAccountAsync(MailAccount account) + => AccountService.UpdateAccountAsync(account); } } diff --git a/Wino.Core/MenuItems/AccountMenuItem.cs b/Wino.Core/MenuItems/AccountMenuItem.cs index 045038f4..5cba23d3 100644 --- a/Wino.Core/MenuItems/AccountMenuItem.cs +++ b/Wino.Core/MenuItems/AccountMenuItem.cs @@ -47,6 +47,12 @@ namespace Wino.Core.MenuItems set => SetProperty(Parameter.Name, value, Parameter, (u, n) => u.Name = n); } + public string Base64ProfilePicture + { + get => Parameter.Name; + set => SetProperty(Parameter.ProfilePictureBase64, value, Parameter, (u, n) => u.ProfilePictureBase64 = n); + } + public IEnumerable HoldingAccounts => new List { Parameter }; public AccountMenuItem(MailAccount account, IMenuItem parent = null) : base(account, account.Id, parent) @@ -59,6 +65,7 @@ namespace Wino.Core.MenuItems Parameter = account; AccountName = account.Name; AttentionReason = account.AttentionReason; + Base64ProfilePicture = account.ProfilePictureBase64; if (SubMenuItems == null) return; diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs index acd7f213..055a4bb4 100644 --- a/Wino.Core/Synchronizers/BaseSynchronizer.cs +++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; @@ -69,8 +70,42 @@ namespace Wino.Core.Synchronizers /// Cancellation token public abstract Task ExecuteNativeRequestsAsync(IEnumerable> batchedRequests, CancellationToken cancellationToken = default); - public abstract Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default); + /// + /// Refreshed remote mail account profile if possible. + /// Aliases, profile pictures, mailbox settings will be handled in this step. + /// + protected virtual Task SynchronizeProfileInformationAsync() => Task.CompletedTask; + /// + /// Returns the base64 encoded profile picture of the account from the given URL. + /// + /// URL to retrieve picture from. + /// base64 encoded profile picture + protected async Task GetProfilePictureBase64EncodedAsync(string url) + { + using var client = new HttpClient(); + + var response = await client.GetAsync(url).ConfigureAwait(false); + var byteContent = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + + return Convert.ToBase64String(byteContent); + } + + /// + /// Internally synchronizes the account with the given options. + /// Not exposed and overriden for each synchronizer. + /// + /// Synchronization options. + /// Cancellation token. + /// Synchronization result that contains summary of the sync. + protected abstract Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default); + + /// + /// Batches network requests, executes them, and does the needed synchronization after the batch request execution. + /// + /// Synchronization options. + /// Cancellation token. + /// Synchronization result that contains summary of the sync. public async Task SynchronizeAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) { try @@ -104,6 +139,14 @@ namespace Wino.Core.Synchronizers await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken); + if (options.Type == SynchronizationType.Full) + { + // Refresh profile information and mailbox settings on full synchronization. + // Exceptions here is not critical. Therefore, they are ignored. + + await SynchronizeProfileInformationAsync(); + } + // Let servers to finish their job. Sometimes the servers doesn't respond immediately. bool shouldDelayExecution = batches.Any(a => a.DelayExecution); @@ -150,6 +193,10 @@ namespace Wino.Core.Synchronizers private void PublishUnreadItemChanges() => WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(Account.Id)); + /// + /// Sends a message to the shell to update the synchronization progress. + /// + /// Percentage of the progress. public void PublishSynchronizationProgress(double progress) => WeakReferenceMessenger.Default.Send(new AccountSynchronizationProgressUpdatedMessage(Account.Id, progress)); diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 3ee7e044..106a8413 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Google.Apis.Gmail.v1; using Google.Apis.Gmail.v1.Data; using Google.Apis.Http; +using Google.Apis.PeopleService.v1; using Google.Apis.Requests; using Google.Apis.Services; using MailKit; @@ -37,8 +38,10 @@ namespace Wino.Core.Synchronizers // https://github.com/googleapis/google-api-dotnet-client/issues/2603 private const uint MaximumAllowedBatchRequestSize = 10; - private readonly ConfigurableHttpClient _gmailHttpClient; + private readonly ConfigurableHttpClient _googleHttpClient; private readonly GmailService _gmailService; + private readonly PeopleServiceService _peopleService; + private readonly IAuthenticator _authenticator; private readonly IGmailChangeProcessor _gmailChangeProcessor; private readonly ILogger _logger = Log.ForContext(); @@ -54,15 +57,64 @@ namespace Wino.Core.Synchronizers HttpClientFactory = this }; - _gmailHttpClient = new ConfigurableHttpClient(messageHandler); + _googleHttpClient = new ConfigurableHttpClient(messageHandler); + _gmailService = new GmailService(initializer); + _peopleService = new PeopleServiceService(initializer); + _authenticator = authenticator; _gmailChangeProcessor = gmailChangeProcessor; } - public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => _gmailHttpClient; + public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => _googleHttpClient; - public override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) + protected override async Task SynchronizeProfileInformationAsync() + { + // Gmail profile info synchronizes Alias and Profile Picture. + + try + { + var profileRequest = _peopleService.People.Get("people/me"); + profileRequest.PersonFields = "names,photos"; + + string senderName = Account.SenderName, base64ProfilePicture = Account.ProfilePictureBase64; + + var userProfile = await profileRequest.ExecuteAsync(); + + senderName = userProfile.Names?.FirstOrDefault()?.DisplayName ?? Account.SenderName; + + var profilePicture = userProfile.Photos?.FirstOrDefault()?.Url ?? string.Empty; + + if (!string.IsNullOrEmpty(profilePicture)) + { + base64ProfilePicture = await GetProfilePictureBase64EncodedAsync(profilePicture).ConfigureAwait(false); + } + + bool shouldUpdateAccountProfile = (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) + || (!string.IsNullOrEmpty(profilePicture) && Account.ProfilePictureBase64 != base64ProfilePicture); + + if (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) + { + Account.SenderName = senderName; + } + + if (!string.IsNullOrEmpty(profilePicture) && Account.ProfilePictureBase64 != base64ProfilePicture) + { + Account.ProfilePictureBase64 = base64ProfilePicture; + } + + if (shouldUpdateAccountProfile) + { + await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); + } + } + catch (Exception ex) + { + Logger.Error(ex, "Error while synchronizing profile information for {Name}", Account.Name); + } + } + + protected override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) { _logger.Information("Internal synchronization started for {Name}", Account.Name); diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index 0b8cd2f6..e0ef6810 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -405,7 +405,7 @@ namespace Wino.Core.Synchronizers ]; } - public override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) + protected override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) { var downloadedMessageIds = new List(); diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 554e9dcf..c9872d3f 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -128,7 +128,7 @@ namespace Wino.Core.Synchronizers #endregion - public override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) + protected override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) { var downloadedMessageIds = new List(); diff --git a/Wino.Core/Wino.Core.csproj b/Wino.Core/Wino.Core.csproj index be8ca55c..476216e3 100644 --- a/Wino.Core/Wino.Core.csproj +++ b/Wino.Core/Wino.Core.csproj @@ -17,6 +17,7 @@ + From b80f0276b4962c87d50675bcc1d55d1815d4f11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 16 Aug 2024 00:37:38 +0200 Subject: [PATCH 05/28] Sender Name and Profile Picture synchronization for Outlook --- .../Synchronizers/OutlookSynchronizer.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index c9872d3f..89496165 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -473,6 +473,71 @@ namespace Wino.Core.Synchronizers } } + /// + /// Get the user's profile picture + /// + /// Base64 encoded profile picture. + private async Task GetUserProfilePictureAsync() + { + try + { + var photoStream = await _graphClient.Me.Photos["48x48"].Content.GetAsync(); + + using var memoryStream = new MemoryStream(); + await photoStream.CopyToAsync(memoryStream); + var byteArray = memoryStream.ToArray(); + + return Convert.ToBase64String(byteArray); + } + catch (Exception ex) + { + Log.Error(ex, "Error occurred while getting user profile picture."); + return string.Empty; + } + } + + private async Task GetSenderNameAsync() + { + try + { + var userInfo = await _graphClient.Users["me"].GetAsync(); + + return userInfo.DisplayName; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to get sender name."); + return string.Empty; + } + } + + protected override async Task SynchronizeProfileInformationAsync() + { + // Outlook profile info synchronizes Sender Name and Profile Picture. + string senderName = Account.SenderName, base64ProfilePicture = Account.ProfilePictureBase64; + + var profilePictureData = await GetUserProfilePictureAsync().ConfigureAwait(false); + senderName = await GetSenderNameAsync().ConfigureAwait(false); + + bool shouldUpdateAccountProfile = (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) + || (!string.IsNullOrEmpty(profilePictureData) && Account.ProfilePictureBase64 != base64ProfilePicture); + + if (!string.IsNullOrEmpty(profilePictureData) && Account.ProfilePictureBase64 != profilePictureData) + { + Account.ProfilePictureBase64 = profilePictureData; + } + + if (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) + { + Account.SenderName = senderName; + } + + if (shouldUpdateAccountProfile) + { + await _outlookChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); + } + } + #region Mail Integration public override bool DelaySendOperationSynchronization() => true; From 7b0343c87fe1114e704e82cbfbaae5918ed36b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 16 Aug 2024 00:37:50 +0200 Subject: [PATCH 06/28] Added sender name comment for gmail. --- Wino.Core/Synchronizers/GmailSynchronizer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 106a8413..5a7a6838 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -70,7 +70,7 @@ namespace Wino.Core.Synchronizers protected override async Task SynchronizeProfileInformationAsync() { - // Gmail profile info synchronizes Alias and Profile Picture. + // Gmail profile info synchronizes Sender Name, Alias and Profile Picture. try { From 7211f94f08807f9204598a969c32b83086b87c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 16 Aug 2024 00:40:10 +0200 Subject: [PATCH 07/28] Try - catch for outlook profile sync. --- .../Synchronizers/OutlookSynchronizer.cs | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 89496165..7080e9c7 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -513,28 +513,35 @@ namespace Wino.Core.Synchronizers protected override async Task SynchronizeProfileInformationAsync() { - // Outlook profile info synchronizes Sender Name and Profile Picture. - string senderName = Account.SenderName, base64ProfilePicture = Account.ProfilePictureBase64; - - var profilePictureData = await GetUserProfilePictureAsync().ConfigureAwait(false); - senderName = await GetSenderNameAsync().ConfigureAwait(false); - - bool shouldUpdateAccountProfile = (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) - || (!string.IsNullOrEmpty(profilePictureData) && Account.ProfilePictureBase64 != base64ProfilePicture); - - if (!string.IsNullOrEmpty(profilePictureData) && Account.ProfilePictureBase64 != profilePictureData) + try { - Account.ProfilePictureBase64 = profilePictureData; + // Outlook profile info synchronizes Sender Name and Profile Picture. + string senderName = Account.SenderName, base64ProfilePicture = Account.ProfilePictureBase64; + + var profilePictureData = await GetUserProfilePictureAsync().ConfigureAwait(false); + senderName = await GetSenderNameAsync().ConfigureAwait(false); + + bool shouldUpdateAccountProfile = (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) + || (!string.IsNullOrEmpty(profilePictureData) && Account.ProfilePictureBase64 != base64ProfilePicture); + + if (!string.IsNullOrEmpty(profilePictureData) && Account.ProfilePictureBase64 != profilePictureData) + { + Account.ProfilePictureBase64 = profilePictureData; + } + + if (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) + { + Account.SenderName = senderName; + } + + if (shouldUpdateAccountProfile) + { + await _outlookChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); + } } - - if (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) + catch (Exception ex) { - Account.SenderName = senderName; - } - - if (shouldUpdateAccountProfile) - { - await _outlookChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); + Log.Error(ex, "Failed to synchronize profile information for {Name}", Account.Name); } } From 1791df236c83738cabb3b45a52e0fe0ed17cfeab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 16 Aug 2024 01:03:00 +0200 Subject: [PATCH 08/28] Remove unused extension class. --- Wino.Core/Extensions/FolderTreeExtensions.cs | 31 -------------------- 1 file changed, 31 deletions(-) delete mode 100644 Wino.Core/Extensions/FolderTreeExtensions.cs diff --git a/Wino.Core/Extensions/FolderTreeExtensions.cs b/Wino.Core/Extensions/FolderTreeExtensions.cs deleted file mode 100644 index 18cf16ca..00000000 --- a/Wino.Core/Extensions/FolderTreeExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Wino.Core.Domain.Interfaces; -using Wino.Core.Domain.Models.Folders; -using Wino.Core.MenuItems; - -namespace Wino.Core.Extensions -{ - public static class FolderTreeExtensions - { - private static MenuItemBase GetMenuItemByFolderRecursive(IMailItemFolder structure, AccountMenuItem parentAccountMenuItem, IMenuItem parentFolderItem) - { - MenuItemBase parentMenuItem = new FolderMenuItem(structure, parentAccountMenuItem.Parameter, parentFolderItem); - - var childStructures = structure.ChildFolders; - - foreach (var childFolder in childStructures) - { - if (childFolder == null) continue; - - // Folder menu item. - var subChildrenFolderTree = GetMenuItemByFolderRecursive(childFolder, parentAccountMenuItem, parentMenuItem); - - if (subChildrenFolderTree is FolderMenuItem folderItem) - { - parentMenuItem.SubMenuItems.Add(folderItem); - } - } - - return parentMenuItem; - } - } -} From cf9f308b7f8fa21fff7132c5ea9b2171a4051383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 16 Aug 2024 01:29:31 +0200 Subject: [PATCH 09/28] Updating aliases during profile sync for Gmail. --- Wino.Core.Domain/Entities/MailAccountAlias.cs | 26 ++++++++++ .../Extensions/EntityExtensions.cs | 34 ++++++++++++++ .../Interfaces/IAccountService.cs | 8 ++++ .../Extensions/GoogleIntegratorExtensions.cs | 47 +++++-------------- .../Processors/DefaultChangeProcessor.cs | 4 ++ Wino.Core/Services/AccountService.cs | 28 +++++++---- Wino.Core/Synchronizers/GmailSynchronizer.cs | 18 +++++++ 7 files changed, 121 insertions(+), 44 deletions(-) create mode 100644 Wino.Core.Domain/Extensions/EntityExtensions.cs diff --git a/Wino.Core.Domain/Entities/MailAccountAlias.cs b/Wino.Core.Domain/Entities/MailAccountAlias.cs index a97efeb7..f1f0b623 100644 --- a/Wino.Core.Domain/Entities/MailAccountAlias.cs +++ b/Wino.Core.Domain/Entities/MailAccountAlias.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using SQLite; namespace Wino.Core.Domain.Entities @@ -38,5 +39,30 @@ namespace Wino.Core.Domain.Entities /// Non-verified alias messages might be rejected by SMTP server. /// public bool IsVerified { get; set; } + + public override bool Equals(object obj) + { + if (obj == null || GetType() != obj.GetType()) + return false; + + var other = (MailAccountAlias)obj; + return other != null && + AccountId == other.AccountId && + AliasAddress == other.AliasAddress && + ReplyToAddress == other.ReplyToAddress && + IsPrimary == other.IsPrimary && + IsVerified == other.IsVerified; + } + + public override int GetHashCode() + { + int hashCode = -753829106; + hashCode = hashCode * -1521134295 + AccountId.GetHashCode(); + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(AliasAddress); + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(ReplyToAddress); + hashCode = hashCode * -1521134295 + IsPrimary.GetHashCode(); + hashCode = hashCode * -1521134295 + IsVerified.GetHashCode(); + return hashCode; + } } } diff --git a/Wino.Core.Domain/Extensions/EntityExtensions.cs b/Wino.Core.Domain/Extensions/EntityExtensions.cs new file mode 100644 index 00000000..b078ad99 --- /dev/null +++ b/Wino.Core.Domain/Extensions/EntityExtensions.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using Wino.Core.Domain.Entities; + +namespace Wino.Core.Domain.Extensions +{ + public static class EntityExtensions + { + public static List GetFinalAliasList(List localAliases, List networkAliases) + { + var finalAliases = new List(); + + var networkAliasDict = networkAliases.ToDictionary(a => a, a => a); + + // Handle updating and retaining existing aliases + foreach (var localAlias in localAliases) + { + if (networkAliasDict.TryGetValue(localAlias, out var networkAlias)) + { + // If alias exists in both lists, update it with the network alias (preserving Id from local) + networkAlias.Id = localAlias.Id; // Preserve the local Id + finalAliases.Add(networkAlias); + networkAliasDict.Remove(localAlias); // Remove from dictionary to track what's been handled + } + // If the alias isn't in the network list, it's considered deleted and not added to finalAliases + } + + // Add new aliases that were not in the local list + finalAliases.AddRange(networkAliasDict.Values); + + return finalAliases; + } + } +} diff --git a/Wino.Core.Domain/Interfaces/IAccountService.cs b/Wino.Core.Domain/Interfaces/IAccountService.cs index 78682cda..926a65d6 100644 --- a/Wino.Core.Domain/Interfaces/IAccountService.cs +++ b/Wino.Core.Domain/Interfaces/IAccountService.cs @@ -100,5 +100,13 @@ namespace Wino.Core.Domain.Interfaces /// /// AccountId-OrderNumber pair for all accounts. Task UpdateAccountOrdersAsync(Dictionary accountIdOrderPair); + + /// + /// Updated account's aliases. + /// + /// Account id to update aliases for. + /// Full list of updated aliases. + /// + Task UpdateAccountAliases(Guid accountId, List aliases); } } diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index d03877b9..263669ab 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Web; using Google.Apis.Gmail.v1.Data; using MimeKit; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Extensions; namespace Wino.Core.Extensions { @@ -205,44 +205,21 @@ namespace Wino.Core.Extensions }; } - public static Tuple> GetMailDetails(this Message message) + public static List GetMailAliases(this ListSendAsResponse response, MailAccount currentAccount) { - MimeMessage mimeMessage = message.GetGmailMimeMessage(); + if (response == null || response.SendAs == null) return currentAccount.Aliases; - if (mimeMessage == null) + var remoteAliases = response.SendAs.Select(a => new MailAccountAlias() { - // This should never happen. - Debugger.Break(); + AccountId = currentAccount.Id, + AliasAddress = a.SendAsEmail, + IsPrimary = a.IsPrimary.GetValueOrDefault(), + ReplyToAddress = string.IsNullOrEmpty(a.ReplyToAddress) ? currentAccount.Address : a.ReplyToAddress, + IsVerified = string.IsNullOrEmpty(a.VerificationStatus) ? true : a.VerificationStatus == "accepted", + Id = Guid.NewGuid() + }).ToList(); - return default; - } - - bool isUnread = message.GetIsUnread(); - bool isFocused = message.GetIsFocused(); - bool isFlagged = message.GetIsFlagged(); - bool isDraft = message.GetIsDraft(); - - var mailCopy = new MailCopy() - { - CreationDate = mimeMessage.Date.UtcDateTime, - Subject = HttpUtility.HtmlDecode(mimeMessage.Subject), - FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage), - FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage), - PreviewText = HttpUtility.HtmlDecode(message.Snippet), - ThreadId = message.ThreadId, - Importance = (MailImportance)mimeMessage.Importance, - Id = message.Id, - IsDraft = isDraft, - HasAttachments = mimeMessage.Attachments.Any(), - IsRead = !isUnread, - IsFlagged = isFlagged, - IsFocused = isFocused, - InReplyTo = mimeMessage.InReplyTo, - MessageId = mimeMessage.MessageId, - References = mimeMessage.References.GetReferences() - }; - - return new Tuple>(mailCopy, mimeMessage, message.LabelIds); + return EntityExtensions.GetFinalAliasList(currentAccount.Aliases, remoteAliases); } } } diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index 4028dc9c..6ab9ef2d 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -46,6 +46,7 @@ namespace Wino.Core.Integration.Processors Task UpdateFolderLastSyncDateAsync(Guid folderId); Task> GetExistingFoldersAsync(Guid accountId); + Task UpdateAccountAliasesAsync(Guid accountId, List aliases); } public interface IGmailChangeProcessor : IDefaultChangeProcessor @@ -176,5 +177,8 @@ namespace Wino.Core.Integration.Processors public Task UpdateAccountAsync(MailAccount account) => AccountService.UpdateAccountAsync(account); + + public Task UpdateAccountAliasesAsync(Guid accountId, List aliases) + => AccountService.UpdateAccountAliases(accountId, aliases); } } diff --git a/Wino.Core/Services/AccountService.cs b/Wino.Core/Services/AccountService.cs index d7c9ab67..ab665f30 100644 --- a/Wino.Core/Services/AccountService.cs +++ b/Wino.Core/Services/AccountService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Diagnostics; @@ -241,7 +240,11 @@ namespace Wino.Core.Services // By default all accounts must have at least 1 primary alias to create drafts for. // If there's no alias, create one from the existing account address. Migration doesn't exists to create one for older messages. - var aliases = await Connection.Table().ToListAsync().ConfigureAwait(false); + var aliases = await Connection + .Table() + .Where(a => a.AccountId == accountId) + .ToListAsync() + .ConfigureAwait(false); if (!aliases.Any()) { @@ -350,17 +353,24 @@ namespace Wino.Core.Services public async Task UpdateAccountAsync(MailAccount account) { - if (account.Preferences == null) - { - Debugger.Break(); - } - - await Connection.UpdateAsync(account.Preferences); - await Connection.UpdateAsync(account); + await Connection.UpdateAsync(account.Preferences).ConfigureAwait(false); + await Connection.UpdateAsync(account).ConfigureAwait(false); ReportUIChange(new AccountUpdatedMessage(account)); } + public async Task UpdateAccountAliases(Guid accountId, List aliases) + { + // Delete existing ones. + await Connection.Table().DeleteAsync(a => a.AccountId == accountId).ConfigureAwait(false); + + // Insert new ones. + foreach (var alias in aliases) + { + await Connection.InsertAsync(alias).ConfigureAwait(false); + } + } + public async Task CreateAccountAsync(MailAccount account, TokenInformation tokenInformation, CustomServerInformation customServerInformation) { Guard.IsNotNull(account); diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 5a7a6838..57da147d 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -103,6 +103,24 @@ namespace Wino.Core.Synchronizers Account.ProfilePictureBase64 = base64ProfilePicture; } + // Sync aliases + + var sendAsListRequest = _gmailService.Users.Settings.SendAs.List("me"); + var sendAsListResponse = await sendAsListRequest.ExecuteAsync(); + + var updatedAliases = sendAsListResponse.GetMailAliases(Account); + + bool shouldUpdateAliases = + Account.Aliases.Any(a => updatedAliases.Any(b => a.Id == b.Id) == false) || + updatedAliases.Any(a => Account.Aliases.Any(b => a.Id == b.Id) == false); + + if (shouldUpdateAliases) + { + Account.Aliases = updatedAliases; + + await _gmailChangeProcessor.UpdateAccountAliasesAsync(Account.Id, updatedAliases); + } + if (shouldUpdateAccountProfile) { await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); From f1154058ba8bba563c02143a0c96513838ac1716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 17 Aug 2024 00:03:45 +0200 Subject: [PATCH 10/28] Fix ascending download for messages. QQ server issue will be handled later. --- Wino.Core/Synchronizers/ImapSynchronizer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index e0ef6810..5624edf4 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -922,7 +922,7 @@ namespace Wino.Core.Synchronizers } // In case of the high input, we'll batch them by 50 to reflect changes quickly. - var batchedMissingMailIds = missingMailIds.Batch(50).Select(a => new UniqueIdSet(a, SortOrder.Ascending)); + var batchedMissingMailIds = missingMailIds.Batch(50).Select(a => new UniqueIdSet(a, SortOrder.Descending)); foreach (var batchMissingMailIds in batchedMissingMailIds) { From abff8504276fdc3c87aad98ad7c1e282dce9735e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 17 Aug 2024 03:43:37 +0200 Subject: [PATCH 11/28] Managing account aliases and profile synchronization for outlook and gmail. --- Wino.Core.Domain/Entities/MailAccount.cs | 7 +- Wino.Core.Domain/Entities/MailAccountAlias.cs | 19 +- .../Enums/AccountCreationDialogState.cs | 3 +- Wino.Core.Domain/Enums/SynchronizationType.cs | 1 + Wino.Core.Domain/Enums/WinoPage.cs | 1 + .../Interfaces/IAccountService.cs | 33 +++- .../Interfaces/IBaseSynchronizer.cs | 8 + .../Interfaces/ICreateAccountAliasDialog.cs | 9 + Wino.Core.Domain/Interfaces/IDialogService.cs | 1 + .../Accounts/AccountCreationDialogResult.cs | 2 +- .../Models/Accounts/ProfileInformation.cs | 9 + .../Models/Accounts/ProviderDetail.cs | 1 - .../Synchronization/SynchronizationResult.cs | 11 +- .../Translations/en_US/resources.json | 24 +++ Wino.Core.Domain/Translator.Designer.cs | 122 ++++++++++++- .../Extensions/GoogleIntegratorExtensions.cs | 11 +- .../Processors/DefaultChangeProcessor.cs | 7 +- Wino.Core/MenuItems/AccountMenuItem.cs | 4 +- Wino.Core/Services/AccountService.cs | 78 +++++---- Wino.Core/Synchronizers/BaseSynchronizer.cs | 50 +++++- Wino.Core/Synchronizers/GmailSynchronizer.cs | 91 ++++------ .../Synchronizers/OutlookSynchronizer.cs | 65 ++----- .../AccountDetailsPageViewModel.cs | 6 +- .../AccountManagementViewModel.cs | 50 ++++-- .../AliasManagementPageViewModel.cs | 115 ++++++++++++ .../Data/BreadcrumbNavigationItemViewModel.cs | 26 +-- .../Wino.Mail.ViewModels.csproj | 1 + Wino.Mail/App.xaml | 135 ++++++++------- Wino.Mail/App.xaml.cs | 1 + Wino.Mail/AppThemes/Mica.xaml | 7 +- Wino.Mail/Dialogs/AccountCreationDialog.xaml | 7 + .../Dialogs/CreateAccountAliasDialog.xaml | 40 +++++ .../Dialogs/CreateAccountAliasDialog.xaml.cs | 30 ++++ Wino.Mail/Dialogs/NewAccountDialog.xaml | 8 - Wino.Mail/Dialogs/NewAccountDialog.xaml.cs | 6 +- Wino.Mail/Services/DialogService.cs | 12 ++ Wino.Mail/Services/WinoNavigationService.cs | 2 + Wino.Mail/Styles/Colors.xaml | 3 + .../Abstract/AliasManagementPageAbstract.cs | 6 + .../Views/Account/AccountDetailsPage.xaml | 12 ++ Wino.Mail/Views/ComposePage.xaml | 1 - .../Views/NewAccountManagementPage.xaml.cs | 1 + .../Views/Settings/AliasManagementPage.xaml | 163 ++++++++++++++++++ .../Settings/AliasManagementPage.xaml.cs | 12 ++ Wino.Mail/Wino.Mail.csproj | 18 ++ .../SynchronizationRequestHandler.cs | 2 +- 46 files changed, 949 insertions(+), 272 deletions(-) create mode 100644 Wino.Core.Domain/Interfaces/ICreateAccountAliasDialog.cs create mode 100644 Wino.Core.Domain/Models/Accounts/ProfileInformation.cs create mode 100644 Wino.Mail.ViewModels/AliasManagementPageViewModel.cs create mode 100644 Wino.Mail/Dialogs/CreateAccountAliasDialog.xaml create mode 100644 Wino.Mail/Dialogs/CreateAccountAliasDialog.xaml.cs create mode 100644 Wino.Mail/Views/Abstract/AliasManagementPageAbstract.cs create mode 100644 Wino.Mail/Views/Settings/AliasManagementPage.xaml create mode 100644 Wino.Mail/Views/Settings/AliasManagementPage.xaml.cs diff --git a/Wino.Core.Domain/Entities/MailAccount.cs b/Wino.Core.Domain/Entities/MailAccount.cs index 60568d32..c1bc43a1 100644 --- a/Wino.Core.Domain/Entities/MailAccount.cs +++ b/Wino.Core.Domain/Entities/MailAccount.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using SQLite; using Wino.Core.Domain.Enums; @@ -48,7 +47,7 @@ namespace Wino.Core.Domain.Entities /// /// Base64 encoded profile picture of the account. /// - public string ProfilePictureBase64 { get; set; } + public string Base64ProfilePictureData { get; set; } /// /// Gets or sets the listing order of the account in the accounts list. @@ -84,8 +83,8 @@ namespace Wino.Core.Domain.Entities /// It's only synchronized for Gmail right now. /// Other provider types are manually added by users and not verified. /// - [Ignore] - public List Aliases { get; set; } + //[Ignore] + //public List Aliases { get; set; } /// /// Account preferences. diff --git a/Wino.Core.Domain/Entities/MailAccountAlias.cs b/Wino.Core.Domain/Entities/MailAccountAlias.cs index f1f0b623..8d103744 100644 --- a/Wino.Core.Domain/Entities/MailAccountAlias.cs +++ b/Wino.Core.Domain/Entities/MailAccountAlias.cs @@ -32,6 +32,13 @@ namespace Wino.Core.Domain.Entities /// public bool IsPrimary { get; set; } + /// + /// Whether this alias is the root alias for the account. + /// Root alias means the first alias that was created for the account. + /// It can't be deleted or changed. + /// + public bool IsRootAlias { get; set; } + /// /// Whether the alias is verified by the server. /// Non-verified aliases will show an info tip to users during sending. @@ -40,6 +47,11 @@ namespace Wino.Core.Domain.Entities /// public bool IsVerified { get; set; } + /// + /// Root aliases can't be deleted. + /// + public bool CanDelete => !IsRootAlias; + public override bool Equals(object obj) { if (obj == null || GetType() != obj.GetType()) @@ -51,17 +63,20 @@ namespace Wino.Core.Domain.Entities AliasAddress == other.AliasAddress && ReplyToAddress == other.ReplyToAddress && IsPrimary == other.IsPrimary && - IsVerified == other.IsVerified; + IsVerified == other.IsVerified && + IsRootAlias == other.IsRootAlias; } public override int GetHashCode() { - int hashCode = -753829106; + int hashCode = 59052167; hashCode = hashCode * -1521134295 + AccountId.GetHashCode(); hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(AliasAddress); hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(ReplyToAddress); hashCode = hashCode * -1521134295 + IsPrimary.GetHashCode(); + hashCode = hashCode * -1521134295 + IsRootAlias.GetHashCode(); hashCode = hashCode * -1521134295 + IsVerified.GetHashCode(); + hashCode = hashCode * -1521134295 + CanDelete.GetHashCode(); return hashCode; } } diff --git a/Wino.Core.Domain/Enums/AccountCreationDialogState.cs b/Wino.Core.Domain/Enums/AccountCreationDialogState.cs index af724f47..947449d4 100644 --- a/Wino.Core.Domain/Enums/AccountCreationDialogState.cs +++ b/Wino.Core.Domain/Enums/AccountCreationDialogState.cs @@ -9,6 +9,7 @@ ManuelSetupWaiting, TestingConnection, AutoDiscoverySetup, - AutoDiscoveryInProgress + AutoDiscoveryInProgress, + FetchingProfileInformation } } diff --git a/Wino.Core.Domain/Enums/SynchronizationType.cs b/Wino.Core.Domain/Enums/SynchronizationType.cs index c95b7d52..927016d6 100644 --- a/Wino.Core.Domain/Enums/SynchronizationType.cs +++ b/Wino.Core.Domain/Enums/SynchronizationType.cs @@ -7,5 +7,6 @@ Inbox, // Only Inbox Custom, // Only sync folders that are specified in the options. Full, // Synchronize everything + UpdateProfile, // Only update profile information } } diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs index 2a7264a2..cf3b5292 100644 --- a/Wino.Core.Domain/Enums/WinoPage.cs +++ b/Wino.Core.Domain/Enums/WinoPage.cs @@ -23,5 +23,6 @@ LanguageTimePage, AppPreferencesPage, SettingOptionsPage, + AliasManagementPage } } diff --git a/Wino.Core.Domain/Interfaces/IAccountService.cs b/Wino.Core.Domain/Interfaces/IAccountService.cs index 926a65d6..e7196653 100644 --- a/Wino.Core.Domain/Interfaces/IAccountService.cs +++ b/Wino.Core.Domain/Interfaces/IAccountService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Wino.Core.Domain.Entities; +using Wino.Core.Domain.Models.Accounts; namespace Wino.Core.Domain.Interfaces { @@ -101,12 +102,42 @@ namespace Wino.Core.Domain.Interfaces /// AccountId-OrderNumber pair for all accounts. Task UpdateAccountOrdersAsync(Dictionary accountIdOrderPair); + /// + /// Returns the account aliases. + /// + /// Account id. + /// A list of MailAccountAlias that has e-mail aliases. + Task> GetAccountAliasesAsync(Guid accountId); + /// /// Updated account's aliases. /// /// Account id to update aliases for. /// Full list of updated aliases. /// - Task UpdateAccountAliases(Guid accountId, List aliases); + Task UpdateAccountAliasesAsync(Guid accountId, List aliases); + + /// + /// Delete account alias. + /// + /// Alias to remove. + Task DeleteAccountAliasAsync(Guid aliasId); + + /// + /// Updated profile information of the account. + /// + /// Account id to update info for. + /// Info data. + /// + Task UpdateProfileInformationAsync(Guid accountId, ProfileInformation profileInformation); + + + /// + /// Creates a root + primary alias for the account. + /// This is only called when the account is created. + /// + /// Account id. + /// Address to create root primary alias from. + Task CreateRootAliasAsync(Guid accountId, string address); } } diff --git a/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs b/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs index 21ca1f8a..e6ccd2a1 100644 --- a/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs +++ b/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using MailKit; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; @@ -43,6 +44,13 @@ namespace Wino.Core.Domain.Interfaces /// Result summary of synchronization. Task SynchronizeAsync(SynchronizationOptions options, CancellationToken cancellationToken = default); + /// + /// Synchronizes profile information with the server. + /// Sender name and + /// + /// + Task SynchronizeProfileInformationAsync(); + /// /// Downloads a single MIME message from the server and saves it to disk. /// diff --git a/Wino.Core.Domain/Interfaces/ICreateAccountAliasDialog.cs b/Wino.Core.Domain/Interfaces/ICreateAccountAliasDialog.cs new file mode 100644 index 00000000..d27a0229 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/ICreateAccountAliasDialog.cs @@ -0,0 +1,9 @@ +using Wino.Core.Domain.Entities; + +namespace Wino.Core.Domain.Interfaces +{ + public interface ICreateAccountAliasDialog + { + public MailAccountAlias CreatedAccountAlias { get; set; } + } +} diff --git a/Wino.Core.Domain/Interfaces/IDialogService.cs b/Wino.Core.Domain/Interfaces/IDialogService.cs index a2986bd9..9d5fad1b 100644 --- a/Wino.Core.Domain/Interfaces/IDialogService.cs +++ b/Wino.Core.Domain/Interfaces/IDialogService.cs @@ -53,5 +53,6 @@ namespace Wino.Core.Domain.Interfaces /// /// Signature information. Null if canceled. Task ShowSignatureEditorDialog(AccountSignature signatureModel = null); + Task ShowCreateAccountAliasDialogAsync(); } } diff --git a/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs b/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs index a1081b9e..428d1307 100644 --- a/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs +++ b/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs @@ -2,5 +2,5 @@ namespace Wino.Core.Domain.Models.Accounts { - public record AccountCreationDialogResult(MailProviderType ProviderType, string AccountName, string SenderName, string AccountColorHex = ""); + public record AccountCreationDialogResult(MailProviderType ProviderType, string AccountName, string AccountColorHex = ""); } diff --git a/Wino.Core.Domain/Models/Accounts/ProfileInformation.cs b/Wino.Core.Domain/Models/Accounts/ProfileInformation.cs new file mode 100644 index 00000000..b9fb5677 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/ProfileInformation.cs @@ -0,0 +1,9 @@ +namespace Wino.Core.Domain.Models.Accounts +{ + /// + /// Encapsulates the profile information of an account. + /// + /// Display sender name for the account. + /// Base 64 encoded profile picture data of the account. Thumbnail size. + public record ProfileInformation(string SenderName, string Base64ProfilePictureData); +} diff --git a/Wino.Core.Domain/Models/Accounts/ProviderDetail.cs b/Wino.Core.Domain/Models/Accounts/ProviderDetail.cs index 4f794749..dc9a5824 100644 --- a/Wino.Core.Domain/Models/Accounts/ProviderDetail.cs +++ b/Wino.Core.Domain/Models/Accounts/ProviderDetail.cs @@ -14,7 +14,6 @@ namespace Wino.Core.Domain.Models.Accounts public string ProviderImage => $"ms-appx:///Assets/Providers/{Type}.png"; public bool IsSupported => Type == MailProviderType.Outlook || Type == MailProviderType.Gmail || Type == MailProviderType.IMAP4; - public bool RequireSenderNameOnCreationDialog => Type != MailProviderType.IMAP4; public ProviderDetail(MailProviderType type) { diff --git a/Wino.Core.Domain/Models/Synchronization/SynchronizationResult.cs b/Wino.Core.Domain/Models/Synchronization/SynchronizationResult.cs index 19e45eb7..885a4b9b 100644 --- a/Wino.Core.Domain/Models/Synchronization/SynchronizationResult.cs +++ b/Wino.Core.Domain/Models/Synchronization/SynchronizationResult.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; namespace Wino.Core.Domain.Models.Synchronization @@ -15,14 +16,18 @@ namespace Wino.Core.Domain.Models.Synchronization /// It's ignored in serialization. Client should not react to this. /// [JsonIgnore] - public IEnumerable DownloadedMessages { get; set; } = new List(); + public IEnumerable DownloadedMessages { get; set; } = []; + + public ProfileInformation ProfileInformation { get; set; } + public SynchronizationCompletedState CompletedState { get; set; } public static SynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success }; - public static SynchronizationResult Completed(IEnumerable downloadedMessages) - => new() { DownloadedMessages = downloadedMessages, CompletedState = SynchronizationCompletedState.Success }; + public static SynchronizationResult Completed(IEnumerable downloadedMessages, ProfileInformation profileInformation = null) + => new() { DownloadedMessages = downloadedMessages, ProfileInformation = profileInformation, CompletedState = SynchronizationCompletedState.Success }; public static SynchronizationResult Canceled => new() { CompletedState = SynchronizationCompletedState.Canceled }; + public static SynchronizationResult Failed => new() { CompletedState = SynchronizationCompletedState.Failed }; } } diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 2f6699c7..b77392dc 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -3,6 +3,7 @@ "AccountCreationDialog_Initializing": "initializing", "AccountCreationDialog_PreparingFolders": "We are getting folder information at the moment.", "AccountCreationDialog_SigninIn": "Account information is being saved.", + "AccountCreationDialog_FetchingProfileInformation": "Fetching profile details.", "AccountEditDialog_Message": "Account Name", "AccountEditDialog_Title": "Edit Account", "AccountPickerDialog_Title": "Pick an account", @@ -64,6 +65,14 @@ "CustomThemeBuilder_WallpaperTitle": "Set custom wallpaper", "DialogMessage_AccountLimitMessage": "You have reached the account creation limit.\nWould you like to purchase 'Unlimited Account' add-on to continue?", "DialogMessage_AccountLimitTitle": "Account Limit Reached", + "DialogMessage_AliasExistsTitle": "Existing Alias", + "DialogMessage_AliasExistsMessage": "This alias is already in use.", + "DialogMessage_InvalidAliasTitle": "Invalid Alias", + "DialogMessage_InvalidAliasMessage": "This alias is not valid. Make sure all addresses of the alias are valid e-mail addresses.", + "DialogMessage_CantDeleteRootAliasTitle": "Can't Delete Alias", + "DialogMessage_CantDeleteRootAliasMessage": "Root alias can't be deleted. This is your main identity associated with your account setup.", + "DialogMessage_AliasCreatedTitle": "Created New Alias", + "DialogMessage_AliasCreatedMessage": "New alias is succesfully created.", "DialogMessage_CleanupFolderMessage": "Do you want to permanently delete all the mails in this folder?", "DialogMessage_CleanupFolderTitle": "Cleanup Folder", "DialogMessage_ComposerMissingRecipientMessage": "Message has no recipient.", @@ -92,6 +101,12 @@ "DialogMessage_UnsubscribeConfirmationGoToWebsiteConfirmButton": "Go to website", "DialogMessage_UnsubscribeConfirmationMailtoMessage": "Do you want to stop getting messages from {0}? Wino will unsubscribe for you by sending an email from your email account to {1}.", "Dialog_DontAskAgain": "Don't ask again", + "CreateAccountAliasDialog_Title": "Create Account Alias", + "CreateAccountAliasDialog_Description": "Make sure your outgoing server allows sending mails from this alias.", + "CreateAccountAliasDialog_AliasAddress": "Address", + "CreateAccountAliasDialog_AliasAddressPlaceholder": "eg. support@mydomain.com", + "CreateAccountAliasDialog_ReplyToAddress": "Reply-To Address", + "CreateAccountAliasDialog_ReplyToAddressPlaceholder": "admin@mydomain.com", "DiscordChannelDisclaimerMessage": "Wino doesn't have it's own Discord server, but special 'wino-mail' channel is hosted at 'Developer Sanctuary' server.\nTo get the updates about Wino please join Developer Sanctuary server and follow 'wino-mail' channel under 'Community Projects'\n\nYou will be directed to server URL since Discord doesn't support channel invites.", "DiscordChannelDisclaimerTitle": "Important Discord Information", "Draft": "Draft", @@ -113,6 +128,7 @@ "Exception_CustomThemeMissingName": "You must provide a name.", "Exception_CustomThemeMissingWallpaper": "You must provide a custom background image.", "Exception_FailedToSynchronizeFolders": "Failed to synchronize folders", + "Exception_FailedToSynchronizeProfileInformation": "Failed to synchronize profile information", "Exception_GoogleAuthCallbackNull": "Callback uri is null on activation.", "Exception_GoogleAuthCorruptedCode": "Corrupted authorization response.", "Exception_GoogleAuthError": "OAuth authorization error: {0}", @@ -253,6 +269,7 @@ "Info_UnsubscribeLinkInvalidMessage": "This unsubscribe link is invalid. Failed to unsubscribe from the list.", "Info_UnsubscribeSuccessMessage": "Successfully unsubscribed from {0}.", "Info_UnsubscribeErrorMessage": "Failed to unsubscribe", + "Info_CantDeletePrimaryAliasMessage": "Primary alias can't be deleted. Please change your alias before deleting this one", "ImapAdvancedSetupDialog_AuthenticationMethod": "Authentication method", "ImapAdvancedSetupDialog_ConnectionSecurity": "Connection security", "ImapAuthenticationMethod_Auto": "Auto", @@ -396,6 +413,8 @@ "SettingsFolderSync_Title": "Folder Synchronization", "SettingsFolderOptions_Title": "Folder Configuration", "SettingsFolderOptions_Description": "Change individual folder settings like enable/disable sync or show/hide unread badge.", + "SettingsManageAliases_Title": "Aliases", + "SettingsManageAliases_Description": "See e-mail aliases assigned for this account, update or delete them.", "SettingsHoverActionCenter": "Center Action", "SettingsHoverActionLeft": "Left Action", "SettingsHoverActionRight": "Right Action", @@ -406,6 +425,11 @@ "SettingsLanguageTime_Title": "Language & Time", "SettingsLanguageTime_Description": "Wino display language, preferred time format.", "CategoriesFolderNameOverride": "Categories", + "AccountAlias_Column_Verified": "Verified", + "AccountAlias_Column_Alias": "Alias", + "AccountAlias_Column_IsPrimaryAlias": "Primary", + "AccountAlias_Disclaimer_FirstLine": "Wino can only import aliases for your Gmail accounts.", + "AccountAlias_Disclaimer_SecondLine": "If you want to use aliases for your Outlook or IMAP account, please add them yourself.", "MoreFolderNameOverride": "More", "SettingsOptions_Title": "Settings", "SettingsLinkAccounts_Description": "Merge multiple accounts into one. See mails from one Inbox together.", diff --git a/Wino.Core.Domain/Translator.Designer.cs b/Wino.Core.Domain/Translator.Designer.cs index 6fc65a03..a20c9711 100644 --- a/Wino.Core.Domain/Translator.Designer.cs +++ b/Wino.Core.Domain/Translator.Designer.cs @@ -38,6 +38,11 @@ namespace Wino.Core.Domain /// public static string AccountCreationDialog_SigninIn => Resources.GetTranslatedString(@"AccountCreationDialog_SigninIn"); + /// + /// Fetching profile details. + /// + public static string AccountCreationDialog_FetchingProfileInformation => Resources.GetTranslatedString(@"AccountCreationDialog_FetchingProfileInformation"); + /// /// Account Name /// @@ -343,6 +348,46 @@ namespace Wino.Core.Domain /// public static string DialogMessage_AccountLimitTitle => Resources.GetTranslatedString(@"DialogMessage_AccountLimitTitle"); + /// + /// Existing Alias + /// + public static string DialogMessage_AliasExistsTitle => Resources.GetTranslatedString(@"DialogMessage_AliasExistsTitle"); + + /// + /// This alias is already in use. + /// + public static string DialogMessage_AliasExistsMessage => Resources.GetTranslatedString(@"DialogMessage_AliasExistsMessage"); + + /// + /// Invalid Alias + /// + public static string DialogMessage_InvalidAliasTitle => Resources.GetTranslatedString(@"DialogMessage_InvalidAliasTitle"); + + /// + /// This alias is not valid. Make sure all addresses of the alias are valid e-mail addresses. + /// + public static string DialogMessage_InvalidAliasMessage => Resources.GetTranslatedString(@"DialogMessage_InvalidAliasMessage"); + + /// + /// Can't Delete Alias + /// + public static string DialogMessage_CantDeleteRootAliasTitle => Resources.GetTranslatedString(@"DialogMessage_CantDeleteRootAliasTitle"); + + /// + /// Root alias can't be deleted. This is your main identity associated with your account setup. + /// + public static string DialogMessage_CantDeleteRootAliasMessage => Resources.GetTranslatedString(@"DialogMessage_CantDeleteRootAliasMessage"); + + /// + /// Created New Alias + /// + public static string DialogMessage_AliasCreatedTitle => Resources.GetTranslatedString(@"DialogMessage_AliasCreatedTitle"); + + /// + /// New alias is succesfully created. + /// + public static string DialogMessage_AliasCreatedMessage => Resources.GetTranslatedString(@"DialogMessage_AliasCreatedMessage"); + /// /// Do you want to permanently delete all the mails in this folder? /// @@ -434,7 +479,7 @@ namespace Wino.Core.Domain public static string DialogMessage_UnlinkAccountsConfirmationTitle => Resources.GetTranslatedString(@"DialogMessage_UnlinkAccountsConfirmationTitle"); /// - /// Missin Subject + /// Missing Subject /// public static string DialogMessage_EmptySubjectConfirmation => Resources.GetTranslatedString(@"DialogMessage_EmptySubjectConfirmation"); @@ -483,6 +528,36 @@ namespace Wino.Core.Domain /// public static string Dialog_DontAskAgain => Resources.GetTranslatedString(@"Dialog_DontAskAgain"); + /// + /// Create Account Alias + /// + public static string CreateAccountAliasDialog_Title => Resources.GetTranslatedString(@"CreateAccountAliasDialog_Title"); + + /// + /// Make sure your outgoing server allows sending mails from this alias. + /// + public static string CreateAccountAliasDialog_Description => Resources.GetTranslatedString(@"CreateAccountAliasDialog_Description"); + + /// + /// Address + /// + public static string CreateAccountAliasDialog_AliasAddress => Resources.GetTranslatedString(@"CreateAccountAliasDialog_AliasAddress"); + + /// + /// eg. support@mydomain.com + /// + public static string CreateAccountAliasDialog_AliasAddressPlaceholder => Resources.GetTranslatedString(@"CreateAccountAliasDialog_AliasAddressPlaceholder"); + + /// + /// Reply-To Address + /// + public static string CreateAccountAliasDialog_ReplyToAddress => Resources.GetTranslatedString(@"CreateAccountAliasDialog_ReplyToAddress"); + + /// + /// admin@mydomain.com + /// + public static string CreateAccountAliasDialog_ReplyToAddressPlaceholder => Resources.GetTranslatedString(@"CreateAccountAliasDialog_ReplyToAddressPlaceholder"); + /// /// Wino doesn't have it's own Discord server, but special 'wino-mail' channel is hosted at 'Developer Sanctuary' server. To get the updates about Wino please join Developer Sanctuary server and follow 'wino-mail' channel under 'Community Projects' You will be directed to server URL since Discord doesn't support channel invites. /// @@ -588,6 +663,11 @@ namespace Wino.Core.Domain /// public static string Exception_FailedToSynchronizeFolders => Resources.GetTranslatedString(@"Exception_FailedToSynchronizeFolders"); + /// + /// Failed to synchronize profile information + /// + public static string Exception_FailedToSynchronizeProfileInformation => Resources.GetTranslatedString(@"Exception_FailedToSynchronizeProfileInformation"); + /// /// Callback uri is null on activation. /// @@ -1288,6 +1368,11 @@ namespace Wino.Core.Domain /// public static string Info_UnsubscribeErrorMessage => Resources.GetTranslatedString(@"Info_UnsubscribeErrorMessage"); + /// + /// Primary alias can't be deleted. Please change your alias before deleting this one + /// + public static string Info_CantDeletePrimaryAliasMessage => Resources.GetTranslatedString(@"Info_CantDeletePrimaryAliasMessage"); + /// /// Authentication method /// @@ -2003,6 +2088,16 @@ namespace Wino.Core.Domain /// public static string SettingsFolderOptions_Description => Resources.GetTranslatedString(@"SettingsFolderOptions_Description"); + /// + /// Aliases + /// + public static string SettingsManageAliases_Title => Resources.GetTranslatedString(@"SettingsManageAliases_Title"); + + /// + /// See e-mail aliases assigned for this account, update or delete them. + /// + public static string SettingsManageAliases_Description => Resources.GetTranslatedString(@"SettingsManageAliases_Description"); + /// /// Center Action /// @@ -2053,6 +2148,31 @@ namespace Wino.Core.Domain /// public static string CategoriesFolderNameOverride => Resources.GetTranslatedString(@"CategoriesFolderNameOverride"); + /// + /// Verified + /// + public static string AccountAlias_Column_Verified => Resources.GetTranslatedString(@"AccountAlias_Column_Verified"); + + /// + /// Alias + /// + public static string AccountAlias_Column_Alias => Resources.GetTranslatedString(@"AccountAlias_Column_Alias"); + + /// + /// Primary + /// + public static string AccountAlias_Column_IsPrimaryAlias => Resources.GetTranslatedString(@"AccountAlias_Column_IsPrimaryAlias"); + + /// + /// Wino can only import aliases for your Gmail accounts. + /// + public static string AccountAlias_Disclaimer_FirstLine => Resources.GetTranslatedString(@"AccountAlias_Disclaimer_FirstLine"); + + /// + /// If you want to use aliases for your Outlook or IMAP account, please add them yourself. + /// + public static string AccountAlias_Disclaimer_SecondLine => Resources.GetTranslatedString(@"AccountAlias_Disclaimer_SecondLine"); + /// /// More /// diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index 263669ab..ed290d13 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -205,21 +205,22 @@ namespace Wino.Core.Extensions }; } - public static List GetMailAliases(this ListSendAsResponse response, MailAccount currentAccount) + public static List GetMailAliases(this ListSendAsResponse response, List currentAliases, MailAccount account) { - if (response == null || response.SendAs == null) return currentAccount.Aliases; + if (response == null || response.SendAs == null) return currentAliases; var remoteAliases = response.SendAs.Select(a => new MailAccountAlias() { - AccountId = currentAccount.Id, + AccountId = account.Id, AliasAddress = a.SendAsEmail, IsPrimary = a.IsPrimary.GetValueOrDefault(), - ReplyToAddress = string.IsNullOrEmpty(a.ReplyToAddress) ? currentAccount.Address : a.ReplyToAddress, + ReplyToAddress = string.IsNullOrEmpty(a.ReplyToAddress) ? account.Address : a.ReplyToAddress, IsVerified = string.IsNullOrEmpty(a.VerificationStatus) ? true : a.VerificationStatus == "accepted", + IsRootAlias = account.Address == a.SendAsEmail, Id = Guid.NewGuid() }).ToList(); - return EntityExtensions.GetFinalAliasList(currentAccount.Aliases, remoteAliases); + return EntityExtensions.GetFinalAliasList(currentAliases, remoteAliases); } } } diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index 6ab9ef2d..46116d72 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -40,6 +40,8 @@ namespace Wino.Core.Integration.Processors /// All folders. Task> GetLocalFoldersAsync(Guid accountId); + Task> GetAccountAliasesAsync(Guid accountId); + Task> GetSynchronizationFoldersAsync(SynchronizationOptions options); Task MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId); @@ -179,6 +181,9 @@ namespace Wino.Core.Integration.Processors => AccountService.UpdateAccountAsync(account); public Task UpdateAccountAliasesAsync(Guid accountId, List aliases) - => AccountService.UpdateAccountAliases(accountId, aliases); + => AccountService.UpdateAccountAliasesAsync(accountId, aliases); + + public Task> GetAccountAliasesAsync(Guid accountId) + => AccountService.GetAccountAliasesAsync(accountId); } } diff --git a/Wino.Core/MenuItems/AccountMenuItem.cs b/Wino.Core/MenuItems/AccountMenuItem.cs index 5cba23d3..c06da2f5 100644 --- a/Wino.Core/MenuItems/AccountMenuItem.cs +++ b/Wino.Core/MenuItems/AccountMenuItem.cs @@ -50,7 +50,7 @@ namespace Wino.Core.MenuItems public string Base64ProfilePicture { get => Parameter.Name; - set => SetProperty(Parameter.ProfilePictureBase64, value, Parameter, (u, n) => u.ProfilePictureBase64 = n); + set => SetProperty(Parameter.Base64ProfilePictureData, value, Parameter, (u, n) => u.Base64ProfilePictureData = n); } public IEnumerable HoldingAccounts => new List { Parameter }; @@ -65,7 +65,7 @@ namespace Wino.Core.MenuItems Parameter = account; AccountName = account.Name; AttentionReason = account.AttentionReason; - Base64ProfilePicture = account.ProfilePictureBase64; + Base64ProfilePicture = account.Base64ProfilePictureData; if (SubMenuItems == null) return; diff --git a/Wino.Core/Services/AccountService.cs b/Wino.Core/Services/AccountService.cs index ab665f30..e029e6bf 100644 --- a/Wino.Core/Services/AccountService.cs +++ b/Wino.Core/Services/AccountService.cs @@ -9,6 +9,7 @@ using SqlKata; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Extensions; using Wino.Messaging.Client.Accounts; using Wino.Messaging.UI; @@ -226,43 +227,37 @@ namespace Wino.Core.Services if (account.MergedInboxId != null) account.MergedInbox = await GetMergedInboxInformationAsync(account.MergedInboxId.Value); - // Load aliases - account.Aliases = await GetAccountAliases(account.Id, account.Address); - account.Preferences = await GetAccountPreferencesAsync(account.Id); } return accounts; } - private async Task> GetAccountAliases(Guid accountId, string primaryAccountAddress) + public async Task CreateRootAliasAsync(Guid accountId, string address) { - // By default all accounts must have at least 1 primary alias to create drafts for. - // If there's no alias, create one from the existing account address. Migration doesn't exists to create one for older messages. - - var aliases = await Connection - .Table() - .Where(a => a.AccountId == accountId) - .ToListAsync() - .ConfigureAwait(false); - - if (!aliases.Any()) + var rootAlias = new MailAccountAlias() { - var primaryAccountAlias = new MailAccountAlias() - { - Id = Guid.NewGuid(), - AccountId = accountId, - IsPrimary = true, - AliasAddress = primaryAccountAddress, - ReplyToAddress = primaryAccountAddress, - IsVerified = true, - }; + AccountId = accountId, + AliasAddress = address, + IsPrimary = true, + IsRootAlias = true, + IsVerified = true, + ReplyToAddress = address, + Id = Guid.NewGuid() + }; - await Connection.InsertAsync(primaryAccountAlias).ConfigureAwait(false); - aliases.Add(primaryAccountAlias); - } + await Connection.InsertAsync(rootAlias).ConfigureAwait(false); - return aliases; + Log.Information("Created root alias for the account {AccountId}", accountId); + } + + public async Task> GetAccountAliasesAsync(Guid accountId) + { + var query = new Query(nameof(MailAccountAlias)) + .Where(nameof(MailAccountAlias.AccountId), accountId) + .OrderByDesc(nameof(MailAccountAlias.IsRootAlias)); + + return await Connection.QueryAsync(query.GetRawQuery()).ConfigureAwait(false); } private Task GetMergedInboxInformationAsync(Guid mergedInboxId) @@ -277,6 +272,7 @@ namespace Wino.Core.Services await Connection.Table().Where(a => a.AccountId == account.Id).DeleteAsync(); await Connection.Table().DeleteAsync(a => a.MailAccountId == account.Id); await Connection.Table().DeleteAsync(a => a.MailAccountId == account.Id); + await Connection.Table().DeleteAsync(a => a.AccountId == account.Id); // Account belongs to a merged inbox. // In case of there'll be a single account in the merged inbox, remove the merged inbox as well. @@ -327,6 +323,19 @@ namespace Wino.Core.Services ReportUIChange(new AccountRemovedMessage(account)); } + public async Task UpdateProfileInformationAsync(Guid accountId, ProfileInformation profileInformation) + { + var account = await GetAccountAsync(accountId).ConfigureAwait(false); + + if (account != null) + { + account.SenderName = profileInformation.SenderName; + account.Base64ProfilePictureData = profileInformation.Base64ProfilePictureData; + + await UpdateAccountAsync(account).ConfigureAwait(false); + } + } + public async Task GetAccountAsync(Guid accountId) { var account = await Connection.Table().FirstOrDefaultAsync(a => a.Id == accountId); @@ -359,7 +368,7 @@ namespace Wino.Core.Services ReportUIChange(new AccountUpdatedMessage(account)); } - public async Task UpdateAccountAliases(Guid accountId, List aliases) + public async Task UpdateAccountAliasesAsync(Guid accountId, List aliases) { // Delete existing ones. await Connection.Table().DeleteAsync(a => a.AccountId == accountId).ConfigureAwait(false); @@ -371,6 +380,17 @@ namespace Wino.Core.Services } } + public async Task DeleteAccountAliasAsync(Guid aliasId) + { + // Create query to delete alias. + + var query = new Query("MailAccountAlias") + .Where("Id", aliasId) + .AsDelete(); + + await Connection.ExecuteAsync(query.GetRawQuery()).ConfigureAwait(false); + } + public async Task CreateAccountAsync(MailAccount account, TokenInformation tokenInformation, CustomServerInformation customServerInformation) { Guard.IsNotNull(account); @@ -424,7 +444,7 @@ namespace Wino.Core.Services // Outlook token cache is managed by MSAL. // Don't save it to database. - if (tokenInformation != null && account.ProviderType != MailProviderType.Outlook) + if (tokenInformation != null && (account.ProviderType != MailProviderType.Outlook || account.ProviderType == MailProviderType.Office365)) await Connection.InsertAsync(tokenInformation); } diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs index 055a4bb4..11c107c6 100644 --- a/Wino.Core/Synchronizers/BaseSynchronizer.cs +++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs @@ -13,6 +13,7 @@ using Wino.Core.Domain; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Integration; @@ -71,10 +72,16 @@ namespace Wino.Core.Synchronizers public abstract Task ExecuteNativeRequestsAsync(IEnumerable> batchedRequests, CancellationToken cancellationToken = default); /// - /// Refreshed remote mail account profile if possible. - /// Aliases, profile pictures, mailbox settings will be handled in this step. + /// Refreshes remote mail account profile if possible. + /// Profile picture, sender name and mailbox settings (todo) will be handled in this step. /// - protected virtual Task SynchronizeProfileInformationAsync() => Task.CompletedTask; + public virtual Task SynchronizeProfileInformationAsync() => default; + + /// + /// Refreshes the aliases of the account. + /// Only available for Gmail right now. + /// + protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask; /// /// Returns the base64 encoded profile picture of the account from the given URL. @@ -100,6 +107,33 @@ namespace Wino.Core.Synchronizers /// Synchronization result that contains summary of the sync. protected abstract Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default); + /// + /// Safely updates account's profile information. + /// Database changes are reflected after this call. + /// Null returns mean that the operation failed. + /// + private async Task SynchronizeProfileInformationInternalAsync() + { + try + { + var profileInformation = await SynchronizeProfileInformationAsync(); + + if (profileInformation != null) + { + Account.SenderName = profileInformation.SenderName; + Account.Base64ProfilePictureData = profileInformation.Base64ProfilePictureData; + } + + return profileInformation; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to update profile information for account '{Name}'", Account.Name); + } + + return null; + } + /// /// Batches network requests, executes them, and does the needed synchronization after the batch request execution. /// @@ -139,12 +173,16 @@ namespace Wino.Core.Synchronizers await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken); - if (options.Type == SynchronizationType.Full) + if (options.Type == SynchronizationType.UpdateProfile) { - // Refresh profile information and mailbox settings on full synchronization. + // Refresh profile information on full synchronization. // Exceptions here is not critical. Therefore, they are ignored. - await SynchronizeProfileInformationAsync(); + var newprofileInformation = await SynchronizeProfileInformationInternalAsync(); + + if (newprofileInformation == null) return SynchronizationResult.Failed; + + return SynchronizationResult.Completed(null, newprofileInformation); } // Let servers to finish their job. Sometimes the servers doesn't respond immediately. diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 57da147d..8dd48eab 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -19,6 +19,7 @@ using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Requests; using Wino.Core.Domain.Models.Synchronization; @@ -68,67 +69,45 @@ namespace Wino.Core.Synchronizers public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => _googleHttpClient; - protected override async Task SynchronizeProfileInformationAsync() + public override async Task SynchronizeProfileInformationAsync() { - // Gmail profile info synchronizes Sender Name, Alias and Profile Picture. + var profileRequest = _peopleService.People.Get("people/me"); + profileRequest.PersonFields = "names,photos"; - try + string senderName = string.Empty, base64ProfilePicture = string.Empty; + + var userProfile = await profileRequest.ExecuteAsync(); + + senderName = userProfile.Names?.FirstOrDefault()?.DisplayName ?? Account.SenderName; + + var profilePicture = userProfile.Photos?.FirstOrDefault()?.Url ?? string.Empty; + + if (!string.IsNullOrEmpty(profilePicture)) { - var profileRequest = _peopleService.People.Get("people/me"); - profileRequest.PersonFields = "names,photos"; - - string senderName = Account.SenderName, base64ProfilePicture = Account.ProfilePictureBase64; - - var userProfile = await profileRequest.ExecuteAsync(); - - senderName = userProfile.Names?.FirstOrDefault()?.DisplayName ?? Account.SenderName; - - var profilePicture = userProfile.Photos?.FirstOrDefault()?.Url ?? string.Empty; - - if (!string.IsNullOrEmpty(profilePicture)) - { - base64ProfilePicture = await GetProfilePictureBase64EncodedAsync(profilePicture).ConfigureAwait(false); - } - - bool shouldUpdateAccountProfile = (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) - || (!string.IsNullOrEmpty(profilePicture) && Account.ProfilePictureBase64 != base64ProfilePicture); - - if (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) - { - Account.SenderName = senderName; - } - - if (!string.IsNullOrEmpty(profilePicture) && Account.ProfilePictureBase64 != base64ProfilePicture) - { - Account.ProfilePictureBase64 = base64ProfilePicture; - } - - // Sync aliases - - var sendAsListRequest = _gmailService.Users.Settings.SendAs.List("me"); - var sendAsListResponse = await sendAsListRequest.ExecuteAsync(); - - var updatedAliases = sendAsListResponse.GetMailAliases(Account); - - bool shouldUpdateAliases = - Account.Aliases.Any(a => updatedAliases.Any(b => a.Id == b.Id) == false) || - updatedAliases.Any(a => Account.Aliases.Any(b => a.Id == b.Id) == false); - - if (shouldUpdateAliases) - { - Account.Aliases = updatedAliases; - - await _gmailChangeProcessor.UpdateAccountAliasesAsync(Account.Id, updatedAliases); - } - - if (shouldUpdateAccountProfile) - { - await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); - } + base64ProfilePicture = await GetProfilePictureBase64EncodedAsync(profilePicture).ConfigureAwait(false); } - catch (Exception ex) + + return new ProfileInformation(senderName, base64ProfilePicture); + } + + protected override async Task SynchronizeAliasesAsync() + { + // Sync aliases + + var sendAsListRequest = _gmailService.Users.Settings.SendAs.List("me"); + var sendAsListResponse = await sendAsListRequest.ExecuteAsync(); + + var localAliases = await _gmailChangeProcessor.GetAccountAliasesAsync(Account.Id).ConfigureAwait(false); + + var updatedAliases = sendAsListResponse.GetMailAliases(localAliases, Account); + + bool shouldUpdateAliases = + localAliases.Any(a => updatedAliases.Any(b => a.Id == b.Id) == false) || + updatedAliases.Any(a => localAliases.Any(b => a.Id == b.Id) == false); + + if (shouldUpdateAliases) { - Logger.Error(ex, "Error while synchronizing profile information for {Name}", Account.Name); + await _gmailChangeProcessor.UpdateAccountAliasesAsync(Account.Id, updatedAliases); } } diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 7080e9c7..90bc66f5 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -23,6 +23,7 @@ using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Requests; using Wino.Core.Domain.Models.Synchronization; @@ -479,70 +480,28 @@ namespace Wino.Core.Synchronizers /// Base64 encoded profile picture. private async Task GetUserProfilePictureAsync() { - try - { - var photoStream = await _graphClient.Me.Photos["48x48"].Content.GetAsync(); + var photoStream = await _graphClient.Me.Photos["48x48"].Content.GetAsync(); - using var memoryStream = new MemoryStream(); - await photoStream.CopyToAsync(memoryStream); - var byteArray = memoryStream.ToArray(); + using var memoryStream = new MemoryStream(); + await photoStream.CopyToAsync(memoryStream); + var byteArray = memoryStream.ToArray(); - return Convert.ToBase64String(byteArray); - } - catch (Exception ex) - { - Log.Error(ex, "Error occurred while getting user profile picture."); - return string.Empty; - } + return Convert.ToBase64String(byteArray); } private async Task GetSenderNameAsync() { - try - { - var userInfo = await _graphClient.Users["me"].GetAsync(); + var userInfo = await _graphClient.Users["me"].GetAsync(); - return userInfo.DisplayName; - } - catch (Exception ex) - { - Log.Error(ex, "Failed to get sender name."); - return string.Empty; - } + return userInfo.DisplayName; } - protected override async Task SynchronizeProfileInformationAsync() + public override async Task SynchronizeProfileInformationAsync() { - try - { - // Outlook profile info synchronizes Sender Name and Profile Picture. - string senderName = Account.SenderName, base64ProfilePicture = Account.ProfilePictureBase64; + var profilePictureData = await GetUserProfilePictureAsync().ConfigureAwait(false); + var senderName = await GetSenderNameAsync().ConfigureAwait(false); - var profilePictureData = await GetUserProfilePictureAsync().ConfigureAwait(false); - senderName = await GetSenderNameAsync().ConfigureAwait(false); - - bool shouldUpdateAccountProfile = (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) - || (!string.IsNullOrEmpty(profilePictureData) && Account.ProfilePictureBase64 != base64ProfilePicture); - - if (!string.IsNullOrEmpty(profilePictureData) && Account.ProfilePictureBase64 != profilePictureData) - { - Account.ProfilePictureBase64 = profilePictureData; - } - - if (!string.IsNullOrEmpty(senderName) && Account.SenderName != senderName) - { - Account.SenderName = senderName; - } - - if (shouldUpdateAccountProfile) - { - await _outlookChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false); - } - } - catch (Exception ex) - { - Log.Error(ex, "Failed to synchronize profile information for {Name}", Account.Name); - } + return new ProfileInformation(senderName, profilePictureData); } #region Mail Integration diff --git a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs index eb8e0fc0..306f3bff 100644 --- a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs @@ -56,7 +56,11 @@ namespace Wino.Mail.ViewModels [RelayCommand] private void EditSignature() - => Messenger.Send(new BreadcrumbNavigationRequested("Signature", WinoPage.SignatureManagementPage, Account.Id)); + => Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsSignature_Title, WinoPage.SignatureManagementPage, Account.Id)); + + [RelayCommand] + private void EditAliases() + => Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id)); public Task FolderSyncToggledAsync(IMailItemFolder folderStructure, bool isEnabled) => _folderService.ChangeFolderSynchronizationStateAsync(folderStructure.Id, isEnabled); diff --git a/Wino.Mail.ViewModels/AccountManagementViewModel.cs b/Wino.Mail.ViewModels/AccountManagementViewModel.cs index e503b99b..367ca141 100644 --- a/Wino.Mail.ViewModels/AccountManagementViewModel.cs +++ b/Wino.Mail.ViewModels/AccountManagementViewModel.cs @@ -154,15 +154,12 @@ namespace Wino.Mail.ViewModels { creationDialog = _dialogService.GetAccountCreationDialog(accountCreationDialogResult.ProviderType); - // _accountService.ExternalAuthenticationAuthenticator = _authenticationProvider.GetAuthenticator(accountCreationDialogResult.ProviderType); - CustomServerInformation customServerInformation = null; createdAccount = new MailAccount() { ProviderType = accountCreationDialogResult.ProviderType, Name = accountCreationDialogResult.AccountName, - SenderName = accountCreationDialogResult.SenderName, AccountColorHex = accountCreationDialogResult.AccountColorHex, Id = Guid.NewGuid() }; @@ -208,30 +205,59 @@ namespace Wino.Mail.ViewModels await _accountService.CreateAccountAsync(createdAccount, tokenInformation, customServerInformation); // Local account has been created. - // Create new synchronizer and start synchronization. + + // Start profile information synchronization. + // Profile info is not updated in the database yet. + + var profileSyncOptions = new SynchronizationOptions() + { + AccountId = createdAccount.Id, + Type = SynchronizationType.UpdateProfile + }; + + var profileSynchronizationResponse = await _winoServerConnectionManager.GetResponseAsync(new NewSynchronizationRequested(profileSyncOptions, SynchronizationSource.Client)); + + var profileSynchronizationResult = profileSynchronizationResponse.Data; + + if (profileSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation); + + createdAccount.SenderName = profileSynchronizationResult.ProfileInformation.SenderName; + createdAccount.Base64ProfilePictureData = profileSynchronizationResult.ProfileInformation.Base64ProfilePictureData; + + await _accountService.UpdateProfileInformationAsync(createdAccount.Id, profileSynchronizationResult.ProfileInformation); if (creationDialog is ICustomServerAccountCreationDialog customServerAccountCreationDialog) customServerAccountCreationDialog.ShowPreparingFolders(); else creationDialog.State = AccountCreationDialogState.PreparingFolders; - var options = new SynchronizationOptions() + // Start synchronizing folders. + var folderSyncOptions = new SynchronizationOptions() { AccountId = createdAccount.Id, Type = SynchronizationType.FoldersOnly }; - var synchronizationResultResponse = await _winoServerConnectionManager.GetResponseAsync(new NewSynchronizationRequested(options, SynchronizationSource.Client)); + var folderSynchronizationResponse = await _winoServerConnectionManager.GetResponseAsync(new NewSynchronizationRequested(folderSyncOptions, SynchronizationSource.Client)); - var synchronizationResult = synchronizationResultResponse.Data; - if (synchronizationResult.CompletedState != SynchronizationCompletedState.Success) + var folderSynchronizationResult = folderSynchronizationResponse.Data; + + if (folderSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) throw new Exception(Translator.Exception_FailedToSynchronizeFolders); - // Check if Inbox folder is available for the account after synchronization. - var isInboxAvailable = await _folderService.IsInboxAvailableForAccountAsync(createdAccount.Id); + // Create root primary alias for the account. + // This is the first alias for the account and it's primary. - if (!isInboxAvailable) - throw new Exception(Translator.Exception_InboxNotAvailable); + await _accountService.CreateRootAliasAsync(createdAccount.Id, createdAccount.Address); + + // TODO: Temporary disabled. Is this even needed? Users can configure special folders manually later on if discovery fails. + // Check if Inbox folder is available for the account after synchronization. + + //var isInboxAvailable = await _folderService.IsInboxAvailableForAccountAsync(createdAccount.Id); + + //if (!isInboxAvailable) + // throw new Exception(Translator.Exception_InboxNotAvailable); // Send changes to listeners. ReportUIChange(new AccountCreatedMessage(createdAccount)); diff --git a/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs b/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs new file mode 100644 index 00000000..234cf49f --- /dev/null +++ b/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using EmailValidation; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Navigation; + +namespace Wino.Mail.ViewModels +{ + public partial class AliasManagementPageViewModel : BaseViewModel + { + private readonly IAccountService _accountService; + + public MailAccount Account { get; set; } + + [ObservableProperty] + private List accountAliases = []; + + public AliasManagementPageViewModel(IDialogService dialogService, IAccountService accountService) : base(dialogService) + { + _accountService = accountService; + } + + public override async void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + + if (parameters is Guid accountId) + Account = await _accountService.GetAccountAsync(accountId); + + if (Account == null) return; + + await LoadAliasesAsync(); + } + + private async Task LoadAliasesAsync() + { + AccountAliases = await _accountService.GetAccountAliasesAsync(Account.Id); + } + + [RelayCommand] + private async Task SetAliasPrimaryAsync(MailAccountAlias alias) + { + if (alias.IsPrimary) return; + + AccountAliases.ForEach(a => + { + a.IsPrimary = a == alias; + }); + + await _accountService.UpdateAccountAliasesAsync(Account.Id, AccountAliases); + await LoadAliasesAsync(); + } + + [RelayCommand] + private async Task AddNewAliasAsync() + { + var createdAliasDialog = await DialogService.ShowCreateAccountAliasDialogAsync(); + + if (createdAliasDialog.CreatedAccountAlias == null) return; + + var newAlias = createdAliasDialog.CreatedAccountAlias; + + // Check existence. + if (AccountAliases.Any(a => a.AliasAddress == newAlias.AliasAddress)) + { + await DialogService.ShowMessageAsync(Translator.DialogMessage_AliasExistsTitle, Translator.DialogMessage_AliasExistsMessage); + return; + } + + // Validate all addresses. + if (!EmailValidator.Validate(newAlias.ReplyToAddress) || !EmailValidator.Validate(newAlias.AliasAddress)) + { + await DialogService.ShowMessageAsync(Translator.DialogMessage_InvalidAliasMessage, Translator.DialogMessage_InvalidAliasTitle); + return; + } + + newAlias.AccountId = Account.Id; + + AccountAliases.Add(newAlias); + + await _accountService.UpdateAccountAliasesAsync(Account.Id, AccountAliases); + DialogService.InfoBarMessage(Translator.DialogMessage_AliasCreatedTitle, Translator.DialogMessage_AliasCreatedMessage, InfoBarMessageType.Success); + + await LoadAliasesAsync(); + } + + [RelayCommand] + private async Task DeleteAliasAsync(MailAccountAlias alias) + { + // Primary aliases can't be deleted. + if (alias.IsPrimary) + { + await DialogService.ShowMessageAsync(Translator.Info_CantDeletePrimaryAliasMessage, Translator.GeneralTitle_Warning); + return; + } + + // Root aliases can't be deleted. + if (alias.IsRootAlias) + { + await DialogService.ShowMessageAsync(Translator.DialogMessage_CantDeleteRootAliasTitle, Translator.DialogMessage_CantDeleteRootAliasMessage); + return; + } + + await _accountService.DeleteAccountAliasAsync(alias.Id); + await LoadAliasesAsync(); + } + } +} diff --git a/Wino.Mail.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs b/Wino.Mail.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs index c90b81aa..9ba7f782 100644 --- a/Wino.Mail.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs @@ -3,31 +3,21 @@ using Wino.Messaging.Client.Navigation; namespace Wino.Mail.ViewModels.Data { - public class BreadcrumbNavigationItemViewModel : ObservableObject + public partial class BreadcrumbNavigationItemViewModel : ObservableObject { + [ObservableProperty] + private string title; + + [ObservableProperty] + private bool isActive; + public BreadcrumbNavigationRequested Request { get; set; } public BreadcrumbNavigationItemViewModel(BreadcrumbNavigationRequested request, bool isActive) { Request = request; Title = request.PageTitle; - - this.isActive = isActive; - } - - private string title; - public string Title - { - get => title; - set => SetProperty(ref title, value); - } - - private bool isActive; - - public bool IsActive - { - get => isActive; - set => SetProperty(ref isActive, value); + IsActive = isActive; } } } diff --git a/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj b/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj index 946a3bf8..82c61360 100644 --- a/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj +++ b/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Wino.Mail/App.xaml b/Wino.Mail/App.xaml index 64b6c59e..57cb3f20 100644 --- a/Wino.Mail/App.xaml +++ b/Wino.Mail/App.xaml @@ -1,9 +1,10 @@ - + @@ -48,6 +49,14 @@ + + + -