diff --git a/Wino.Core.Domain/Interfaces/IAccountService.cs b/Wino.Core.Domain/Interfaces/IAccountService.cs index d802c8d4..6b97c770 100644 --- a/Wino.Core.Domain/Interfaces/IAccountService.cs +++ b/Wino.Core.Domain/Interfaces/IAccountService.cs @@ -23,6 +23,20 @@ public interface IAccountService /// All local accounts Task> GetAccountsAsync(); + /// + /// Checks whether an account with the same display name already exists. + /// + /// Account display name. + /// Optional account id to exclude from the check. + Task AccountNameExistsAsync(string name, Guid? excludedAccountId = null); + + /// + /// Checks whether an account with the same primary address already exists. + /// + /// Primary e-mail address. + /// Optional account id to exclude from the check. + Task AccountAddressExistsAsync(string address, Guid? excludedAccountId = null); + /// /// Returns single MailAccount /// diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index fe103b57..7fe57093 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -274,10 +274,13 @@ "Dialog_DontAskAgain": "Don't ask again", "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_AccountAddressExistsMessage": "An account with the same e-mail address already exists.", + "DialogMessage_AccountExistsTitle": "Existing Account", "DialogMessage_AliasCreatedMessage": "New alias is succesfully created.", "DialogMessage_AliasCreatedTitle": "Created New Alias", "DialogMessage_AliasExistsMessage": "This alias is already in use.", "DialogMessage_AliasExistsTitle": "Existing Alias", + "DialogMessage_AccountNameExistsMessage": "An account with the same name already exists.", "DialogMessage_AliasNotSelectedMessage": "You must select an alias before sending a message.", "DialogMessage_AliasNotSelectedTitle": "Missing Alias", "DialogMessage_CantDeleteRootAliasMessage": "Root alias can't be deleted. This is your main identity associated with your account setup.", @@ -883,6 +886,7 @@ "SettingsMailCategories_Title": "Categories", "SettingsEditAccountDetails_Title": "Edit Account Details", "SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.", + "SettingsAccountDetails_NavigationTitle": "{0} details", "EditAccountDetailsPage_SaveSuccess_Title": "Changes Saved", "EditAccountDetailsPage_SaveSuccess_Message": "Your account details have been updated successfully.", "MailCategoryManagementPage_Title": "Categories", diff --git a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs index 5b407df5..c1c9e7b5 100644 --- a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs +++ b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs @@ -69,7 +69,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM [RelayCommand] private void NavigateAccountDetails(AccountProviderDetailViewModel accountDetails) { - Messenger.Send(new BreadcrumbNavigationRequested(accountDetails.Account.Name, + Messenger.Send(new BreadcrumbNavigationRequested(GetAccountDetailsTitle(accountDetails.Account), WinoPage.AccountDetailsPage, accountDetails.Account.Id)); } @@ -131,4 +131,9 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM { OnPropertyChanged(nameof(HasAccountsDefined)); } + + private static string GetAccountDetailsTitle(MailAccount account) + => !string.IsNullOrWhiteSpace(account?.Address) + ? string.Format(Translator.SettingsAccountDetails_NavigationTitle, account.Address) + : account?.Name ?? Translator.AccountDetailsPage_Title; } diff --git a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs index 10fc1f6d..b51d8109 100644 --- a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs +++ b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs @@ -149,7 +149,7 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel switch (account) { case AccountProviderDetailViewModel accountDetails: - Messenger.Send(new BreadcrumbNavigationRequested(accountDetails.Account.Name, WinoPage.AccountDetailsPage, accountDetails.Account.Id)); + Messenger.Send(new BreadcrumbNavigationRequested(GetAccountDetailsTitle(accountDetails.Account), WinoPage.AccountDetailsPage, accountDetails.Account.Id)); break; case MergedAccountProviderDetailViewModel mergedAccount: Messenger.Send(new BreadcrumbNavigationRequested(mergedAccount.MergedInbox.Name, WinoPage.MergedAccountDetailsPage, mergedAccount)); @@ -201,6 +201,11 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel }); } + private static string GetAccountDetailsTitle(MailAccount account) + => !string.IsNullOrWhiteSpace(account?.Address) + ? string.Format(Translator.SettingsAccountDetails_NavigationTitle, account.Address) + : account?.Name ?? Translator.AccountDetailsPage_Title; + private void InitializeQuickSettings() { _isInitializingSettings = true; diff --git a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs index 388b9e57..b036d94b 100644 --- a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs @@ -220,7 +220,12 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel _createdAccount.Base64ProfilePictureData = profileResult.ProfileInformation.Base64ProfilePictureData; if (!string.IsNullOrEmpty(profileResult.ProfileInformation.AccountAddress)) + { + if (await _accountService.AccountAddressExistsAsync(profileResult.ProfileInformation.AccountAddress, _createdAccount.Id).ConfigureAwait(false)) + throw new InvalidOperationException(Translator.DialogMessage_AccountAddressExistsMessage); + _createdAccount.Address = profileResult.ProfileInformation.AccountAddress; + } await _accountService.UpdateProfileInformationAsync(_createdAccount.Id, profileResult.ProfileInformation); } diff --git a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs index 49e6ff2b..9164cabe 100644 --- a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs +++ b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs @@ -34,6 +34,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel private Guid _editingAccountId; private SpecialImapProvider _editingSpecialImapProvider; private TaskCompletionSource _completionSource; + private AccountCreationDialogResult _accountCreationContext; private bool _isCompletionFinalized; private bool _localOnlyInfoShown; @@ -283,6 +284,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel _pageMode = context.Mode; _editingAccountId = context.AccountId; _completionSource = context.CompletionSource; + _accountCreationContext = context.AccountCreationDialogResult; _isCompletionFinalized = false; _localOnlyInfoShown = false; SelectedSetupTabIndex = 0; @@ -406,6 +408,13 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel ValidateImapSettings(serverInformation); ValidateCalendarModeSpecificSettings(serverInformation); + var excludedAccountId = _pageMode == ImapCalDavSettingsPageMode.Edit + ? _editingAccountId + : (Guid?)null; + + if (!await ValidateAccountUniquenessAsync(excludedAccountId).ConfigureAwait(false)) + return; + await ValidateImapConnectivityAsync(serverInformation); IsImapValidationSucceeded = true; @@ -770,6 +779,34 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel Messenger.Send(new BackBreadcrumNavigationRequested()); } + private async Task ValidateAccountUniquenessAsync(Guid? excludedAccountId) + { + var accountName = (_pageMode == ImapCalDavSettingsPageMode.Create || _pageMode == ImapCalDavSettingsPageMode.Wizard) + ? _accountCreationContext?.AccountName + : null; + + if (!string.IsNullOrWhiteSpace(accountName) && + await _accountService.AccountNameExistsAsync(accountName, excludedAccountId).ConfigureAwait(false)) + { + _mailDialogService.InfoBarMessage( + Translator.DialogMessage_AccountExistsTitle, + Translator.DialogMessage_AccountNameExistsMessage, + InfoBarMessageType.Error); + return false; + } + + if (await _accountService.AccountAddressExistsAsync(EmailAddress, excludedAccountId).ConfigureAwait(false)) + { + _mailDialogService.InfoBarMessage( + Translator.DialogMessage_AccountExistsTitle, + Translator.DialogMessage_AccountAddressExistsMessage, + InfoBarMessageType.Error); + return false; + } + + return true; + } + private async Task SaveEditFlowAsync(CustomServerInformation serverInformation) { var account = await _accountService.GetAccountAsync(_editingAccountId).ConfigureAwait(false); diff --git a/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs b/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs index 71afb9c3..ed5c2b4f 100644 --- a/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs +++ b/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; @@ -16,6 +17,8 @@ namespace Wino.Mail.ViewModels; public partial class ProviderSelectionPageViewModel : MailBaseViewModel { + private readonly IAccountService _accountService; + private readonly IDialogServiceBase _dialogService; private readonly IProviderService _providerService; private readonly INewThemeService _themeService; @@ -53,10 +56,14 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel public bool IsInitialSynchronizationWarningVisible => SelectedInitialSynchronizationRange?.IsEverything == true; public ProviderSelectionPageViewModel( + IAccountService accountService, + IDialogServiceBase dialogService, IProviderService providerService, INewThemeService themeService, WelcomeWizardContext wizardContext) { + _accountService = accountService; + _dialogService = dialogService; _providerService = providerService; _themeService = themeService; WizardContext = wizardContext; @@ -107,10 +114,19 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel } [RelayCommand] - private void Proceed() + private async Task ProceedAsync() { if (!CanProceed) return; + if (await _accountService.AccountNameExistsAsync(AccountName).ConfigureAwait(false)) + { + await _dialogService.ShowMessageAsync( + Translator.DialogMessage_AccountNameExistsMessage, + Translator.DialogMessage_AccountExistsTitle, + WinoCustomMessageDialogIcon.Warning); + return; + } + // Persist to wizard context WizardContext.SelectedProvider = SelectedProvider; WizardContext.AccountName = AccountName?.Trim(); diff --git a/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs b/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs index 9ba42445..ccc55289 100644 --- a/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs +++ b/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs @@ -15,6 +15,8 @@ namespace Wino.Mail.ViewModels; public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel { + private readonly IAccountService _accountService; + private readonly IDialogServiceBase _dialogService; private static readonly Dictionary AppPasswordHelpLinks = new() { { SpecialImapProvider.iCloud, "https://support.apple.com/en-us/102654" }, @@ -56,9 +58,13 @@ public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel : Translator.ProviderSelection_CalendarMode_CalDavDescription_Yahoo; public SpecialImapCredentialsPageViewModel( + IAccountService accountService, + IDialogServiceBase dialogService, INativeAppService nativeAppService, WelcomeWizardContext wizardContext) { + _accountService = accountService; + _dialogService = dialogService; _nativeAppService = nativeAppService; WizardContext = wizardContext; } @@ -98,10 +104,19 @@ public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel } [RelayCommand] - private void Proceed() + private async Task ProceedAsync() { if (!CanProceed) return; + if (await _accountService.AccountAddressExistsAsync(EmailAddress).ConfigureAwait(false)) + { + await _dialogService.ShowMessageAsync( + Translator.DialogMessage_AccountAddressExistsMessage, + Translator.DialogMessage_AccountExistsTitle, + WinoCustomMessageDialogIcon.Warning); + return; + } + WizardContext.DisplayName = DisplayName?.Trim(); WizardContext.EmailAddress = EmailAddress?.Trim(); WizardContext.AppSpecificPassword = AppSpecificPassword?.Trim(); diff --git a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml index 5c460e21..2a0ec6e8 100644 --- a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml @@ -127,6 +127,16 @@ Text="{x:Bind ViewModel.SenderName, Mode=TwoWay}" /> + + + + + + + > _recipientSuggestions = []; public bool SupportsPopOut => !_isPoppedOut; public event EventHandler? PopOutRequested; @@ -132,12 +131,17 @@ public sealed partial class ComposePage : ComposePageAbstract, { _ = ViewModel.ExecuteUIThread(() => { - var addresses = x.Result; + var addresses = x.Result ?? []; + _recipientSuggestions[box] = addresses; senderBox.ItemsSource = addresses; }); }); } + else + { + _recipientSuggestions[box] = []; + } } }); } @@ -293,7 +297,7 @@ public sealed partial class ComposePage : ComposePageAbstract, { base.OnNavigatedTo(e); - FocusManager.GotFocus += GlobalFocusManagerGotFocus; + // FocusManager.GotFocus += GlobalFocusManagerGotFocus; var webView = GetWebView(); @@ -335,36 +339,60 @@ public sealed partial class ComposePage : ComposePageAbstract, private async void TokenItemAdding(TokenizingTextBox sender, TokenItemAddingEventArgs args) { - // Check is valid email. - if (!EmailValidator.Validate(args.TokenText)) - { - args.Cancel = true; - ViewModel.NotifyInvalidEmail(args.TokenText); - - return; - } - var deferral = args.GetDeferral(); - - var addedItem = (sender.Tag?.ToString()) switch + try { - "ToBox" => await ViewModel.GetAddressInformationAsync(args.TokenText, ViewModel.ToItems), - "CCBox" => await ViewModel.GetAddressInformationAsync(args.TokenText, ViewModel.CCItems), - "BCCBox" => await ViewModel.GetAddressInformationAsync(args.TokenText, ViewModel.BCCItems), - _ => null - }; + var suggestedContact = GetFirstSuggestedContact(sender); + var tokenText = suggestedContact?.Address ?? args.TokenText; + var addressCollection = sender.Tag?.ToString() switch + { + "ToBox" => ViewModel.ToItems, + "CCBox" => ViewModel.CCItems, + "BCCBox" => ViewModel.BCCItems, + _ => null + }; - if (addedItem == null) - { - args.Cancel = true; - ViewModel.NotifyAddressExists(); + if (suggestedContact == null && !EmailValidator.Validate(tokenText)) + { + args.Cancel = true; + ViewModel.NotifyInvalidEmail(args.TokenText); + return; + } + + AccountContact? addedItem = null; + + if (suggestedContact != null) + { + addedItem = addressCollection?.Any(a => string.Equals(a.Address, suggestedContact.Address, StringComparison.OrdinalIgnoreCase)) == true + ? null + : suggestedContact; + } + else + { + addedItem = sender.Tag?.ToString() switch + { + "ToBox" => await ViewModel.GetAddressInformationAsync(tokenText, ViewModel.ToItems), + "CCBox" => await ViewModel.GetAddressInformationAsync(tokenText, ViewModel.CCItems), + "BCCBox" => await ViewModel.GetAddressInformationAsync(tokenText, ViewModel.BCCItems), + _ => null + }; + } + + if (addedItem == null) + { + args.Cancel = true; + ViewModel.NotifyAddressExists(); + } + else + { + args.Item = addedItem; + } } - else + finally { - args.Item = addedItem; + _recipientSuggestions[sender] = []; + deferral.Complete(); } - - deferral.Complete(); } void IRecipient.Receive(ApplicationThemeChanged message) @@ -438,6 +466,11 @@ public sealed partial class ComposePage : ComposePageAbstract, } } + private AccountContact? GetFirstSuggestedContact(TokenizingTextBox box) + => _recipientSuggestions.TryGetValue(box, out var suggestions) + ? suggestions.FirstOrDefault() + : null; + private void ComposerLoaded(object sender, RoutedEventArgs e) { if (ShouldFocusRecipients()) diff --git a/Wino.Mail.WinUI/Views/SettingsPage.xaml.cs b/Wino.Mail.WinUI/Views/SettingsPage.xaml.cs index 4d69addb..349fda6b 100644 --- a/Wino.Mail.WinUI/Views/SettingsPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/SettingsPage.xaml.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Messaging; using Microsoft.UI.Xaml.Media.Animation; using Microsoft.UI.Xaml.Navigation; using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Models.Settings; using Wino.Helpers; @@ -162,7 +163,7 @@ public sealed partial class SettingsPage : SettingsPageAbstract, DispatcherQueue.TryEnqueue(() => { - activePage.Title = message.Account.Name; + activePage.Title = GetAccountDetailsTitle(message.Account); _ = RefreshCurrentPageStateAsync(); UpdateWindowTitle(); }); @@ -249,6 +250,11 @@ public sealed partial class SettingsPage : SettingsPageAbstract, : activeTitle; } + private static string GetAccountDetailsTitle(MailAccount account) + => !string.IsNullOrWhiteSpace(account?.Address) + ? string.Format(Translator.SettingsAccountDetails_NavigationTitle, account.Address) + : account?.Name ?? Translator.AccountDetailsPage_Title; + public Task OnTitleBarSearchTextChangedAsync() { SearchSuggestions.Clear(); diff --git a/Wino.Services/AccountService.cs b/Wino.Services/AccountService.cs index 6f8defdd..f4b0ac89 100644 --- a/Wino.Services/AccountService.cs +++ b/Wino.Services/AccountService.cs @@ -240,6 +240,34 @@ public class AccountService : BaseDatabaseService, IAccountService return accounts; } + public async Task AccountNameExistsAsync(string name, Guid? excludedAccountId = null) + { + var normalizedName = name?.Trim(); + + if (string.IsNullOrWhiteSpace(normalizedName)) + return false; + + var accounts = await Connection.Table().ToListAsync().ConfigureAwait(false); + + return accounts.Any(account => + account.Id != excludedAccountId && + string.Equals(account.Name?.Trim(), normalizedName, StringComparison.OrdinalIgnoreCase)); + } + + public async Task AccountAddressExistsAsync(string address, Guid? excludedAccountId = null) + { + var normalizedAddress = address?.Trim(); + + if (string.IsNullOrWhiteSpace(normalizedAddress)) + return false; + + var accounts = await Connection.Table().ToListAsync().ConfigureAwait(false); + + return accounts.Any(account => + account.Id != excludedAccountId && + string.Equals(account.Address?.Trim(), normalizedAddress, StringComparison.OrdinalIgnoreCase)); + } + public async Task CreateRootAliasAsync(Guid accountId, string address) { var rootAlias = new MailAccountAlias() @@ -610,6 +638,12 @@ public class AccountService : BaseDatabaseService, IAccountService { Guard.IsNotNull(account); + if (await AccountNameExistsAsync(account.Name).ConfigureAwait(false)) + throw new InvalidOperationException(Translator.DialogMessage_AccountNameExistsMessage); + + if (await AccountAddressExistsAsync(account.Address).ConfigureAwait(false)) + throw new InvalidOperationException(Translator.DialogMessage_AccountAddressExistsMessage); + if (!account.CreatedAt.HasValue) { account.CreatedAt = DateTime.UtcNow;