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;