using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Diagnostics; using CommunityToolkit.Mvvm.Messaging; using Serilog; using SqlKata; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Messaging.Client.Accounts; using Wino.Messaging.UI; using Wino.Services.Extensions; namespace Wino.Services; public class AccountService : BaseDatabaseService, IAccountService { public IAuthenticator ExternalAuthenticationAuthenticator { get; set; } private readonly ISignatureService _signatureService; private readonly IMimeFileService _mimeFileService; private readonly IPreferencesService _preferencesService; private readonly ILogger _logger = Log.ForContext(); public AccountService(IDatabaseService databaseService, ISignatureService signatureService, IMimeFileService mimeFileService, IPreferencesService preferencesService) : base(databaseService) { _signatureService = signatureService; _mimeFileService = mimeFileService; _preferencesService = preferencesService; } public async Task ClearAccountAttentionAsync(Guid accountId) { var account = await GetAccountAsync(accountId); Guard.IsNotNull(account); account.AttentionReason = AccountAttentionReason.None; await UpdateAccountAsync(account); } public async Task UpdateMergedInboxAsync(Guid mergedInboxId, IEnumerable linkedAccountIds) { // First, remove all accounts from merged inbox. await Connection.ExecuteAsync("UPDATE MailAccount SET MergedInboxId = NULL WHERE MergedInboxId = ?", mergedInboxId); // Then, add new accounts to merged inbox. var query = new Query("MailAccount") .WhereIn("Id", linkedAccountIds) .AsUpdate(new { MergedInboxId = mergedInboxId }); await Connection.ExecuteAsync(query.GetRawQuery()); WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested()); } public async Task UpdateSyncIdentifierRawAsync(Guid accountId, string syncIdentifier) { await Connection.ExecuteAsync("UPDATE MailAccount SET SynchronizationDeltaIdentifier = ? WHERE Id = ?", syncIdentifier, accountId); return syncIdentifier; } public async Task UnlinkMergedInboxAsync(Guid mergedInboxId) { var mergedInbox = await Connection.Table().FirstOrDefaultAsync(a => a.Id == mergedInboxId).ConfigureAwait(false); if (mergedInbox == null) { _logger.Warning("Could not find merged inbox with id {MergedInboxId}", mergedInboxId); return; } var query = new Query("MailAccount") .Where("MergedInboxId", mergedInboxId) .AsUpdate(new { MergedInboxId = (Guid?)null }); await Connection.ExecuteAsync(query.GetRawQuery()).ConfigureAwait(false); await Connection.DeleteAsync(mergedInbox).ConfigureAwait(false); // Change the startup entity id if it was the merged inbox. // Take the first account as startup account. if (_preferencesService.StartupEntityId == mergedInboxId) { var firstAccount = await Connection.Table().FirstOrDefaultAsync(); if (firstAccount != null) { _preferencesService.StartupEntityId = firstAccount.Id; } else { _preferencesService.StartupEntityId = null; } } WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested()); } public async Task CreateMergeAccountsAsync(MergedInbox mergedInbox, IEnumerable accountsToMerge) { if (mergedInbox == null) return; // 0. Give the merged inbox a new Guid. mergedInbox.Id = Guid.NewGuid(); var accountFolderDictionary = new Dictionary>(); // 1. Make all folders in the accounts unsticky. We will stick them based on common special folder types. foreach (var account in accountsToMerge) { var accountFolderList = new List(); var folders = await Connection.Table().Where(a => a.MailAccountId == account.Id).ToListAsync(); foreach (var folder in folders) { accountFolderList.Add(folder); folder.IsSticky = false; await Connection.UpdateAsync(folder); } accountFolderDictionary.Add(account, accountFolderList); } // 2. Find the common special folders and stick them. // Only following types will be considered as common special folder. SpecialFolderType[] commonSpecialTypes = [ SpecialFolderType.Inbox, SpecialFolderType.Sent, SpecialFolderType.Draft, SpecialFolderType.Archive, SpecialFolderType.Junk, SpecialFolderType.Deleted ]; foreach (var type in commonSpecialTypes) { var isCommonType = accountFolderDictionary .Select(a => a.Value) .Where(a => a.Any(a => a.SpecialFolderType == type)) .Count() == accountsToMerge.Count(); if (isCommonType) { foreach (var account in accountsToMerge) { var folder = accountFolderDictionary[account].FirstOrDefault(a => a.SpecialFolderType == type); if (folder != null) { folder.IsSticky = true; await Connection.UpdateAsync(folder); } } } } // 3. Insert merged inbox and assign accounts. await Connection.InsertAsync(mergedInbox); foreach (var account in accountsToMerge) { account.MergedInboxId = mergedInbox.Id; await Connection.UpdateAsync(account); } WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested()); } public async Task RenameMergedAccountAsync(Guid mergedInboxId, string newName) { var query = new Query("MergedInbox") .Where("Id", mergedInboxId) .AsUpdate(new { Name = newName }); await Connection.ExecuteAsync(query.GetRawQuery()); ReportUIChange(new MergedInboxRenamed(mergedInboxId, newName)); } public async Task FixTokenIssuesAsync(Guid accountId) { var account = await Connection.Table().FirstOrDefaultAsync(a => a.Id == accountId); if (account == null) return; //var authenticator = _authenticationProvider.GetAuthenticator(account.ProviderType); //// This will re-generate token. //var token = await authenticator.GenerateTokenInformationAsync(account); // TODO: Rest? // Guard.IsNotNull(token); } private Task GetAccountPreferencesAsync(Guid accountId) => Connection.Table().FirstOrDefaultAsync(a => a.AccountId == accountId); public async Task> GetAccountsAsync() { var accounts = await Connection.Table().OrderBy(a => a.Order).ToListAsync(); foreach (var account in accounts) { // Load IMAP server configuration. if (account.ProviderType == MailProviderType.IMAP4) account.ServerInformation = await GetAccountCustomServerInformationAsync(account.Id); // Load MergedInbox information. if (account.MergedInboxId != null) account.MergedInbox = await GetMergedInboxInformationAsync(account.MergedInboxId.Value); account.Preferences = await GetAccountPreferencesAsync(account.Id); } return accounts; } public async Task CreateRootAliasAsync(Guid accountId, string address) { var rootAlias = new MailAccountAlias() { AccountId = accountId, AliasAddress = address, IsPrimary = true, IsRootAlias = true, IsVerified = true, ReplyToAddress = address, Id = Guid.NewGuid() }; await Connection.InsertAsync(rootAlias).ConfigureAwait(false); 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) => Connection.Table().FirstOrDefaultAsync(a => a.Id == mergedInboxId); public async Task DeleteAccountMailCacheAsync(Guid accountId, AccountCacheResetReason accountCacheResetReason) { var deleteQuery = new Query("MailCopy") .WhereIn("Id", q => q .From("MailCopy") .Select("Id") .WhereIn("FolderId", q2 => q2 .From("MailItemFolder") .Select("Id") .Where("MailAccountId", accountId) )).AsDelete(); await Connection.ExecuteAsync(deleteQuery.GetRawQuery()); WeakReferenceMessenger.Default.Send(new AccountCacheResetMessage(accountId, accountCacheResetReason)); } public async Task DeleteAccountAsync(MailAccount account) { await DeleteAccountMailCacheAsync(account.Id, AccountCacheResetReason.AccountRemoval); 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. if (account.MergedInboxId != null) { var mergedInboxAccountCount = await Connection.Table().Where(a => a.MergedInboxId == account.MergedInboxId.Value).CountAsync(); // There will be only one account in the merged inbox. Remove the link for the other account as well. if (mergedInboxAccountCount == 2) { var query = new Query("MailAccount") .Where("MergedInboxId", account.MergedInboxId.Value) .AsUpdate(new { MergedInboxId = (Guid?)null }); await Connection.ExecuteAsync(query.GetRawQuery()).ConfigureAwait(false); } } if (account.ProviderType == MailProviderType.IMAP4) await Connection.Table().DeleteAsync(a => a.AccountId == account.Id); if (account.Preferences != null) await Connection.DeleteAsync(account.Preferences); await Connection.DeleteAsync(account); await _mimeFileService.DeleteUserMimeCacheAsync(account.Id).ConfigureAwait(false); // Clear out or set up a new startup entity id. // Next account after the deleted one will be the startup account. if (_preferencesService.StartupEntityId == account.Id || _preferencesService.StartupEntityId == account.MergedInboxId) { var firstNonStartupAccount = await Connection.Table().FirstOrDefaultAsync(a => a.Id != account.Id); if (firstNonStartupAccount != null) { _preferencesService.StartupEntityId = firstNonStartupAccount.Id; } else { _preferencesService.StartupEntityId = null; } } 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; if (string.IsNullOrEmpty(account.Address)) { account.Address = profileInformation.AccountAddress; } // Forcefully add or update a contact data with the provided information. var accountContact = new AccountContact() { Address = account.Address, Name = account.SenderName, Base64ContactPicture = account.Base64ProfilePictureData, IsRootContact = true }; await Connection.InsertOrReplaceAsync(accountContact).ConfigureAwait(false); await UpdateAccountAsync(account).ConfigureAwait(false); } } public async Task GetAccountAsync(Guid accountId) { var account = await Connection.Table().FirstOrDefaultAsync(a => a.Id == accountId); if (account == null) { _logger.Error("Could not find account with id {AccountId}", accountId); } else { if (account.ProviderType == MailProviderType.IMAP4) account.ServerInformation = await GetAccountCustomServerInformationAsync(account.Id); account.Preferences = await GetAccountPreferencesAsync(account.Id); return account; } return null; } public Task GetAccountCustomServerInformationAsync(Guid accountId) => Connection.Table().FirstOrDefaultAsync(a => a.AccountId == accountId); public async Task UpdateAccountAsync(MailAccount account) { await Connection.UpdateAsync(account.Preferences).ConfigureAwait(false); await Connection.UpdateAsync(account).ConfigureAwait(false); ReportUIChange(new AccountUpdatedMessage(account)); } public async Task UpdateAccountCustomServerInformationAsync(CustomServerInformation customServerInformation) { await Connection.UpdateAsync(customServerInformation).ConfigureAwait(false); } public async Task UpdateAccountAliasesAsync(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 UpdateRemoteAliasInformationAsync(MailAccount account, List remoteAccountAliases) { var localAliases = await GetAccountAliasesAsync(account.Id).ConfigureAwait(false); var rootAlias = localAliases.Find(a => a.IsRootAlias); foreach (var remoteAlias in remoteAccountAliases) { var existingAlias = localAliases.Find(a => a.AccountId == account.Id && a.AliasAddress == remoteAlias.AliasAddress); if (existingAlias == null) { // Create new alias. var newAlias = new MailAccountAlias() { AccountId = account.Id, AliasAddress = remoteAlias.AliasAddress, IsPrimary = remoteAlias.IsPrimary, IsVerified = remoteAlias.IsVerified, ReplyToAddress = remoteAlias.ReplyToAddress, Id = Guid.NewGuid(), IsRootAlias = remoteAlias.IsRootAlias, AliasSenderName = remoteAlias.AliasSenderName }; await Connection.InsertAsync(newAlias); localAliases.Add(newAlias); } else { // Update existing alias. existingAlias.IsPrimary = remoteAlias.IsPrimary; existingAlias.IsVerified = remoteAlias.IsVerified; existingAlias.ReplyToAddress = remoteAlias.ReplyToAddress; existingAlias.AliasSenderName = remoteAlias.AliasSenderName; await Connection.UpdateAsync(existingAlias); } } // Make sure there is only 1 root alias and 1 primary alias selected. bool shouldUpdatePrimary = localAliases.Count(a => a.IsPrimary) != 1; bool shouldUpdateRoot = localAliases.Count(a => a.IsRootAlias) != 1; if (shouldUpdatePrimary) { localAliases.ForEach(a => a.IsPrimary = false); var idealPrimaryAlias = localAliases.Find(a => a.AliasAddress == account.Address) ?? localAliases.First(); idealPrimaryAlias.IsPrimary = true; await Connection.UpdateAsync(idealPrimaryAlias).ConfigureAwait(false); } if (shouldUpdateRoot) { localAliases.ForEach(a => a.IsRootAlias = false); var idealRootAlias = localAliases.Find(a => a.AliasAddress == account.Address) ?? localAliases.First(); idealRootAlias.IsRootAlias = true; await Connection.UpdateAsync(idealRootAlias).ConfigureAwait(false); } } 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, CustomServerInformation customServerInformation) { Guard.IsNotNull(account); var accountCount = await Connection.Table().CountAsync(); // If there are no accounts before this one, set it as startup account. if (accountCount == 0) { _preferencesService.StartupEntityId = account.Id; } else { // Set the order of the account. // This can be changed by the user later in manage accounts page. account.Order = accountCount; } await Connection.InsertAsync(account); var preferences = new MailAccountPreferences() { Id = Guid.NewGuid(), AccountId = account.Id, IsNotificationsEnabled = true, ShouldAppendMessagesToSentFolder = false }; // iCloud does not appends sent messages to sent folder automatically. if (account.SpecialImapProvider == SpecialImapProvider.iCloud || account.SpecialImapProvider == SpecialImapProvider.Yahoo) { preferences.ShouldAppendMessagesToSentFolder = true; } account.Preferences = preferences; // Outlook & Office 365 supports Focused inbox. Enabled by default. bool isMicrosoftProvider = account.ProviderType == MailProviderType.Outlook; // TODO: This should come from account settings API. // Wino doesn't have MailboxSettings yet. if (isMicrosoftProvider) account.Preferences.IsFocusedInboxEnabled = true; // Setup default signature. var defaultSignature = await _signatureService.CreateDefaultSignatureAsync(account.Id); account.Preferences.SignatureIdForNewMessages = defaultSignature.Id; account.Preferences.SignatureIdForFollowingMessages = defaultSignature.Id; account.Preferences.IsSignatureEnabled = true; await Connection.InsertAsync(preferences); if (customServerInformation != null) await Connection.InsertAsync(customServerInformation); } //public async Task UpdateSynchronizationIdentifierAsync(Guid accountId, string newIdentifier) //{ // var account = await GetAccountAsync(accountId); // if (account == null) // { // _logger.Error("Could not find account with id {AccountId}", accountId); // return string.Empty; // } // var currentIdentifier = account.SynchronizationDeltaIdentifier; // bool shouldUpdateIdentifier = account.ProviderType == MailProviderType.Gmail ? // string.IsNullOrEmpty(currentIdentifier) ? true : !string.IsNullOrEmpty(currentIdentifier) // && ulong.TryParse(currentIdentifier, out ulong currentIdentifierValue) // && ulong.TryParse(newIdentifier, out ulong newIdentifierValue) // && newIdentifierValue > currentIdentifierValue : true; // if (shouldUpdateIdentifier) // { // account.SynchronizationDeltaIdentifier = newIdentifier; // await UpdateAccountAsync(account); // } // return account.SynchronizationDeltaIdentifier; //} public async Task UpdateAccountOrdersAsync(Dictionary accountIdOrderPair) { foreach (var pair in accountIdOrderPair) { var account = await GetAccountAsync(pair.Key); if (account == null) { _logger.Information("Could not find account with id {Key} for reordering. It may be a linked account.", pair.Key); continue; } account.Order = pair.Value; await Connection.UpdateAsync(account); } Messenger.Send(new AccountMenuItemsReordered(accountIdOrderPair)); } public async Task GetPrimaryAccountAliasAsync(Guid accountId) { var aliases = await GetAccountAliasesAsync(accountId); if (aliases == null || aliases.Count == 0) return null; return aliases.FirstOrDefault(a => a.IsPrimary) ?? aliases.First(); } public async Task IsAccountFocusedEnabledAsync(Guid accountId) { var account = await GetAccountAsync(accountId); return account.Preferences.IsFocusedInboxEnabled.GetValueOrDefault(); } public async Task IsNotificationsEnabled(Guid accountId) { var account = await GetAccountAsync(accountId); return account?.Preferences?.IsNotificationsEnabled ?? false; } }