Separation of core library from the UWP app.
This commit is contained in:
582
Wino.Services/AccountService.cs
Normal file
582
Wino.Services/AccountService.cs
Normal file
@@ -0,0 +1,582 @@
|
||||
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 IPreferencesService _preferencesService;
|
||||
|
||||
private readonly ILogger _logger = Log.ForContext<AccountService>();
|
||||
|
||||
public AccountService(IDatabaseService databaseService,
|
||||
ISignatureService signatureService,
|
||||
IPreferencesService preferencesService) : base(databaseService)
|
||||
{
|
||||
_signatureService = signatureService;
|
||||
_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<Guid> 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 UnlinkMergedInboxAsync(Guid mergedInboxId)
|
||||
{
|
||||
var mergedInbox = await Connection.Table<MergedInbox>().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<MailAccount>().FirstOrDefaultAsync();
|
||||
|
||||
if (firstAccount != null)
|
||||
{
|
||||
_preferencesService.StartupEntityId = firstAccount.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
_preferencesService.StartupEntityId = null;
|
||||
}
|
||||
}
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested());
|
||||
}
|
||||
|
||||
public async Task CreateMergeAccountsAsync(MergedInbox mergedInbox, IEnumerable<MailAccount> accountsToMerge)
|
||||
{
|
||||
if (mergedInbox == null) return;
|
||||
|
||||
// 0. Give the merged inbox a new Guid.
|
||||
mergedInbox.Id = Guid.NewGuid();
|
||||
|
||||
var accountFolderDictionary = new Dictionary<MailAccount, List<MailItemFolder>>();
|
||||
|
||||
// 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<MailItemFolder>();
|
||||
|
||||
var folders = await Connection.Table<MailItemFolder>().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<MailAccount>().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<MailAccountPreferences> GetAccountPreferencesAsync(Guid accountId)
|
||||
=> Connection.Table<MailAccountPreferences>().FirstOrDefaultAsync(a => a.AccountId == accountId);
|
||||
|
||||
public async Task<List<MailAccount>> GetAccountsAsync()
|
||||
{
|
||||
var accounts = await Connection.Table<MailAccount>().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<List<MailAccountAlias>> GetAccountAliasesAsync(Guid accountId)
|
||||
{
|
||||
var query = new Query(nameof(MailAccountAlias))
|
||||
.Where(nameof(MailAccountAlias.AccountId), accountId)
|
||||
.OrderByDesc(nameof(MailAccountAlias.IsRootAlias));
|
||||
|
||||
return await Connection.QueryAsync<MailAccountAlias>(query.GetRawQuery()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task<MergedInbox> GetMergedInboxInformationAsync(Guid mergedInboxId)
|
||||
=> Connection.Table<MergedInbox>().FirstOrDefaultAsync(a => a.Id == mergedInboxId);
|
||||
|
||||
public async Task DeleteAccountAsync(MailAccount account)
|
||||
{
|
||||
// TODO: Delete mime messages and attachments.
|
||||
// TODO: Delete token cache by underlying provider.
|
||||
|
||||
await Connection.ExecuteAsync("DELETE FROM MailCopy WHERE Id IN(SELECT Id FROM MailCopy WHERE FolderId IN (SELECT Id from MailItemFolder WHERE MailAccountId == ?))", account.Id);
|
||||
|
||||
await Connection.Table<MailItemFolder>().DeleteAsync(a => a.MailAccountId == account.Id);
|
||||
await Connection.Table<AccountSignature>().DeleteAsync(a => a.MailAccountId == account.Id);
|
||||
await Connection.Table<MailAccountAlias>().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<MailAccount>().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<CustomServerInformation>().DeleteAsync(a => a.AccountId == account.Id);
|
||||
|
||||
if (account.Preferences != null)
|
||||
await Connection.DeleteAsync(account.Preferences);
|
||||
|
||||
await Connection.DeleteAsync(account);
|
||||
|
||||
// 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<MailAccount>().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<MailAccount> GetAccountAsync(Guid accountId)
|
||||
{
|
||||
var account = await Connection.Table<MailAccount>().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<CustomServerInformation> GetAccountCustomServerInformationAsync(Guid accountId)
|
||||
=> Connection.Table<CustomServerInformation>().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 UpdateAccountAliasesAsync(Guid accountId, List<MailAccountAlias> aliases)
|
||||
{
|
||||
// Delete existing ones.
|
||||
await Connection.Table<MailAccountAlias>().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<RemoteAccountAlias> 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<MailAccount>().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
|
||||
};
|
||||
|
||||
account.Preferences = preferences;
|
||||
|
||||
// Outlook & Office 365 supports Focused inbox. Enabled by default.
|
||||
bool isMicrosoftProvider = account.ProviderType == MailProviderType.Outlook || account.ProviderType == MailProviderType.Office365;
|
||||
|
||||
// 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<string> 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<Guid, int> 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<MailAccountAlias> GetPrimaryAccountAliasAsync(Guid accountId)
|
||||
{
|
||||
var aliases = await GetAccountAliasesAsync(accountId);
|
||||
|
||||
if (aliases == null || aliases.Count == 0) return null;
|
||||
|
||||
return aliases.FirstOrDefault(a => a.IsPrimary) ?? aliases.First();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Wino.Services/ApplicationConfiguration.cs
Normal file
13
Wino.Services/ApplicationConfiguration.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class ApplicationConfiguration : IApplicationConfiguration
|
||||
{
|
||||
public const string SharedFolderName = "WinoShared";
|
||||
|
||||
public string ApplicationDataFolderPath { get; set; }
|
||||
public string PublisherSharedFolderPath { get; set; }
|
||||
public string ApplicationTempFolderPath { get; set; }
|
||||
}
|
||||
}
|
||||
22
Wino.Services/BaseDatabaseService.cs
Normal file
22
Wino.Services/BaseDatabaseService.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class BaseDatabaseService
|
||||
{
|
||||
protected IMessenger Messenger => WeakReferenceMessenger.Default;
|
||||
protected SQLiteAsyncConnection Connection => _databaseService.Connection;
|
||||
|
||||
private readonly IDatabaseService _databaseService;
|
||||
|
||||
public BaseDatabaseService(IDatabaseService databaseService)
|
||||
{
|
||||
_databaseService = databaseService;
|
||||
}
|
||||
|
||||
public void ReportUIChange<TMessage>(TMessage message) where TMessage : class, IUIMessage
|
||||
=> Messenger.Send(message);
|
||||
}
|
||||
}
|
||||
56
Wino.Services/ContactService.cs
Normal file
56
Wino.Services/ContactService.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MimeKit;
|
||||
using SqlKata;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Services.Extensions;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class ContactService : BaseDatabaseService, IContactService
|
||||
{
|
||||
public ContactService(IDatabaseService databaseService) : base(databaseService) { }
|
||||
|
||||
public Task<List<AccountContact>> GetAddressInformationAsync(string queryText)
|
||||
{
|
||||
if (queryText == null || queryText.Length < 2)
|
||||
return Task.FromResult<List<AccountContact>>(null);
|
||||
|
||||
var query = new Query(nameof(AccountContact));
|
||||
query.WhereContains("Address", queryText);
|
||||
query.OrWhereContains("Name", queryText);
|
||||
|
||||
var rawLikeQuery = query.GetRawQuery();
|
||||
|
||||
return Connection.QueryAsync<AccountContact>(rawLikeQuery);
|
||||
}
|
||||
|
||||
public Task<AccountContact> GetAddressInformationByAddressAsync(string address)
|
||||
=> Connection.Table<AccountContact>().Where(a => a.Address == address).FirstOrDefaultAsync();
|
||||
|
||||
public async Task SaveAddressInformationAsync(MimeMessage message)
|
||||
{
|
||||
var recipients = message
|
||||
.GetRecipients(true)
|
||||
.Where(a => !string.IsNullOrEmpty(a.Name) && !string.IsNullOrEmpty(a.Address));
|
||||
|
||||
var addressInformations = recipients.Select(a => new AccountContact() { Name = a.Name, Address = a.Address });
|
||||
|
||||
foreach (var info in addressInformations)
|
||||
{
|
||||
var currentContact = await GetAddressInformationByAddressAsync(info.Address).ConfigureAwait(false);
|
||||
|
||||
if (currentContact == null)
|
||||
{
|
||||
await Connection.InsertAsync(info).ConfigureAwait(false);
|
||||
}
|
||||
else if (!currentContact.IsRootContact) // Don't update root contacts. They belong to accounts.
|
||||
{
|
||||
await Connection.InsertOrReplaceAsync(info).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
Wino.Services/ContextMenuItemService.cs
Normal file
181
Wino.Services/ContextMenuItemService.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Menus;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class ContextMenuItemService : IContextMenuItemService
|
||||
{
|
||||
public virtual IEnumerable<FolderOperationMenuItem> GetFolderContextMenuActions(IBaseFolderMenuItem folderInformation)
|
||||
{
|
||||
var list = new List<FolderOperationMenuItem>();
|
||||
|
||||
if (folderInformation.IsSticky)
|
||||
list.Add(FolderOperationMenuItem.Create(FolderOperation.Unpin));
|
||||
else
|
||||
list.Add(FolderOperationMenuItem.Create(FolderOperation.Pin));
|
||||
|
||||
list.Add(FolderOperationMenuItem.Create(FolderOperation.Seperator));
|
||||
|
||||
// Following 4 items are disabled for system folders.
|
||||
|
||||
list.Add(FolderOperationMenuItem.Create(FolderOperation.Rename, !folderInformation.IsSystemFolder));
|
||||
list.Add(FolderOperationMenuItem.Create(FolderOperation.Delete, !folderInformation.IsSystemFolder));
|
||||
list.Add(FolderOperationMenuItem.Create(FolderOperation.CreateSubFolder, !folderInformation.IsSystemFolder));
|
||||
|
||||
list.Add(FolderOperationMenuItem.Create(FolderOperation.Seperator));
|
||||
|
||||
list.Add(FolderOperationMenuItem.Create(FolderOperation.Empty));
|
||||
|
||||
list.Add(FolderOperationMenuItem.Create(FolderOperation.MarkAllAsRead));
|
||||
|
||||
return list;
|
||||
}
|
||||
public virtual IEnumerable<MailOperationMenuItem> GetMailItemContextMenuActions(IEnumerable<IMailItem> selectedMailItems)
|
||||
{
|
||||
if (selectedMailItems == null)
|
||||
return default;
|
||||
|
||||
var operationList = new List<MailOperationMenuItem>();
|
||||
|
||||
// Disable archive button for Archive folder itself.
|
||||
|
||||
bool isArchiveFolder = selectedMailItems.All(a => a.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive);
|
||||
bool isDraftOrSent = selectedMailItems.All(a => a.AssignedFolder.SpecialFolderType == SpecialFolderType.Draft || a.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent);
|
||||
bool isJunkFolder = selectedMailItems.All(a => a.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk);
|
||||
|
||||
bool isSingleItem = selectedMailItems.Count() == 1;
|
||||
|
||||
IMailItem singleItem = selectedMailItems.FirstOrDefault();
|
||||
|
||||
// Archive button.
|
||||
|
||||
if (isArchiveFolder)
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.UnArchive));
|
||||
else
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.Archive));
|
||||
|
||||
// Delete button.
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.SoftDelete));
|
||||
|
||||
// Move button.
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.Move, !isDraftOrSent));
|
||||
|
||||
// Independent flag, read etc.
|
||||
if (isSingleItem)
|
||||
{
|
||||
if (singleItem.IsFlagged)
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
|
||||
else
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.SetFlag));
|
||||
|
||||
if (singleItem.IsRead)
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread));
|
||||
else
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead));
|
||||
}
|
||||
else
|
||||
{
|
||||
bool isAllRead = selectedMailItems.All(a => a.IsRead);
|
||||
bool isAllUnread = selectedMailItems.All(a => !a.IsRead);
|
||||
bool isAllFlagged = selectedMailItems.All(a => a.IsFlagged);
|
||||
bool isAllNotFlagged = selectedMailItems.All(a => !a.IsFlagged);
|
||||
|
||||
List<MailOperationMenuItem> readOperations = (isAllRead, isAllUnread) switch
|
||||
{
|
||||
(true, false) => [MailOperationMenuItem.Create(MailOperation.MarkAsUnread)],
|
||||
(false, true) => [MailOperationMenuItem.Create(MailOperation.MarkAsRead)],
|
||||
_ => [MailOperationMenuItem.Create(MailOperation.MarkAsRead), MailOperationMenuItem.Create(MailOperation.MarkAsUnread)]
|
||||
};
|
||||
operationList.AddRange(readOperations);
|
||||
|
||||
List<MailOperationMenuItem> flagsOperations = (isAllFlagged, isAllNotFlagged) switch
|
||||
{
|
||||
(true, false) => [MailOperationMenuItem.Create(MailOperation.ClearFlag)],
|
||||
(false, true) => [MailOperationMenuItem.Create(MailOperation.SetFlag)],
|
||||
_ => [MailOperationMenuItem.Create(MailOperation.SetFlag), MailOperationMenuItem.Create(MailOperation.ClearFlag)]
|
||||
};
|
||||
operationList.AddRange(flagsOperations);
|
||||
}
|
||||
|
||||
// Ignore
|
||||
if (!isDraftOrSent)
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.Ignore));
|
||||
|
||||
// Seperator
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.Seperator));
|
||||
|
||||
// Junk folder
|
||||
if (isJunkFolder)
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsNotJunk));
|
||||
else if (!isDraftOrSent)
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.MoveToJunk));
|
||||
|
||||
// TODO: Focus folder support.
|
||||
|
||||
// Remove the separator if it's the last item remaining.
|
||||
// It's creating unpleasent UI glitch.
|
||||
|
||||
if (operationList.LastOrDefault()?.Operation == MailOperation.Seperator)
|
||||
operationList.RemoveAt(operationList.Count - 1);
|
||||
|
||||
return operationList;
|
||||
}
|
||||
public virtual IEnumerable<MailOperationMenuItem> GetMailItemRenderMenuActions(IMailItem mailItem, bool isDarkEditor)
|
||||
{
|
||||
var actionList = new List<MailOperationMenuItem>();
|
||||
|
||||
bool isArchiveFolder = mailItem.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive;
|
||||
|
||||
// Add light/dark editor theme switch.
|
||||
if (isDarkEditor)
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.LightEditor));
|
||||
else
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.DarkEditor));
|
||||
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.Seperator));
|
||||
|
||||
// You can't do these to draft items.
|
||||
if (!mailItem.IsDraft)
|
||||
{
|
||||
// Reply
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.Reply));
|
||||
|
||||
// Reply All
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.ReplyAll));
|
||||
|
||||
// Forward
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.Forward));
|
||||
}
|
||||
|
||||
// Archive - Unarchive
|
||||
if (isArchiveFolder)
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.UnArchive));
|
||||
else
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.Archive));
|
||||
|
||||
// Delete
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.SoftDelete));
|
||||
|
||||
// Flag - Clear Flag
|
||||
if (mailItem.IsFlagged)
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
|
||||
else
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.SetFlag));
|
||||
|
||||
// Secondary items.
|
||||
|
||||
// Read - Unread
|
||||
if (mailItem.IsRead)
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread, true, false));
|
||||
else
|
||||
actionList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false));
|
||||
|
||||
return actionList;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Wino.Services/DatabaseService.cs
Normal file
59
Wino.Services/DatabaseService.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public interface IDatabaseService : IInitializeAsync
|
||||
{
|
||||
SQLiteAsyncConnection Connection { get; }
|
||||
}
|
||||
|
||||
public class DatabaseService : IDatabaseService
|
||||
{
|
||||
private const string DatabaseName = "Wino180.db";
|
||||
|
||||
private bool _isInitialized = false;
|
||||
private readonly IApplicationConfiguration _folderConfiguration;
|
||||
|
||||
public SQLiteAsyncConnection Connection { get; private set; }
|
||||
|
||||
public DatabaseService(IApplicationConfiguration folderConfiguration)
|
||||
{
|
||||
_folderConfiguration = folderConfiguration;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (_isInitialized)
|
||||
return;
|
||||
|
||||
var publisherCacheFolder = _folderConfiguration.PublisherSharedFolderPath;
|
||||
var databaseFileName = Path.Combine(publisherCacheFolder, DatabaseName);
|
||||
|
||||
Connection = new SQLiteAsyncConnection(databaseFileName);
|
||||
|
||||
await CreateTablesAsync();
|
||||
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
private async Task CreateTablesAsync()
|
||||
{
|
||||
await Connection.CreateTablesAsync(CreateFlags.None,
|
||||
typeof(MailCopy),
|
||||
typeof(MailItemFolder),
|
||||
typeof(MailAccount),
|
||||
typeof(AccountContact),
|
||||
typeof(CustomServerInformation),
|
||||
typeof(AccountSignature),
|
||||
typeof(MergedInbox),
|
||||
typeof(MailAccountPreferences),
|
||||
typeof(MailAccountAlias)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
120
Wino.Services/Extensions/HtmlAgilityPackExtensions.cs
Normal file
120
Wino.Services/Extensions/HtmlAgilityPackExtensions.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using HtmlAgilityPack;
|
||||
|
||||
namespace Wino.Services.Extensions
|
||||
{
|
||||
public static class HtmlAgilityPackExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Clears out the src attribute for all `img` and `v:fill` tags.
|
||||
/// </summary>
|
||||
/// <param name="document"></param>
|
||||
public static void ClearImages(this HtmlDocument document)
|
||||
{
|
||||
if (document.DocumentNode.InnerHtml.Contains("<img"))
|
||||
{
|
||||
foreach (var eachNode in document.DocumentNode.SelectNodes("//img"))
|
||||
{
|
||||
eachNode.Attributes.Remove("src");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes `style` tags from the document.
|
||||
/// </summary>
|
||||
/// <param name="document"></param>
|
||||
public static void ClearStyles(this HtmlDocument document)
|
||||
{
|
||||
document.DocumentNode
|
||||
.Descendants()
|
||||
.Where(n => n.Name.Equals("script", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Name.Equals("style", StringComparison.OrdinalIgnoreCase)
|
||||
|| n.Name.Equals("#comment", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList()
|
||||
.ForEach(n => n.Remove());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns plain text from the HTML content.
|
||||
/// </summary>
|
||||
/// <param name="htmlContent">Content to get preview from.</param>
|
||||
/// <returns>Text body for the html.</returns>
|
||||
public static string GetPreviewText(string htmlContent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(htmlContent)) return string.Empty;
|
||||
|
||||
HtmlDocument doc = new HtmlDocument();
|
||||
doc.LoadHtml(htmlContent);
|
||||
|
||||
StringWriter sw = new StringWriter();
|
||||
ConvertTo(doc.DocumentNode, sw);
|
||||
sw.Flush();
|
||||
|
||||
return sw.ToString().Replace(Environment.NewLine, "");
|
||||
}
|
||||
|
||||
private static void ConvertContentTo(HtmlNode node, TextWriter outText)
|
||||
{
|
||||
foreach (HtmlNode subnode in node.ChildNodes)
|
||||
{
|
||||
ConvertTo(subnode, outText);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConvertTo(HtmlNode node, TextWriter outText)
|
||||
{
|
||||
string html;
|
||||
switch (node.NodeType)
|
||||
{
|
||||
case HtmlNodeType.Comment:
|
||||
// don't output comments
|
||||
break;
|
||||
|
||||
case HtmlNodeType.Document:
|
||||
ConvertContentTo(node, outText);
|
||||
break;
|
||||
|
||||
case HtmlNodeType.Text:
|
||||
// script and style must not be output
|
||||
string parentName = node.ParentNode.Name;
|
||||
if (parentName == "script" || parentName == "style")
|
||||
break;
|
||||
|
||||
// get text
|
||||
html = ((HtmlTextNode)node).Text;
|
||||
|
||||
// is it in fact a special closing node output as text?
|
||||
if (HtmlNode.IsOverlappedClosingElement(html))
|
||||
break;
|
||||
|
||||
// check the text is meaningful and not a bunch of whitespaces
|
||||
if (html.Trim().Length > 0)
|
||||
{
|
||||
outText.Write(HtmlEntity.DeEntitize(html));
|
||||
}
|
||||
break;
|
||||
|
||||
case HtmlNodeType.Element:
|
||||
switch (node.Name)
|
||||
{
|
||||
case "p":
|
||||
// treat paragraphs as crlf
|
||||
outText.Write("\r\n");
|
||||
break;
|
||||
case "br":
|
||||
outText.Write("\r\n");
|
||||
break;
|
||||
}
|
||||
|
||||
if (node.HasChildNodes)
|
||||
{
|
||||
ConvertContentTo(node, outText);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
Wino.Services/Extensions/MailkitClientExtensions.cs
Normal file
191
Wino.Services/Extensions/MailkitClientExtensions.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using MailKit;
|
||||
using MimeKit;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Services.Extensions;
|
||||
|
||||
namespace Wino.Services.Extensions
|
||||
{
|
||||
public static class MailkitClientExtensions
|
||||
{
|
||||
public static char MailCopyUidSeparator = '_';
|
||||
|
||||
public static uint ResolveUid(string mailCopyId)
|
||||
{
|
||||
var splitted = mailCopyId.Split(MailCopyUidSeparator);
|
||||
|
||||
if (splitted.Length > 1 && uint.TryParse(splitted[1], out uint parsedUint)) return parsedUint;
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(mailCopyId), mailCopyId, "Invalid mailCopyId format.");
|
||||
}
|
||||
|
||||
public static string CreateUid(Guid folderId, uint messageUid)
|
||||
=> $"{folderId}{MailCopyUidSeparator}{messageUid}";
|
||||
|
||||
public static MailImportance GetImportance(this MimeMessage messageSummary)
|
||||
{
|
||||
if (messageSummary.Headers != null && messageSummary.Headers.Contains(HeaderId.Importance))
|
||||
{
|
||||
var rawImportance = messageSummary.Headers[HeaderId.Importance];
|
||||
|
||||
return rawImportance switch
|
||||
{
|
||||
"Low" => MailImportance.Low,
|
||||
"High" => MailImportance.High,
|
||||
_ => MailImportance.Normal,
|
||||
};
|
||||
}
|
||||
|
||||
return MailImportance.Normal;
|
||||
}
|
||||
|
||||
public static bool GetIsRead(this MessageFlags? flags)
|
||||
=> flags.GetValueOrDefault().HasFlag(MessageFlags.Seen);
|
||||
|
||||
public static bool GetIsFlagged(this MessageFlags? flags)
|
||||
=> flags.GetValueOrDefault().HasFlag(MessageFlags.Flagged);
|
||||
|
||||
public static string GetThreadId(this IMessageSummary messageSummary)
|
||||
{
|
||||
// First check whether we have the default values.
|
||||
|
||||
if (!string.IsNullOrEmpty(messageSummary.ThreadId))
|
||||
return messageSummary.ThreadId;
|
||||
|
||||
if (messageSummary.GMailThreadId != null)
|
||||
return messageSummary.GMailThreadId.ToString();
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public static string GetMessageId(this MimeMessage mimeMessage)
|
||||
=> mimeMessage.MessageId;
|
||||
|
||||
public static string GetReferences(this MessageIdList messageIdList)
|
||||
=> string.Join(";", messageIdList);
|
||||
|
||||
public static string GetInReplyTo(this MimeMessage mimeMessage)
|
||||
{
|
||||
if (mimeMessage.Headers.Contains(HeaderId.InReplyTo))
|
||||
{
|
||||
// Normalize if <> brackets are there.
|
||||
var inReplyTo = mimeMessage.Headers[HeaderId.InReplyTo];
|
||||
|
||||
if (inReplyTo.StartsWith("<") && inReplyTo.EndsWith(">"))
|
||||
return inReplyTo.Substring(1, inReplyTo.Length - 2);
|
||||
|
||||
return inReplyTo;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string GetPreviewText(this MimeMessage message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message.HtmlBody))
|
||||
return message.TextBody;
|
||||
else
|
||||
return HtmlAgilityPackExtensions.GetPreviewText(message.HtmlBody);
|
||||
}
|
||||
|
||||
public static MailCopy GetMailDetails(this IMessageSummary messageSummary, MailItemFolder folder, MimeMessage mime)
|
||||
{
|
||||
// MessageSummary will only have UniqueId, Flags, ThreadId.
|
||||
// Other properties are extracted directly from the MimeMessage.
|
||||
|
||||
// IMAP doesn't have unique id for mails.
|
||||
// All mails are mapped to specific folders with incremental Id.
|
||||
// Uid 1 may belong to different messages in different folders, but can never be
|
||||
// same for different messages in same folders.
|
||||
// Here we create arbitrary Id that maps the Id of the message with Folder UniqueId.
|
||||
// When folder becomes invalid, we'll clear out these MailCopies as well.
|
||||
|
||||
var messageUid = CreateUid(folder.Id, messageSummary.UniqueId.Id);
|
||||
var previewText = mime.GetPreviewText();
|
||||
|
||||
var copy = new MailCopy()
|
||||
{
|
||||
Id = messageUid,
|
||||
CreationDate = mime.Date.UtcDateTime,
|
||||
ThreadId = messageSummary.GetThreadId(),
|
||||
MessageId = mime.GetMessageId(),
|
||||
Subject = mime.Subject,
|
||||
IsRead = messageSummary.Flags.GetIsRead(),
|
||||
IsFlagged = messageSummary.Flags.GetIsFlagged(),
|
||||
PreviewText = previewText,
|
||||
FromAddress = GetActualSenderAddress(mime),
|
||||
FromName = GetActualSenderName(mime),
|
||||
IsFocused = false,
|
||||
Importance = mime.GetImportance(),
|
||||
References = mime.References?.GetReferences(),
|
||||
InReplyTo = mime.GetInReplyTo(),
|
||||
HasAttachments = mime.Attachments.Any(),
|
||||
FileId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
// TODO: Name and Address parsing should be handled better.
|
||||
// At some point Wino needs better contact management.
|
||||
|
||||
public static string GetActualSenderName(MimeMessage message)
|
||||
{
|
||||
if (message == null)
|
||||
return string.Empty;
|
||||
|
||||
return message.From.Mailboxes.FirstOrDefault()?.Name ?? message.Sender?.Name ?? Translator.UnknownSender;
|
||||
|
||||
// From MimeKit
|
||||
|
||||
// The "From" header specifies the author(s) of the message.
|
||||
// If more than one MimeKit.MailboxAddress is added to the list of "From" addresses,
|
||||
// the MimeKit.MimeMessage.Sender should be set to the single MimeKit.MailboxAddress
|
||||
// of the personal actually sending the message.
|
||||
|
||||
// Also handle: https://stackoverflow.com/questions/46474030/mailkit-from-address
|
||||
|
||||
//if (message.Sender != null)
|
||||
// return string.IsNullOrEmpty(message.Sender.Name) ? message.Sender.Address : message.Sender.Name;
|
||||
//else if (message.From?.Mailboxes != null)
|
||||
//{
|
||||
// var firstAvailableName = message.From.Mailboxes.FirstOrDefault(a => !string.IsNullOrEmpty(a.Name))?.Name;
|
||||
|
||||
// if (string.IsNullOrEmpty(firstAvailableName))
|
||||
// {
|
||||
// var firstAvailableAddress = message.From.Mailboxes.FirstOrDefault(a => !string.IsNullOrEmpty(a.Address))?.Address;
|
||||
|
||||
// if (!string.IsNullOrEmpty(firstAvailableAddress))
|
||||
// {
|
||||
// return firstAvailableAddress;
|
||||
// }
|
||||
// }
|
||||
|
||||
// return firstAvailableName;
|
||||
//}
|
||||
|
||||
//// No sender, no from, I don't know what to do.
|
||||
//return Translator.UnknownSender;
|
||||
}
|
||||
|
||||
// TODO: This is wrong.
|
||||
public static string GetActualSenderAddress(MimeMessage message)
|
||||
{
|
||||
return message.From.Mailboxes.FirstOrDefault()?.Address ?? message.Sender?.Address ?? Translator.UnknownSender;
|
||||
//if (mime == null)
|
||||
// return string.Empty;
|
||||
|
||||
//bool hasSingleFromMailbox = mime.From.Mailboxes.Count() == 1;
|
||||
|
||||
//if (hasSingleFromMailbox)
|
||||
// return mime.From.Mailboxes.First().GetAddress(idnEncode: true);
|
||||
//else if (mime.Sender != null)
|
||||
// return mime.Sender.GetAddress(idnEncode: true);
|
||||
//else
|
||||
// return Translator.UnknownSender;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Wino.Services/Extensions/SqlKataExtensions.cs
Normal file
15
Wino.Services/Extensions/SqlKataExtensions.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using SqlKata;
|
||||
using SqlKata.Compilers;
|
||||
|
||||
namespace Wino.Services.Extensions
|
||||
{
|
||||
public static class SqlKataExtensions
|
||||
{
|
||||
private static SqliteCompiler Compiler = new SqliteCompiler();
|
||||
|
||||
public static string GetRawQuery(this Query query)
|
||||
{
|
||||
return Compiler.Compile(query).ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
684
Wino.Services/FolderService.cs
Normal file
684
Wino.Services/FolderService.cs
Normal file
@@ -0,0 +1,684 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Serilog;
|
||||
using SqlKata;
|
||||
using Wino.Core.Domain;
|
||||
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.MenuItems;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Messaging.UI;
|
||||
using Wino.Services.Extensions;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class FolderService : BaseDatabaseService, IFolderService
|
||||
{
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ILogger _logger = Log.ForContext<FolderService>();
|
||||
|
||||
private readonly SpecialFolderType[] gmailCategoryFolderTypes =
|
||||
[
|
||||
SpecialFolderType.Promotions,
|
||||
SpecialFolderType.Social,
|
||||
SpecialFolderType.Updates,
|
||||
SpecialFolderType.Forums,
|
||||
SpecialFolderType.Personal
|
||||
];
|
||||
|
||||
public FolderService(IDatabaseService databaseService,
|
||||
IAccountService accountService) : base(databaseService)
|
||||
{
|
||||
_accountService = accountService;
|
||||
}
|
||||
|
||||
public async Task ChangeStickyStatusAsync(Guid folderId, bool isSticky)
|
||||
=> await Connection.ExecuteAsync("UPDATE MailItemFolder SET IsSticky = ? WHERE Id = ?", isSticky, folderId);
|
||||
|
||||
public async Task<int> GetFolderNotificationBadgeAsync(Guid folderId)
|
||||
{
|
||||
var folder = await GetFolderAsync(folderId);
|
||||
|
||||
if (folder == null || !folder.ShowUnreadCount) return default;
|
||||
|
||||
var account = await _accountService.GetAccountAsync(folder.MailAccountId);
|
||||
|
||||
if (account == null) return default;
|
||||
|
||||
var query = new Query("MailCopy")
|
||||
.Where("FolderId", folderId)
|
||||
.SelectRaw("count (DISTINCT Id)");
|
||||
|
||||
// If focused inbox is enabled, we need to check if this is the inbox folder.
|
||||
if (account.Preferences.IsFocusedInboxEnabled.GetValueOrDefault() && folder.SpecialFolderType == SpecialFolderType.Inbox)
|
||||
{
|
||||
query.Where("IsFocused", 1);
|
||||
}
|
||||
|
||||
// Draft and Junk folders are not counted as unread. They must return the item count instead.
|
||||
if (folder.SpecialFolderType != SpecialFolderType.Draft && folder.SpecialFolderType != SpecialFolderType.Junk)
|
||||
{
|
||||
query.Where("IsRead", 0);
|
||||
}
|
||||
|
||||
return await Connection.ExecuteScalarAsync<int>(query.GetRawQuery());
|
||||
}
|
||||
|
||||
public async Task<AccountFolderTree> GetFolderStructureForAccountAsync(Guid accountId, bool includeHiddenFolders)
|
||||
{
|
||||
var account = await _accountService.GetAccountAsync(accountId);
|
||||
|
||||
if (account == null)
|
||||
throw new ArgumentException(nameof(account));
|
||||
|
||||
var accountTree = new AccountFolderTree(account);
|
||||
|
||||
// Account folders.
|
||||
var folderQuery = Connection.Table<MailItemFolder>().Where(a => a.MailAccountId == accountId);
|
||||
|
||||
if (!includeHiddenFolders)
|
||||
folderQuery = folderQuery.Where(a => !a.IsHidden);
|
||||
|
||||
// Load child folders for each folder.
|
||||
var allFolders = await folderQuery.OrderBy(a => a.SpecialFolderType).ToListAsync();
|
||||
|
||||
if (allFolders.Any())
|
||||
{
|
||||
// Get sticky folders. Category type is always sticky.
|
||||
// Sticky folders don't have tree structure. So they can be added to the main tree.
|
||||
var stickyFolders = allFolders.Where(a => a.IsSticky && a.SpecialFolderType != SpecialFolderType.Category);
|
||||
|
||||
foreach (var stickyFolder in stickyFolders)
|
||||
{
|
||||
var childStructure = await GetChildFolderItemsRecursiveAsync(stickyFolder.Id, accountId);
|
||||
|
||||
accountTree.Folders.Add(childStructure);
|
||||
}
|
||||
|
||||
// Check whether we need special 'Categories' kind of folder.
|
||||
var categoryExists = allFolders.Any(a => a.SpecialFolderType == SpecialFolderType.Category);
|
||||
|
||||
if (categoryExists)
|
||||
{
|
||||
var categoryFolder = allFolders.First(a => a.SpecialFolderType == SpecialFolderType.Category);
|
||||
|
||||
// Construct category items under pinned items.
|
||||
var categoryFolders = allFolders.Where(a => gmailCategoryFolderTypes.Contains(a.SpecialFolderType));
|
||||
|
||||
foreach (var categoryFolderSubItem in categoryFolders)
|
||||
{
|
||||
categoryFolder.ChildFolders.Add(categoryFolderSubItem);
|
||||
}
|
||||
|
||||
accountTree.Folders.Add(categoryFolder);
|
||||
allFolders.Remove(categoryFolder);
|
||||
}
|
||||
|
||||
// Move rest of the items into virtual More folder if any.
|
||||
var nonStickyFolders = allFolders.Except(stickyFolders);
|
||||
|
||||
if (nonStickyFolders.Any())
|
||||
{
|
||||
var virtualMoreFolder = new MailItemFolder()
|
||||
{
|
||||
FolderName = Translator.More,
|
||||
SpecialFolderType = SpecialFolderType.More
|
||||
};
|
||||
|
||||
foreach (var unstickyItem in nonStickyFolders)
|
||||
{
|
||||
if (account.ProviderType == MailProviderType.Gmail)
|
||||
{
|
||||
// Gmail requires this check to not include child folders as
|
||||
// separate folder without their parent for More folder...
|
||||
|
||||
if (!string.IsNullOrEmpty(unstickyItem.ParentRemoteFolderId))
|
||||
continue;
|
||||
}
|
||||
else if (account.ProviderType == MailProviderType.Outlook || account.ProviderType == MailProviderType.Office365)
|
||||
{
|
||||
bool belongsToExistingParent = await Connection
|
||||
.Table<MailItemFolder>()
|
||||
.Where(a => unstickyItem.ParentRemoteFolderId == a.RemoteFolderId)
|
||||
.CountAsync() > 0;
|
||||
|
||||
// No need to include this as unsticky.
|
||||
if (belongsToExistingParent) continue;
|
||||
}
|
||||
|
||||
var structure = await GetChildFolderItemsRecursiveAsync(unstickyItem.Id, accountId);
|
||||
|
||||
virtualMoreFolder.ChildFolders.Add(structure);
|
||||
}
|
||||
|
||||
// Only add more if there are any.
|
||||
if (virtualMoreFolder.ChildFolders.Count > 0)
|
||||
accountTree.Folders.Add(virtualMoreFolder);
|
||||
}
|
||||
}
|
||||
|
||||
return accountTree;
|
||||
}
|
||||
|
||||
|
||||
public Task<IEnumerable<IMenuItem>> GetAccountFoldersForDisplayAsync(IAccountMenuItem accountMenuItem)
|
||||
{
|
||||
if (accountMenuItem is IMergedAccountMenuItem mergedAccountFolderMenuItem)
|
||||
{
|
||||
return GetMergedAccountFolderMenuItemsAsync(mergedAccountFolderMenuItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
return GetSingleAccountFolderMenuItemsAsync(accountMenuItem);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FolderMenuItem> GetPreparedFolderMenuItemRecursiveAsync(MailAccount account, MailItemFolder parentFolder, IMenuItem parentMenuItem)
|
||||
{
|
||||
// Localize category folder name.
|
||||
if (parentFolder.SpecialFolderType == SpecialFolderType.Category) parentFolder.FolderName = Translator.CategoriesFolderNameOverride;
|
||||
|
||||
var query = new Query(nameof(MailItemFolder))
|
||||
.Where(nameof(MailItemFolder.ParentRemoteFolderId), parentFolder.RemoteFolderId)
|
||||
.Where(nameof(MailItemFolder.MailAccountId), parentFolder.MailAccountId);
|
||||
|
||||
var preparedFolder = new FolderMenuItem(parentFolder, account, parentMenuItem);
|
||||
|
||||
var childFolders = await Connection.QueryAsync<MailItemFolder>(query.GetRawQuery()).ConfigureAwait(false);
|
||||
|
||||
if (childFolders.Any())
|
||||
{
|
||||
foreach (var subChildFolder in childFolders)
|
||||
{
|
||||
var preparedChild = await GetPreparedFolderMenuItemRecursiveAsync(account, subChildFolder, preparedFolder);
|
||||
|
||||
if (preparedChild == null) continue;
|
||||
|
||||
preparedFolder.SubMenuItems.Add(preparedChild);
|
||||
}
|
||||
}
|
||||
|
||||
return preparedFolder;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<IMenuItem>> GetSingleAccountFolderMenuItemsAsync(IAccountMenuItem accountMenuItem)
|
||||
{
|
||||
var accountId = accountMenuItem.EntityId.Value;
|
||||
var preparedFolderMenuItems = new List<IMenuItem>();
|
||||
|
||||
// Get all folders for the account. Excluding hidden folders.
|
||||
var folders = await GetVisibleFoldersAsync(accountId).ConfigureAwait(false);
|
||||
|
||||
if (!folders.Any()) return new List<IMenuItem>();
|
||||
|
||||
var mailAccount = accountMenuItem.HoldingAccounts.First();
|
||||
|
||||
var listingFolders = folders.OrderBy(a => a.SpecialFolderType);
|
||||
|
||||
var moreFolder = MailItemFolder.CreateMoreFolder();
|
||||
var categoryFolder = MailItemFolder.CreateCategoriesFolder();
|
||||
|
||||
var moreFolderMenuItem = new FolderMenuItem(moreFolder, mailAccount, accountMenuItem);
|
||||
var categoryFolderMenuItem = new FolderMenuItem(categoryFolder, mailAccount, accountMenuItem);
|
||||
|
||||
foreach (var item in listingFolders)
|
||||
{
|
||||
// Category type folders should be skipped. They will be categorized under virtual category folder.
|
||||
if (ServiceConstants.SubCategoryFolderLabelIds.Contains(item.RemoteFolderId)) continue;
|
||||
|
||||
bool skipEmptyParentRemoteFolders = mailAccount.ProviderType == MailProviderType.Gmail;
|
||||
|
||||
if (skipEmptyParentRemoteFolders && !string.IsNullOrEmpty(item.ParentRemoteFolderId)) continue;
|
||||
|
||||
// Sticky items belong to account menu item directly. Rest goes to More folder.
|
||||
IMenuItem parentFolderMenuItem = item.IsSticky ? accountMenuItem : ServiceConstants.SubCategoryFolderLabelIds.Contains(item.FolderName.ToUpper()) ? categoryFolderMenuItem : moreFolderMenuItem;
|
||||
|
||||
var preparedItem = await GetPreparedFolderMenuItemRecursiveAsync(mailAccount, item, parentFolderMenuItem).ConfigureAwait(false);
|
||||
|
||||
// Don't add menu items that are prepared for More folder. They've been included in More virtual folder already.
|
||||
// We'll add More folder later on at the end of the list.
|
||||
|
||||
if (preparedItem == null) continue;
|
||||
|
||||
if (item.IsSticky)
|
||||
{
|
||||
preparedFolderMenuItems.Add(preparedItem);
|
||||
}
|
||||
else if (parentFolderMenuItem is FolderMenuItem baseParentFolderMenuItem)
|
||||
{
|
||||
baseParentFolderMenuItem.SubMenuItems.Add(preparedItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add category folder if it's Gmail.
|
||||
if (mailAccount.ProviderType == MailProviderType.Gmail) preparedFolderMenuItems.Add(categoryFolderMenuItem);
|
||||
|
||||
// Only add More folder if there are any items in it.
|
||||
if (moreFolderMenuItem.SubMenuItems.Any()) preparedFolderMenuItems.Add(moreFolderMenuItem);
|
||||
|
||||
return preparedFolderMenuItems;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<IMenuItem>> GetMergedAccountFolderMenuItemsAsync(IMergedAccountMenuItem mergedAccountFolderMenuItem)
|
||||
{
|
||||
var holdingAccounts = mergedAccountFolderMenuItem.HoldingAccounts;
|
||||
|
||||
if (holdingAccounts == null || !holdingAccounts.Any()) return [];
|
||||
|
||||
var preparedFolderMenuItems = new List<IMenuItem>();
|
||||
|
||||
// First gather all account folders.
|
||||
// Prepare single menu items for both of them.
|
||||
|
||||
var allAccountFolders = new List<List<MailItemFolder>>();
|
||||
|
||||
foreach (var account in holdingAccounts)
|
||||
{
|
||||
var accountFolders = await GetVisibleFoldersAsync(account.Id).ConfigureAwait(false);
|
||||
|
||||
allAccountFolders.Add(accountFolders);
|
||||
}
|
||||
|
||||
var commonFolders = FindCommonFolders(allAccountFolders);
|
||||
|
||||
// Prepare menu items for common folders.
|
||||
foreach (var commonFolderType in commonFolders)
|
||||
{
|
||||
var folderItems = allAccountFolders.SelectMany(a => a.Where(b => b.SpecialFolderType == commonFolderType)).Cast<IMailItemFolder>().ToList();
|
||||
var menuItem = new MergedAccountFolderMenuItem(folderItems, null, mergedAccountFolderMenuItem.Parameter);
|
||||
|
||||
preparedFolderMenuItems.Add(menuItem);
|
||||
}
|
||||
|
||||
return preparedFolderMenuItems;
|
||||
}
|
||||
|
||||
private HashSet<SpecialFolderType> FindCommonFolders(List<List<MailItemFolder>> lists)
|
||||
{
|
||||
var allSpecialTypesExceptOther = Enum.GetValues(typeof(SpecialFolderType)).Cast<SpecialFolderType>().Where(a => a != SpecialFolderType.Other).ToList();
|
||||
|
||||
// Start with all special folder types from the first list
|
||||
var commonSpecialFolderTypes = new HashSet<SpecialFolderType>(allSpecialTypesExceptOther);
|
||||
|
||||
// Intersect with special folder types from all lists
|
||||
foreach (var list in lists)
|
||||
{
|
||||
commonSpecialFolderTypes.IntersectWith(list.Select(f => f.SpecialFolderType));
|
||||
}
|
||||
|
||||
return commonSpecialFolderTypes;
|
||||
}
|
||||
|
||||
private async Task<MailItemFolder> GetChildFolderItemsRecursiveAsync(Guid folderId, Guid accountId)
|
||||
{
|
||||
var folder = await Connection.Table<MailItemFolder>().Where(a => a.Id == folderId && a.MailAccountId == accountId).FirstOrDefaultAsync();
|
||||
|
||||
if (folder == null)
|
||||
return null;
|
||||
|
||||
var childFolders = await Connection.Table<MailItemFolder>()
|
||||
.Where(a => a.ParentRemoteFolderId == folder.RemoteFolderId && a.MailAccountId == folder.MailAccountId)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var childFolder in childFolders)
|
||||
{
|
||||
var subChild = await GetChildFolderItemsRecursiveAsync(childFolder.Id, accountId);
|
||||
folder.ChildFolders.Add(subChild);
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
public async Task<MailItemFolder> GetSpecialFolderByAccountIdAsync(Guid accountId, SpecialFolderType type)
|
||||
=> await Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.SpecialFolderType == type);
|
||||
|
||||
public async Task<MailItemFolder> GetFolderAsync(Guid folderId)
|
||||
=> await Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.Id.Equals(folderId));
|
||||
|
||||
public Task<int> GetCurrentItemCountForFolder(Guid folderId)
|
||||
=> Connection.Table<MailCopy>().Where(a => a.FolderId == folderId).CountAsync();
|
||||
|
||||
public Task<List<MailItemFolder>> GetFoldersAsync(Guid accountId)
|
||||
{
|
||||
var query = new Query(nameof(MailItemFolder))
|
||||
.Where(nameof(MailItemFolder.MailAccountId), accountId)
|
||||
.OrderBy(nameof(MailItemFolder.SpecialFolderType));
|
||||
|
||||
return Connection.QueryAsync<MailItemFolder>(query.GetRawQuery());
|
||||
}
|
||||
|
||||
public Task<List<MailItemFolder>> GetVisibleFoldersAsync(Guid accountId)
|
||||
{
|
||||
var query = new Query(nameof(MailItemFolder))
|
||||
.Where(nameof(MailItemFolder.MailAccountId), accountId)
|
||||
.Where(nameof(MailItemFolder.IsHidden), false)
|
||||
.OrderBy(nameof(MailItemFolder.SpecialFolderType));
|
||||
|
||||
return Connection.QueryAsync<MailItemFolder>(query.GetRawQuery());
|
||||
}
|
||||
|
||||
public async Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
|
||||
{
|
||||
var folder = await GetFolderAsync(folderId);
|
||||
|
||||
if (folder == null) return default;
|
||||
|
||||
var mailCopyIds = await GetMailCopyIdsByFolderIdAsync(folderId);
|
||||
|
||||
// Make sure we don't include Ids that doesn't have uid separator.
|
||||
// Local drafts might not have it for example.
|
||||
|
||||
return new List<uint>(mailCopyIds.Where(a => a.Contains(MailkitClientExtensions.MailCopyUidSeparator)).Select(a => MailkitClientExtensions.ResolveUid(a)));
|
||||
}
|
||||
|
||||
public async Task<MailAccount> UpdateSystemFolderConfigurationAsync(Guid accountId, SystemFolderConfiguration configuration)
|
||||
{
|
||||
if (configuration == null)
|
||||
throw new ArgumentNullException(nameof(configuration));
|
||||
|
||||
// Update system folders for this account.
|
||||
|
||||
await Task.WhenAll(UpdateSystemFolderInternalAsync(configuration.SentFolder, SpecialFolderType.Sent),
|
||||
UpdateSystemFolderInternalAsync(configuration.DraftFolder, SpecialFolderType.Draft),
|
||||
UpdateSystemFolderInternalAsync(configuration.JunkFolder, SpecialFolderType.Junk),
|
||||
UpdateSystemFolderInternalAsync(configuration.TrashFolder, SpecialFolderType.Deleted),
|
||||
UpdateSystemFolderInternalAsync(configuration.ArchiveFolder, SpecialFolderType.Archive));
|
||||
|
||||
|
||||
return await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task UpdateSystemFolderInternalAsync(MailItemFolder folder, SpecialFolderType assignedSpecialFolderType)
|
||||
{
|
||||
if (folder == null) return Task.CompletedTask;
|
||||
|
||||
folder.IsSticky = true;
|
||||
folder.IsSynchronizationEnabled = true;
|
||||
folder.IsSystemFolder = true;
|
||||
folder.SpecialFolderType = assignedSpecialFolderType;
|
||||
|
||||
return UpdateFolderAsync(folder);
|
||||
}
|
||||
|
||||
public async Task ChangeFolderSynchronizationStateAsync(Guid folderId, bool isSynchronizationEnabled)
|
||||
{
|
||||
var localFolder = await Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.Id == folderId);
|
||||
|
||||
if (localFolder != null)
|
||||
{
|
||||
localFolder.IsSynchronizationEnabled = isSynchronizationEnabled;
|
||||
|
||||
await UpdateFolderAsync(localFolder).ConfigureAwait(false);
|
||||
|
||||
Messenger.Send(new FolderSynchronizationEnabled(localFolder));
|
||||
}
|
||||
}
|
||||
|
||||
#region Repository Calls
|
||||
|
||||
public async Task InsertFolderAsync(MailItemFolder folder)
|
||||
{
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.Warning("Folder is null. Cannot insert.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var account = await _accountService.GetAccountAsync(folder.MailAccountId);
|
||||
|
||||
if (account == null)
|
||||
{
|
||||
_logger.Warning("Account with id {MailAccountId} does not exist. Cannot insert folder.", folder.MailAccountId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var existingFolder = await GetFolderAsync(folder.Id).ConfigureAwait(false);
|
||||
|
||||
// IMAP servers don't have unique identifier for folders all the time.
|
||||
// So we'll try to match them with remote folder id and account id relation.
|
||||
// If we have a match, we'll update the folder instead of inserting.
|
||||
|
||||
existingFolder ??= await GetFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
if (existingFolder == null)
|
||||
{
|
||||
_logger.Debug("Inserting folder {Id} - {FolderName}", folder.Id, folder.FolderName, folder.MailAccountId);
|
||||
|
||||
await Connection.InsertAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO: This is not alright. We should've updated the folder instead of inserting.
|
||||
// Now we need to match the properties that user might've set locally.
|
||||
|
||||
folder.Id = existingFolder.Id;
|
||||
folder.IsSticky = existingFolder.IsSticky;
|
||||
folder.SpecialFolderType = existingFolder.SpecialFolderType;
|
||||
folder.ShowUnreadCount = existingFolder.ShowUnreadCount;
|
||||
folder.TextColorHex = existingFolder.TextColorHex;
|
||||
folder.BackgroundColorHex = existingFolder.BackgroundColorHex;
|
||||
|
||||
_logger.Debug("Folder {Id} - {FolderName} already exists. Updating.", folder.Id, folder.FolderName);
|
||||
|
||||
await UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateFolderAsync(MailItemFolder folder)
|
||||
{
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.Warning("Folder is null. Cannot update.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Updating folder {FolderName}", folder.Id, folder.FolderName);
|
||||
|
||||
await Connection.UpdateAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task DeleteFolderAsync(MailItemFolder folder)
|
||||
{
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.Warning("Folder is null. Cannot delete.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var account = await _accountService.GetAccountAsync(folder.MailAccountId).ConfigureAwait(false);
|
||||
if (account == null)
|
||||
{
|
||||
_logger.Warning("Account with id {MailAccountId} does not exist. Cannot delete folder.", folder.MailAccountId);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Deleting folder {FolderName}", folder.FolderName);
|
||||
|
||||
await Connection.DeleteAsync(folder).ConfigureAwait(false);
|
||||
|
||||
// Delete all existing mails from this folder.
|
||||
await Connection.ExecuteAsync("DELETE FROM MailCopy WHERE FolderId = ?", folder.Id);
|
||||
|
||||
// TODO: Delete MIME messages from the disk.
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private Task<List<string>> GetMailCopyIdsByFolderIdAsync(Guid folderId)
|
||||
{
|
||||
var query = new Query("MailCopy")
|
||||
.Where("FolderId", folderId)
|
||||
.Select("Id");
|
||||
|
||||
return Connection.QueryScalarsAsync<string>(query.GetRawQuery());
|
||||
}
|
||||
|
||||
public async Task<List<MailFolderPairMetadata>> GetMailFolderPairMetadatasAsync(IEnumerable<string> mailCopyIds)
|
||||
{
|
||||
// Get all assignments for all items.
|
||||
var query = new Query(nameof(MailCopy))
|
||||
.Join(nameof(MailItemFolder), $"{nameof(MailCopy)}.FolderId", $"{nameof(MailItemFolder)}.Id")
|
||||
.WhereIn($"{nameof(MailCopy)}.Id", mailCopyIds)
|
||||
.SelectRaw($"{nameof(MailCopy)}.Id as MailCopyId, {nameof(MailItemFolder)}.Id as FolderId, {nameof(MailItemFolder)}.RemoteFolderId as RemoteFolderId")
|
||||
.Distinct();
|
||||
|
||||
var rowQuery = query.GetRawQuery();
|
||||
|
||||
return await Connection.QueryAsync<MailFolderPairMetadata>(rowQuery);
|
||||
}
|
||||
|
||||
public Task<List<MailFolderPairMetadata>> GetMailFolderPairMetadatasAsync(string mailCopyId)
|
||||
=> GetMailFolderPairMetadatasAsync(new List<string>() { mailCopyId });
|
||||
|
||||
public async Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options)
|
||||
{
|
||||
var folders = new List<MailItemFolder>();
|
||||
|
||||
if (options.Type == SynchronizationType.FullFolders)
|
||||
{
|
||||
// Only get sync enabled folders.
|
||||
|
||||
var synchronizationFolders = await Connection.Table<MailItemFolder>()
|
||||
.Where(a => a.MailAccountId == options.AccountId && a.IsSynchronizationEnabled)
|
||||
.OrderBy(a => a.SpecialFolderType)
|
||||
.ToListAsync();
|
||||
|
||||
folders.AddRange(synchronizationFolders);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Inbox, Sent and Draft folders must always be synchronized regardless of whether they are enabled or not.
|
||||
// Custom folder sync will add additional folders to the list if not specified.
|
||||
|
||||
var mustHaveFolders = await GetInboxSynchronizationFoldersAsync(options.AccountId);
|
||||
|
||||
if (options.Type == SynchronizationType.InboxOnly)
|
||||
{
|
||||
return mustHaveFolders;
|
||||
}
|
||||
else if (options.Type == SynchronizationType.CustomFolders)
|
||||
{
|
||||
// Only get the specified and enabled folders.
|
||||
|
||||
var synchronizationFolders = await Connection.Table<MailItemFolder>()
|
||||
.Where(a => a.MailAccountId == options.AccountId && options.SynchronizationFolderIds.Contains(a.Id))
|
||||
.ToListAsync();
|
||||
|
||||
// Order is important for moving.
|
||||
// By implementation, removing mail folders must be synchronized first. Requests are made in that order for custom sync.
|
||||
// eg. Moving item from Folder A to Folder B. If we start syncing Folder B first, we might miss adding assignment for Folder A.
|
||||
|
||||
var orderedCustomFolders = synchronizationFolders.OrderBy(a => options.SynchronizationFolderIds.IndexOf(a.Id));
|
||||
|
||||
foreach (var item in orderedCustomFolders)
|
||||
{
|
||||
if (!mustHaveFolders.Any(a => a.Id == item.Id))
|
||||
{
|
||||
mustHaveFolders.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mustHaveFolders;
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
private async Task<List<MailItemFolder>> GetInboxSynchronizationFoldersAsync(Guid accountId)
|
||||
{
|
||||
var folders = new List<MailItemFolder>();
|
||||
|
||||
var inboxFolder = await GetSpecialFolderByAccountIdAsync(accountId, SpecialFolderType.Inbox);
|
||||
var sentFolder = await GetSpecialFolderByAccountIdAsync(accountId, SpecialFolderType.Sent);
|
||||
var draftFolder = await GetSpecialFolderByAccountIdAsync(accountId, SpecialFolderType.Draft);
|
||||
var deletedFolder = await GetSpecialFolderByAccountIdAsync(accountId, SpecialFolderType.Deleted);
|
||||
|
||||
if (deletedFolder != null)
|
||||
{
|
||||
folders.Add(deletedFolder);
|
||||
}
|
||||
|
||||
if (inboxFolder != null)
|
||||
{
|
||||
folders.Add(inboxFolder);
|
||||
}
|
||||
|
||||
// For properly creating threads we need Sent and Draft to be synchronized as well.
|
||||
|
||||
if (sentFolder != null)
|
||||
{
|
||||
folders.Add(sentFolder);
|
||||
}
|
||||
|
||||
if (draftFolder != null)
|
||||
{
|
||||
folders.Add(draftFolder);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
public Task<MailItemFolder> GetFolderAsync(Guid accountId, string remoteFolderId)
|
||||
=> Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.RemoteFolderId == remoteFolderId);
|
||||
|
||||
public async Task DeleteFolderAsync(Guid accountId, string remoteFolderId)
|
||||
{
|
||||
var folder = await GetFolderAsync(accountId, remoteFolderId);
|
||||
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.Warning("Folder with id {RemoteFolderId} does not exist. Delete folder canceled.", remoteFolderId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await DeleteFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount)
|
||||
{
|
||||
var localFolder = await GetFolderAsync(folderId);
|
||||
|
||||
if (localFolder != null)
|
||||
{
|
||||
localFolder.ShowUnreadCount = showUnreadCount;
|
||||
|
||||
await UpdateFolderAsync(localFolder).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsInboxAvailableForAccountAsync(Guid accountId)
|
||||
=> await Connection.Table<MailItemFolder>()
|
||||
.Where(a => a.SpecialFolderType == SpecialFolderType.Inbox && a.MailAccountId == accountId)
|
||||
.CountAsync() == 1;
|
||||
|
||||
public Task UpdateFolderLastSyncDateAsync(Guid folderId)
|
||||
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET LastSynchronizedDate = ? WHERE Id = ?", DateTime.UtcNow, folderId);
|
||||
|
||||
public Task<List<UnreadItemCountResult>> GetUnreadItemCountResultsAsync(IEnumerable<Guid> accountIds)
|
||||
{
|
||||
var query = new Query(nameof(MailCopy))
|
||||
.Join(nameof(MailItemFolder), $"{nameof(MailCopy)}.FolderId", $"{nameof(MailItemFolder)}.Id")
|
||||
.WhereIn($"{nameof(MailItemFolder)}.MailAccountId", accountIds)
|
||||
.Where($"{nameof(MailCopy)}.IsRead", 0)
|
||||
.Where($"{nameof(MailItemFolder)}.ShowUnreadCount", 1)
|
||||
.SelectRaw($"{nameof(MailItemFolder)}.Id as FolderId, {nameof(MailItemFolder)}.SpecialFolderType as SpecialFolderType, count (DISTINCT {nameof(MailCopy)}.Id) as UnreadItemCount, {nameof(MailItemFolder)}.MailAccountId as AccountId")
|
||||
.GroupBy($"{nameof(MailItemFolder)}.Id");
|
||||
|
||||
return Connection.QueryAsync<UnreadItemCountResult>(query.GetRawQuery());
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Wino.Services/LaunchProtocolService.cs
Normal file
11
Wino.Services/LaunchProtocolService.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Launch;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class LaunchProtocolService : ILaunchProtocolService
|
||||
{
|
||||
public object LaunchParameter { get; set; }
|
||||
public MailToUri MailToUri { get; set; }
|
||||
}
|
||||
}
|
||||
40
Wino.Services/LogInitializer.cs
Normal file
40
Wino.Services/LogInitializer.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class LogInitializer : ILogInitializer
|
||||
{
|
||||
private readonly LoggingLevelSwitch _levelSwitch = new LoggingLevelSwitch();
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
|
||||
public LogInitializer(IPreferencesService preferencesService)
|
||||
{
|
||||
_preferencesService = preferencesService;
|
||||
|
||||
RefreshLoggingLevel();
|
||||
}
|
||||
|
||||
public void RefreshLoggingLevel()
|
||||
{
|
||||
#if DEBUG
|
||||
_levelSwitch.MinimumLevel = Serilog.Events.LogEventLevel.Debug;
|
||||
#else
|
||||
_levelSwitch.MinimumLevel = _preferencesService.IsLoggingEnabled ? Serilog.Events.LogEventLevel.Information : Serilog.Events.LogEventLevel.Fatal;
|
||||
#endif
|
||||
}
|
||||
|
||||
public void SetupLogger(string fullLogFilePath)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.ControlledBy(_levelSwitch)
|
||||
.WriteTo.File(fullLogFilePath, retainedFileCountLimit: 3, rollOnFileSizeLimit: true, rollingInterval: RollingInterval.Day)
|
||||
.WriteTo.Debug()
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithExceptionDetails()
|
||||
.CreateLogger();
|
||||
}
|
||||
}
|
||||
}
|
||||
990
Wino.Services/MailService.cs
Normal file
990
Wino.Services/MailService.cs
Normal file
@@ -0,0 +1,990 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MimeKit;
|
||||
using Serilog;
|
||||
using SqlKata;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Comparers;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Messaging.UI;
|
||||
using Wino.Services.Extensions;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class MailService : BaseDatabaseService, IMailService
|
||||
{
|
||||
private const int ItemLoadCount = 100;
|
||||
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly IContactService _contactService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ISignatureService _signatureService;
|
||||
private readonly IThreadingStrategyProvider _threadingStrategyProvider;
|
||||
private readonly IMimeFileService _mimeFileService;
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
|
||||
private readonly ILogger _logger = Log.ForContext<MailService>();
|
||||
|
||||
public MailService(IDatabaseService databaseService,
|
||||
IFolderService folderService,
|
||||
IContactService contactService,
|
||||
IAccountService accountService,
|
||||
ISignatureService signatureService,
|
||||
IThreadingStrategyProvider threadingStrategyProvider,
|
||||
IMimeFileService mimeFileService,
|
||||
IPreferencesService preferencesService) : base(databaseService)
|
||||
{
|
||||
_folderService = folderService;
|
||||
_contactService = contactService;
|
||||
_accountService = accountService;
|
||||
_signatureService = signatureService;
|
||||
_threadingStrategyProvider = threadingStrategyProvider;
|
||||
_mimeFileService = mimeFileService;
|
||||
_preferencesService = preferencesService;
|
||||
}
|
||||
|
||||
public async Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions)
|
||||
{
|
||||
var composerAccount = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
|
||||
var createdDraftMimeMessage = await CreateDraftMimeAsync(composerAccount, draftCreationOptions);
|
||||
|
||||
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(composerAccount.Id, SpecialFolderType.Draft);
|
||||
|
||||
// Get locally created unique id from the mime headers.
|
||||
// This header will be used to map the local draft copy with the remote draft copy.
|
||||
var mimeUniqueId = createdDraftMimeMessage.Headers[Constants.WinoLocalDraftHeader];
|
||||
|
||||
var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(accountId).ConfigureAwait(false);
|
||||
|
||||
var copy = new MailCopy
|
||||
{
|
||||
UniqueId = Guid.Parse(mimeUniqueId),
|
||||
Id = Guid.NewGuid().ToString(), // This will be replaced after network call with the remote draft id.
|
||||
CreationDate = DateTime.UtcNow,
|
||||
FromAddress = primaryAlias?.AliasAddress ?? composerAccount.Address,
|
||||
FromName = composerAccount.SenderName,
|
||||
HasAttachments = false,
|
||||
Importance = MailImportance.Normal,
|
||||
Subject = createdDraftMimeMessage.Subject,
|
||||
PreviewText = createdDraftMimeMessage.TextBody,
|
||||
IsRead = true,
|
||||
IsDraft = true,
|
||||
FolderId = draftFolder.Id,
|
||||
DraftId = $"{Constants.LocalDraftStartPrefix}{Guid.NewGuid()}",
|
||||
AssignedFolder = draftFolder,
|
||||
AssignedAccount = composerAccount,
|
||||
FileId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
// If replying, add In-Reply-To, ThreadId and References.
|
||||
if (draftCreationOptions.ReferencedMessage != null)
|
||||
{
|
||||
if (draftCreationOptions.ReferencedMessage.MimeMessage.References != null)
|
||||
copy.References = string.Join(",", draftCreationOptions.ReferencedMessage.MimeMessage.References);
|
||||
|
||||
if (!string.IsNullOrEmpty(draftCreationOptions.ReferencedMessage.MimeMessage.MessageId))
|
||||
copy.InReplyTo = draftCreationOptions.ReferencedMessage.MimeMessage.MessageId;
|
||||
|
||||
if (!string.IsNullOrEmpty(draftCreationOptions.ReferencedMessage.MailCopy?.ThreadId))
|
||||
copy.ThreadId = draftCreationOptions.ReferencedMessage.MailCopy.ThreadId;
|
||||
}
|
||||
|
||||
await Connection.InsertAsync(copy);
|
||||
|
||||
await _mimeFileService.SaveMimeMessageAsync(copy.FileId, createdDraftMimeMessage, composerAccount.Id);
|
||||
|
||||
ReportUIChange(new DraftCreated(copy, composerAccount));
|
||||
|
||||
return (copy, createdDraftMimeMessage.GetBase64MimeMessage());
|
||||
}
|
||||
|
||||
public async Task<List<MailCopy>> GetMailsByFolderIdAsync(Guid folderId)
|
||||
{
|
||||
var mails = await Connection.QueryAsync<MailCopy>("SELECT * FROM MailCopy WHERE FolderId = ?", folderId);
|
||||
|
||||
foreach (var mail in mails)
|
||||
{
|
||||
await LoadAssignedPropertiesAsync(mail).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return mails;
|
||||
}
|
||||
|
||||
public async Task<List<MailCopy>> GetUnreadMailsByFolderIdAsync(Guid folderId)
|
||||
{
|
||||
var unreadMails = await Connection.QueryAsync<MailCopy>("SELECT * FROM MailCopy WHERE FolderId = ? AND IsRead = 0", folderId);
|
||||
|
||||
foreach (var mail in unreadMails)
|
||||
{
|
||||
await LoadAssignedPropertiesAsync(mail).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return unreadMails;
|
||||
}
|
||||
|
||||
private string BuildMailFetchQuery(MailListInitializationOptions options)
|
||||
{
|
||||
// If the search query is there, we should ignore some properties and trim it.
|
||||
//if (!string.IsNullOrEmpty(options.SearchQuery))
|
||||
//{
|
||||
// options.IsFocusedOnly = null;
|
||||
// filterType = FilterOptionType.All;
|
||||
|
||||
// searchQuery = searchQuery.Trim();
|
||||
//}
|
||||
|
||||
// SQLite PCL doesn't support joins.
|
||||
// We make the query using SqlKatka and execute it directly on SQLite-PCL.
|
||||
|
||||
var query = new Query("MailCopy")
|
||||
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
|
||||
.WhereIn("MailCopy.FolderId", options.Folders.Select(a => a.Id))
|
||||
.Take(ItemLoadCount)
|
||||
.SelectRaw("MailCopy.*");
|
||||
|
||||
if (options.SortingOptionType == SortingOptionType.ReceiveDate)
|
||||
query.OrderByDesc("CreationDate");
|
||||
else if (options.SortingOptionType == SortingOptionType.Sender)
|
||||
query.OrderBy("FromName");
|
||||
|
||||
// Conditional where.
|
||||
switch (options.FilterType)
|
||||
{
|
||||
case FilterOptionType.Unread:
|
||||
query.Where("MailCopy.IsRead", false);
|
||||
break;
|
||||
case FilterOptionType.Flagged:
|
||||
query.Where("MailCopy.IsFlagged", true);
|
||||
break;
|
||||
case FilterOptionType.Files:
|
||||
query.Where("MailCopy.HasAttachments", true);
|
||||
break;
|
||||
}
|
||||
|
||||
if (options.IsFocusedOnly != null)
|
||||
query.Where("MailCopy.IsFocused", options.IsFocusedOnly.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(options.SearchQuery))
|
||||
query.Where(a =>
|
||||
a.OrWhereContains("MailCopy.PreviewText", options.SearchQuery)
|
||||
.OrWhereContains("MailCopy.Subject", options.SearchQuery)
|
||||
.OrWhereContains("MailCopy.FromName", options.SearchQuery)
|
||||
.OrWhereContains("MailCopy.FromAddress", options.SearchQuery));
|
||||
|
||||
if (options.ExistingUniqueIds?.Any() ?? false)
|
||||
{
|
||||
query.WhereNotIn("MailCopy.UniqueId", options.ExistingUniqueIds);
|
||||
}
|
||||
|
||||
//if (options.Skip > 0)
|
||||
//{
|
||||
// query.Skip(options.Skip);
|
||||
//}
|
||||
|
||||
return query.GetRawQuery();
|
||||
}
|
||||
|
||||
public async Task<List<IMailItem>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = BuildMailFetchQuery(options);
|
||||
|
||||
var mails = await Connection.QueryAsync<MailCopy>(query);
|
||||
|
||||
Dictionary<Guid, MailItemFolder> folderCache = [];
|
||||
Dictionary<Guid, MailAccount> accountCache = [];
|
||||
Dictionary<string, AccountContact> contactCache = [];
|
||||
|
||||
// Populate Folder Assignment for each single mail, to be able later group by "MailAccountId".
|
||||
// This is needed to execute threading strategy by account type.
|
||||
// Avoid DBs calls as possible, storing info in a dictionary.
|
||||
foreach (var mail in mails)
|
||||
{
|
||||
await LoadAssignedPropertiesWithCacheAsync(mail, folderCache, accountCache).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Remove items that has no assigned account or folder.
|
||||
mails.RemoveAll(a => a.AssignedAccount == null || a.AssignedFolder == null);
|
||||
|
||||
if (!options.CreateThreads)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Threading is disabled. Just return everything as it is.
|
||||
mails.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer());
|
||||
|
||||
return new List<IMailItem>(mails);
|
||||
}
|
||||
|
||||
// Populate threaded items.
|
||||
|
||||
var threadedItems = new List<IMailItem>();
|
||||
|
||||
// Each account items must be threaded separately.
|
||||
foreach (var group in mails.GroupBy(a => a.AssignedAccount.Id))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var accountId = group.Key;
|
||||
var groupAccount = mails.First(a => a.AssignedAccount.Id == accountId).AssignedAccount;
|
||||
|
||||
var threadingStrategy = _threadingStrategyProvider.GetStrategy(groupAccount.ProviderType);
|
||||
|
||||
// Only thread items from Draft and Sent folders must present here.
|
||||
// Otherwise this strategy will fetch the items that are in Deleted folder as well.
|
||||
var accountThreadedItems = await threadingStrategy.ThreadItemsAsync([.. group], options.Folders.First());
|
||||
|
||||
// Populate threaded items with folder and account assignments.
|
||||
// Almost everything already should be in cache from initial population.
|
||||
foreach (var mail in accountThreadedItems)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await LoadAssignedPropertiesWithCacheAsync(mail, folderCache, accountCache).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (accountThreadedItems != null)
|
||||
{
|
||||
threadedItems.AddRange(accountThreadedItems);
|
||||
}
|
||||
}
|
||||
|
||||
threadedItems.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer());
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return threadedItems;
|
||||
|
||||
// Recursive function to populate folder and account assignments for each mail item.
|
||||
async Task LoadAssignedPropertiesWithCacheAsync(IMailItem mail,
|
||||
Dictionary<Guid, MailItemFolder> folderCache,
|
||||
Dictionary<Guid, MailAccount> accountCache)
|
||||
{
|
||||
if (mail is ThreadMailItem threadMailItem)
|
||||
{
|
||||
foreach (var childMail in threadMailItem.ThreadItems)
|
||||
{
|
||||
await LoadAssignedPropertiesWithCacheAsync(childMail, folderCache, accountCache).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (mail is MailCopy mailCopy)
|
||||
{
|
||||
MailAccount accountAssignment = null;
|
||||
|
||||
var isFolderCached = folderCache.TryGetValue(mailCopy.FolderId, out MailItemFolder folderAssignment);
|
||||
accountAssignment = null;
|
||||
if (!isFolderCached)
|
||||
{
|
||||
folderAssignment = await _folderService.GetFolderAsync(mailCopy.FolderId).ConfigureAwait(false);
|
||||
if (!folderCache.ContainsKey(mailCopy.FolderId))
|
||||
{
|
||||
folderCache.Add(mailCopy.FolderId, folderAssignment);
|
||||
}
|
||||
}
|
||||
|
||||
if (folderAssignment != null)
|
||||
{
|
||||
var isAccountCached = accountCache.TryGetValue(folderAssignment.MailAccountId, out accountAssignment);
|
||||
if (!isAccountCached)
|
||||
{
|
||||
accountAssignment = await _accountService.GetAccountAsync(folderAssignment.MailAccountId).ConfigureAwait(false);
|
||||
|
||||
if (!accountCache.ContainsKey(folderAssignment.MailAccountId))
|
||||
{
|
||||
accountCache.Add(folderAssignment.MailAccountId, accountAssignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AccountContact contactAssignment = null;
|
||||
|
||||
bool isContactCached = !string.IsNullOrEmpty(mailCopy.FromAddress) ?
|
||||
contactCache.TryGetValue(mailCopy.FromAddress, out contactAssignment) :
|
||||
false;
|
||||
|
||||
if (!isContactCached && accountAssignment != null)
|
||||
{
|
||||
contactAssignment = await GetSenderContactForAccountAsync(accountAssignment, mailCopy.FromAddress).ConfigureAwait(false);
|
||||
|
||||
if (contactAssignment != null)
|
||||
{
|
||||
if (!contactCache.ContainsKey(mailCopy.FromAddress))
|
||||
{
|
||||
contactCache.Add(mailCopy.FromAddress, contactAssignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mailCopy.AssignedFolder = folderAssignment;
|
||||
mailCopy.AssignedAccount = accountAssignment;
|
||||
mailCopy.SenderContact = contactAssignment ?? CreateUnknownContact(mailCopy.FromName, mailCopy.FromAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AccountContact CreateUnknownContact(string fromName, string fromAddress)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fromName) && string.IsNullOrEmpty(fromAddress))
|
||||
{
|
||||
return new AccountContact()
|
||||
{
|
||||
Name = Translator.UnknownSender,
|
||||
Address = Translator.UnknownAddress
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrEmpty(fromName)) fromName = fromAddress;
|
||||
|
||||
return new AccountContact()
|
||||
{
|
||||
Name = fromName,
|
||||
Address = fromAddress
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<MailCopy>> GetMailItemsAsync(string mailCopyId)
|
||||
{
|
||||
var mailCopies = await Connection.Table<MailCopy>().Where(a => a.Id == mailCopyId).ToListAsync();
|
||||
|
||||
foreach (var mailCopy in mailCopies)
|
||||
{
|
||||
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return mailCopies;
|
||||
}
|
||||
|
||||
private Task<AccountContact> GetSenderContactForAccountAsync(MailAccount account, string fromAddress)
|
||||
{
|
||||
// Make sure to return the latest up to date contact information for the original account.
|
||||
if (fromAddress == account.Address)
|
||||
{
|
||||
return Task.FromResult(new AccountContact() { Address = account.Address, Name = account.SenderName, Base64ContactPicture = account.Base64ProfilePictureData });
|
||||
}
|
||||
else
|
||||
{
|
||||
return _contactService.GetAddressInformationByAddressAsync(fromAddress);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAssignedPropertiesAsync(MailCopy mailCopy)
|
||||
{
|
||||
if (mailCopy == null) return;
|
||||
|
||||
// Load AssignedAccount, AssignedFolder and SenderContact.
|
||||
|
||||
var folder = await _folderService.GetFolderAsync(mailCopy.FolderId);
|
||||
|
||||
if (folder == null) return;
|
||||
|
||||
var account = await _accountService.GetAccountAsync(folder.MailAccountId);
|
||||
|
||||
if (account == null) return;
|
||||
|
||||
mailCopy.AssignedAccount = account;
|
||||
mailCopy.AssignedFolder = folder;
|
||||
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<MailCopy> GetSingleMailItemWithoutFolderAssignmentAsync(string mailCopyId)
|
||||
{
|
||||
var mailCopy = await Connection.Table<MailCopy>().FirstOrDefaultAsync(a => a.Id == mailCopyId);
|
||||
|
||||
if (mailCopy == null) return null;
|
||||
|
||||
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
|
||||
|
||||
return mailCopy;
|
||||
}
|
||||
|
||||
public async Task<MailCopy> GetSingleMailItemAsync(string mailCopyId, string remoteFolderId)
|
||||
{
|
||||
var query = new Query("MailCopy")
|
||||
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
|
||||
.Where("MailCopy.Id", mailCopyId)
|
||||
.Where("MailItemFolder.RemoteFolderId", remoteFolderId)
|
||||
.SelectRaw("MailCopy.*")
|
||||
.GetRawQuery();
|
||||
|
||||
var mailItem = await Connection.FindWithQueryAsync<MailCopy>(query);
|
||||
|
||||
if (mailItem == null) return null;
|
||||
|
||||
await LoadAssignedPropertiesAsync(mailItem).ConfigureAwait(false);
|
||||
|
||||
return mailItem;
|
||||
}
|
||||
|
||||
public async Task<MailCopy> GetSingleMailItemAsync(Guid uniqueMailId)
|
||||
{
|
||||
var mailItem = await Connection.FindAsync<MailCopy>(uniqueMailId);
|
||||
|
||||
if (mailItem == null) return null;
|
||||
|
||||
await LoadAssignedPropertiesAsync(mailItem).ConfigureAwait(false);
|
||||
|
||||
return mailItem;
|
||||
}
|
||||
|
||||
// v2
|
||||
|
||||
public async Task DeleteMailAsync(Guid accountId, string mailCopyId)
|
||||
{
|
||||
var allMails = await GetMailItemsAsync(mailCopyId).ConfigureAwait(false);
|
||||
|
||||
foreach (var mailItem in allMails)
|
||||
{
|
||||
// Delete mime file as well.
|
||||
// Even though Gmail might have multiple copies for the same mail, we only have one MIME file for all.
|
||||
// Their FileId is inserted same.
|
||||
|
||||
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
#region Repository Calls
|
||||
|
||||
private async Task InsertMailAsync(MailCopy mailCopy)
|
||||
{
|
||||
if (mailCopy == null)
|
||||
{
|
||||
_logger.Warning("Null mail passed to InsertMailAsync call.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mailCopy.FolderId == Guid.Empty)
|
||||
{
|
||||
_logger.Warning("Invalid FolderId for MailCopyId {Id} for InsertMailAsync", mailCopy.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Inserting mail {MailCopyId} to {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
|
||||
|
||||
await Connection.InsertAsync(mailCopy).ConfigureAwait(false);
|
||||
|
||||
ReportUIChange(new MailAddedMessage(mailCopy));
|
||||
}
|
||||
|
||||
public async Task UpdateMailAsync(MailCopy mailCopy)
|
||||
{
|
||||
if (mailCopy == null)
|
||||
{
|
||||
_logger.Warning("Null mail passed to UpdateMailAsync call.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
|
||||
|
||||
await Connection.UpdateAsync(mailCopy).ConfigureAwait(false);
|
||||
|
||||
ReportUIChange(new MailUpdatedMessage(mailCopy));
|
||||
}
|
||||
|
||||
private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile)
|
||||
{
|
||||
if (mailCopy == null)
|
||||
{
|
||||
_logger.Warning("Null mail passed to DeleteMailAsync call.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
|
||||
|
||||
await Connection.DeleteAsync(mailCopy).ConfigureAwait(false);
|
||||
|
||||
// If there are no more copies exists of the same mail, delete the MIME file as well.
|
||||
var isMailExists = await IsMailExistsAsync(mailCopy.Id).ConfigureAwait(false);
|
||||
|
||||
if (!isMailExists && !preserveMimeFile)
|
||||
{
|
||||
await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ReportUIChange(new MailRemovedMessage(mailCopy));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task UpdateAllMailCopiesAsync(string mailCopyId, Func<MailCopy, bool> action)
|
||||
{
|
||||
var mailCopies = await GetMailItemsAsync(mailCopyId);
|
||||
|
||||
if (mailCopies == null || !mailCopies.Any())
|
||||
{
|
||||
_logger.Warning("Updating mail copies failed because there are no copies available with Id {MailCopyId}", mailCopyId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Updating {MailCopyCount} mail copies with Id {MailCopyId}", mailCopies.Count, mailCopyId);
|
||||
|
||||
foreach (var mailCopy in mailCopies)
|
||||
{
|
||||
bool shouldUpdateItem = action(mailCopy);
|
||||
|
||||
if (shouldUpdateItem)
|
||||
{
|
||||
await UpdateMailAsync(mailCopy).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
_logger.Debug("Skipped updating mail because it is already in the desired state.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task ChangeReadStatusAsync(string mailCopyId, bool isRead)
|
||||
=> UpdateAllMailCopiesAsync(mailCopyId, (item) =>
|
||||
{
|
||||
if (item.IsRead == isRead) return false;
|
||||
|
||||
item.IsRead = isRead;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
public Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged)
|
||||
=> UpdateAllMailCopiesAsync(mailCopyId, (item) =>
|
||||
{
|
||||
if (item.IsFlagged == isFlagged) return false;
|
||||
|
||||
item.IsFlagged = isFlagged;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
public async Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
||||
{
|
||||
// Note: Folder might not be available at the moment due to user not syncing folders before the delta processing.
|
||||
// This is a problem, because assignments won't be created.
|
||||
// Therefore we sync folders every time before the delta processing.
|
||||
|
||||
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
|
||||
|
||||
if (localFolder == null)
|
||||
{
|
||||
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
|
||||
_logger.Warning("Skipping assignment creation for the the message {MailCopyId}", mailCopyId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId);
|
||||
|
||||
if (mailCopy == null)
|
||||
{
|
||||
_logger.Warning("Can't create assignment for mail {MailCopyId} because it does not exist.", mailCopyId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent &&
|
||||
localFolder.SpecialFolderType == SpecialFolderType.Deleted)
|
||||
{
|
||||
// Sent item is deleted.
|
||||
// Gmail does not delete the sent items, but moves them to the deleted folder.
|
||||
// API doesn't allow removing Sent label.
|
||||
// Here we intercept this behavior, removing the Sent copy of the mail and adding the Deleted copy.
|
||||
// This way item will only be visible in Trash folder as in Gmail Web UI.
|
||||
// Don't delete MIME file since if exists.
|
||||
|
||||
await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Copy one of the mail copy and assign it to the new folder.
|
||||
// We don't need to create a new MIME pack.
|
||||
// Therefore FileId is not changed for the new MailCopy.
|
||||
|
||||
mailCopy.UniqueId = Guid.NewGuid();
|
||||
mailCopy.FolderId = localFolder.Id;
|
||||
mailCopy.AssignedFolder = localFolder;
|
||||
|
||||
await InsertMailAsync(mailCopy).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
||||
{
|
||||
var mailItem = await GetSingleMailItemAsync(mailCopyId, remoteFolderId).ConfigureAwait(false);
|
||||
|
||||
if (mailItem == null)
|
||||
{
|
||||
_logger.Warning("Mail not found with id {MailCopyId} with remote folder {RemoteFolderId}", mailCopyId, remoteFolderId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
|
||||
|
||||
if (localFolder == null)
|
||||
{
|
||||
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
|
||||
{
|
||||
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
|
||||
|
||||
if (account == null) return false;
|
||||
|
||||
if (string.IsNullOrEmpty(package.AssignedRemoteFolderId))
|
||||
{
|
||||
_logger.Warning("Remote folder id is not set for {MailCopyId}.", package.Copy.Id);
|
||||
_logger.Warning("Ignoring creation of mail.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var assignedFolder = await _folderService.GetFolderAsync(accountId, package.AssignedRemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
if (assignedFolder == null)
|
||||
{
|
||||
_logger.Warning("Assigned folder not found for {MailCopyId}.", package.Copy.Id);
|
||||
_logger.Warning("Ignoring creation of mail.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var mailCopy = package.Copy;
|
||||
var mimeMessage = package.Mime;
|
||||
|
||||
mailCopy.UniqueId = Guid.NewGuid();
|
||||
mailCopy.AssignedAccount = account;
|
||||
mailCopy.AssignedFolder = assignedFolder;
|
||||
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
|
||||
mailCopy.FolderId = assignedFolder.Id;
|
||||
|
||||
// Only save MIME files if they don't exists.
|
||||
// This is because 1 mail may have multiple copies in different folders.
|
||||
// but only single MIME to represent all.
|
||||
|
||||
// Save mime file to disk.
|
||||
var isMimeExists = await _mimeFileService.IsMimeExistAsync(accountId, mailCopy.FileId);
|
||||
|
||||
if (!isMimeExists)
|
||||
{
|
||||
bool isMimeSaved = await _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, accountId).ConfigureAwait(false);
|
||||
|
||||
if (!isMimeSaved)
|
||||
{
|
||||
_logger.Warning("Failed to save mime file for {MailCopyId}.", mailCopy.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Save contact information.
|
||||
await _contactService.SaveAddressInformationAsync(mimeMessage).ConfigureAwait(false);
|
||||
|
||||
// Create mail copy in the database.
|
||||
// Update if exists.
|
||||
|
||||
var existingCopyItem = await Connection.Table<MailCopy>()
|
||||
.FirstOrDefaultAsync(a => a.Id == mailCopy.Id && a.FolderId == assignedFolder.Id);
|
||||
|
||||
if (existingCopyItem != null)
|
||||
{
|
||||
mailCopy.UniqueId = existingCopyItem.UniqueId;
|
||||
|
||||
await UpdateMailAsync(mailCopy).ConfigureAwait(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
await InsertMailAsync(mailCopy).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
|
||||
{
|
||||
// This unique id is stored in mime headers for Wino to identify remote message with local copy.
|
||||
// Same unique id will be used for the local copy as well.
|
||||
// Synchronizer will map this unique id to the local draft copy after synchronization.
|
||||
|
||||
var message = new MimeMessage()
|
||||
{
|
||||
Headers = { { Constants.WinoLocalDraftHeader, Guid.NewGuid().ToString() } },
|
||||
};
|
||||
|
||||
var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(account.Id) ?? throw new MissingAliasException();
|
||||
|
||||
// Set FromName and FromAddress by alias.
|
||||
message.From.Add(new MailboxAddress(account.SenderName, primaryAlias.AliasAddress));
|
||||
|
||||
var builder = new BodyBuilder();
|
||||
|
||||
var signature = await GetSignature(account, draftCreationOptions.Reason);
|
||||
|
||||
_ = draftCreationOptions.Reason switch
|
||||
{
|
||||
DraftCreationReason.Empty => CreateEmptyDraft(builder, message, draftCreationOptions, signature),
|
||||
_ => CreateReferencedDraft(builder, message, draftCreationOptions, account, signature),
|
||||
};
|
||||
|
||||
// TODO: Migration
|
||||
// builder.SetHtmlBody(builder.HtmlBody);
|
||||
|
||||
message.Body = builder.ToMessageBody();
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private string CreateHtmlGap()
|
||||
{
|
||||
var template = $"""<div style="font-family: '{_preferencesService.ComposerFont}', Arial, sans-serif; font-size: {_preferencesService.ComposerFontSize}px"><br></div>""";
|
||||
return string.Concat(Enumerable.Repeat(template, 2));
|
||||
}
|
||||
|
||||
private async Task<string> GetSignature(MailAccount account, DraftCreationReason reason)
|
||||
{
|
||||
if (account.Preferences.IsSignatureEnabled)
|
||||
{
|
||||
var signatureId = reason == DraftCreationReason.Empty ?
|
||||
account.Preferences.SignatureIdForNewMessages :
|
||||
account.Preferences.SignatureIdForFollowingMessages;
|
||||
|
||||
if (signatureId != null)
|
||||
{
|
||||
var signature = await _signatureService.GetSignatureAsync(signatureId.Value);
|
||||
|
||||
return signature.HtmlBody;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private MimeMessage CreateEmptyDraft(BodyBuilder builder, MimeMessage message, DraftCreationOptions draftCreationOptions, string signature)
|
||||
{
|
||||
builder.HtmlBody = CreateHtmlGap();
|
||||
if (draftCreationOptions.MailToUri != null)
|
||||
{
|
||||
if (draftCreationOptions.MailToUri.Subject != null)
|
||||
message.Subject = draftCreationOptions.MailToUri.Subject;
|
||||
|
||||
if (draftCreationOptions.MailToUri.Body != null)
|
||||
{
|
||||
// TODO: In .NET 6+ replace with string "ReplaceLineEndings" method.
|
||||
var escapedBody = draftCreationOptions.MailToUri.Body.Replace("\r\n", "<br>").Replace("\n", "<br>").Replace("\r", "<br>");
|
||||
builder.HtmlBody = $"""<div style="font-family: '{_preferencesService.ComposerFont}', Arial, sans-serif; font-size: {_preferencesService.ComposerFontSize}px">{escapedBody}</div>""" + builder.HtmlBody;
|
||||
}
|
||||
|
||||
if (draftCreationOptions.MailToUri.To.Any())
|
||||
message.To.AddRange(draftCreationOptions.MailToUri.To.Select(x => new MailboxAddress(x, x)));
|
||||
|
||||
if (draftCreationOptions.MailToUri.Cc.Any())
|
||||
message.Cc.AddRange(draftCreationOptions.MailToUri.Cc.Select(x => new MailboxAddress(x, x)));
|
||||
|
||||
if (draftCreationOptions.MailToUri.Bcc.Any())
|
||||
message.Bcc.AddRange(draftCreationOptions.MailToUri.Bcc.Select(x => new MailboxAddress(x, x)));
|
||||
}
|
||||
|
||||
if (signature != null)
|
||||
builder.HtmlBody += signature;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private MimeMessage CreateReferencedDraft(BodyBuilder builder, MimeMessage message, DraftCreationOptions draftCreationOptions, MailAccount account, string signature)
|
||||
{
|
||||
var reason = draftCreationOptions.Reason;
|
||||
var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage;
|
||||
|
||||
var gap = CreateHtmlGap();
|
||||
builder.HtmlBody = gap + CreateHtmlForReferencingMessage(referenceMessage);
|
||||
|
||||
if (signature != null)
|
||||
{
|
||||
builder.HtmlBody = gap + signature + builder.HtmlBody;
|
||||
}
|
||||
|
||||
// Manage "To"
|
||||
if (reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll)
|
||||
{
|
||||
// Reply to the sender of the message
|
||||
if (referenceMessage.ReplyTo.Count > 0)
|
||||
message.To.AddRange(referenceMessage.ReplyTo);
|
||||
else if (referenceMessage.From.Count > 0)
|
||||
message.To.AddRange(referenceMessage.From);
|
||||
else if (referenceMessage.Sender != null)
|
||||
message.To.Add(referenceMessage.Sender);
|
||||
|
||||
if (reason == DraftCreationReason.ReplyAll)
|
||||
{
|
||||
// Include all of the other original recipients
|
||||
message.To.AddRange(referenceMessage.To.Where(x => x is MailboxAddress mailboxAddress && !mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase)));
|
||||
message.Cc.AddRange(referenceMessage.Cc.Where(x => x is MailboxAddress mailboxAddress && !mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
// Self email can be present at this step, when replying to own message. It should be removed only in case there no other recipients.
|
||||
if (message.To.Count > 1)
|
||||
{
|
||||
var self = message.To.FirstOrDefault(x => x is MailboxAddress mailboxAddress && mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase));
|
||||
if (self != null)
|
||||
message.To.Remove(self);
|
||||
}
|
||||
|
||||
// Manage "ThreadId-ConversationId"
|
||||
if (!string.IsNullOrEmpty(referenceMessage.MessageId))
|
||||
{
|
||||
message.InReplyTo = referenceMessage.MessageId;
|
||||
message.References.AddRange(referenceMessage.References);
|
||||
message.References.Add(referenceMessage.MessageId);
|
||||
}
|
||||
|
||||
message.Headers.Add("Thread-Topic", referenceMessage.Subject);
|
||||
}
|
||||
|
||||
// Manage Subject
|
||||
if (reason == DraftCreationReason.Forward && !referenceMessage.Subject.StartsWith("FW: ", StringComparison.OrdinalIgnoreCase))
|
||||
message.Subject = $"FW: {referenceMessage.Subject}";
|
||||
else if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) && !referenceMessage.Subject.StartsWith("RE: ", StringComparison.OrdinalIgnoreCase))
|
||||
message.Subject = $"RE: {referenceMessage.Subject}";
|
||||
else if (referenceMessage != null)
|
||||
message.Subject = referenceMessage.Subject;
|
||||
|
||||
// Only include attachments if forwarding.
|
||||
if (reason == DraftCreationReason.Forward && (referenceMessage?.Attachments?.Any() ?? false))
|
||||
{
|
||||
foreach (var attachment in referenceMessage.Attachments)
|
||||
{
|
||||
builder.Attachments.Add(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
|
||||
// Generates html representation of To/Cc/From/Time and so on from referenced message.
|
||||
string CreateHtmlForReferencingMessage(MimeMessage referenceMessage)
|
||||
{
|
||||
var htmlMimeInfo = string.Empty;
|
||||
// Separation Line
|
||||
htmlMimeInfo += "<hr style='display:inline-block;width:100%' tabindex='-1'>";
|
||||
|
||||
var visitor = _mimeFileService.CreateHTMLPreviewVisitor(referenceMessage, string.Empty);
|
||||
visitor.Visit(referenceMessage);
|
||||
|
||||
htmlMimeInfo += $"""
|
||||
<div id="divRplyFwdMsg" dir="ltr">
|
||||
<font face="Calibri, sans-serif" style="font-size: 11pt;" color="#000000">
|
||||
<b>From:</b> {ParticipantsToHtml(referenceMessage.From)}<br>
|
||||
<b>Sent:</b> {referenceMessage.Date.ToLocalTime()}<br>
|
||||
<b>To:</b> {ParticipantsToHtml(referenceMessage.To)}<br>
|
||||
{(referenceMessage.Cc.Count > 0 ? $"<b>Cc:</b> {ParticipantsToHtml(referenceMessage.Cc)}<br>" : string.Empty)}
|
||||
<b>Subject:</b> {referenceMessage.Subject}
|
||||
</font>
|
||||
<div> </div>
|
||||
{visitor.HtmlBody}
|
||||
</div>
|
||||
""";
|
||||
|
||||
return htmlMimeInfo;
|
||||
}
|
||||
|
||||
static string ParticipantsToHtml(InternetAddressList internetAddresses) =>
|
||||
string.Join("; ", internetAddresses.Mailboxes
|
||||
.Select(x => $"{x.Name ?? Translator.UnknownSender} <<a href=\"mailto:{x.Address ?? Translator.UnknownAddress}\">{x.Address ?? Translator.UnknownAddress}</a>>"));
|
||||
}
|
||||
|
||||
public async Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
|
||||
{
|
||||
var query = new Query("MailCopy")
|
||||
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
|
||||
.Where("MailCopy.UniqueId", localDraftCopyUniqueId)
|
||||
.Where("MailItemFolder.MailAccountId", accountId)
|
||||
.SelectRaw("MailCopy.*")
|
||||
.GetRawQuery();
|
||||
|
||||
var localDraftCopy = await Connection.FindWithQueryAsync<MailCopy>(query);
|
||||
|
||||
if (localDraftCopy == null)
|
||||
{
|
||||
_logger.Warning("Draft mapping failed because local draft copy with unique id {LocalDraftCopyUniqueId} does not exist.", localDraftCopyUniqueId);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var oldLocalDraftId = localDraftCopy.Id;
|
||||
|
||||
await LoadAssignedPropertiesAsync(localDraftCopy).ConfigureAwait(false);
|
||||
|
||||
bool isIdChanging = localDraftCopy.Id != newMailCopyId;
|
||||
|
||||
localDraftCopy.Id = newMailCopyId;
|
||||
localDraftCopy.DraftId = newDraftId;
|
||||
localDraftCopy.ThreadId = newThreadId;
|
||||
|
||||
await UpdateMailAsync(localDraftCopy).ConfigureAwait(false);
|
||||
|
||||
ReportUIChange(new DraftMapped(oldLocalDraftId, newDraftId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId)
|
||||
{
|
||||
return UpdateAllMailCopiesAsync(mailCopyId, (item) =>
|
||||
{
|
||||
if (item.ThreadId != newThreadId || item.DraftId != newDraftId)
|
||||
{
|
||||
var oldDraftId = item.DraftId;
|
||||
|
||||
item.DraftId = newDraftId;
|
||||
item.ThreadId = newThreadId;
|
||||
|
||||
ReportUIChange(new DraftMapped(oldDraftId, newDraftId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds)
|
||||
{
|
||||
var rawQuery = new Query("MailCopy")
|
||||
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
|
||||
.WhereIn("MailCopy.Id", downloadedMailCopyIds)
|
||||
.Where("MailCopy.IsRead", false)
|
||||
.Where("MailItemFolder.MailAccountId", accountId)
|
||||
.Where("MailItemFolder.SpecialFolderType", SpecialFolderType.Inbox)
|
||||
.SelectRaw("MailCopy.*")
|
||||
.GetRawQuery();
|
||||
|
||||
return Connection.QueryAsync<MailCopy>(rawQuery);
|
||||
}
|
||||
|
||||
public Task<MailAccount> GetMailAccountByUniqueIdAsync(Guid uniqueMailId)
|
||||
{
|
||||
var query = new Query("MailCopy")
|
||||
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
|
||||
.Join("MailAccount", "MailItemFolder.MailAccountId", "MailAccount.Id")
|
||||
.Where("MailCopy.UniqueId", uniqueMailId)
|
||||
.SelectRaw("MailAccount.*")
|
||||
.GetRawQuery();
|
||||
|
||||
return Connection.FindWithQueryAsync<MailAccount>(query);
|
||||
}
|
||||
|
||||
public Task<bool> IsMailExistsAsync(string mailCopyId)
|
||||
=> Connection.ExecuteScalarAsync<bool>("SELECT EXISTS(SELECT 1 FROM MailCopy WHERE Id = ?)", mailCopyId);
|
||||
|
||||
public Task<bool> IsMailExistsAsync(string mailCopyId, Guid folderId)
|
||||
=> Connection.ExecuteScalarAsync<bool>("SELECT EXISTS(SELECT 1 FROM MailCopy WHERE Id = ? AND FolderId = ?)", mailCopyId, folderId);
|
||||
}
|
||||
}
|
||||
180
Wino.Services/MimeFileService.cs
Normal file
180
Wino.Services/MimeFileService.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MimeKit;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Reader;
|
||||
using Wino.Services.Extensions;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class MimeFileService : IMimeFileService
|
||||
{
|
||||
private readonly INativeAppService _nativeAppService;
|
||||
private ILogger _logger = Log.ForContext<MimeFileService>();
|
||||
|
||||
public MimeFileService(INativeAppService nativeAppService)
|
||||
{
|
||||
_nativeAppService = nativeAppService;
|
||||
}
|
||||
|
||||
public async Task<MimeMessageInformation> GetMimeMessageInformationAsync(Guid fileId, Guid accountId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resourcePath = await GetMimeResourcePathAsync(accountId, fileId).ConfigureAwait(false);
|
||||
var mimeFilePath = GetEMLPath(resourcePath);
|
||||
|
||||
var loadedMimeMessage = await MimeMessage.LoadAsync(mimeFilePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new MimeMessageInformation(loadedMimeMessage, resourcePath);
|
||||
}
|
||||
|
||||
public async Task<MimeMessageInformation> GetMimeMessageInformationAsync(byte[] fileBytes, string emlDirectoryPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var memoryStream = new MemoryStream(fileBytes);
|
||||
|
||||
var loadedMimeMessage = await MimeMessage.LoadAsync(memoryStream, cancellationToken).ConfigureAwait(false);
|
||||
return new MimeMessageInformation(loadedMimeMessage, emlDirectoryPath);
|
||||
}
|
||||
|
||||
public async Task<bool> SaveMimeMessageAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resourcePath = await GetMimeResourcePathAsync(accountId, fileId).ConfigureAwait(false);
|
||||
var completeFilePath = GetEMLPath(resourcePath);
|
||||
|
||||
using var fileStream = File.Open(completeFilePath, FileMode.OpenOrCreate);
|
||||
|
||||
await mimeMessage.WriteToAsync(fileStream).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Could not save mime file for FileId: {FileId}", fileId);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string GetEMLPath(string resourcePath) => $"{resourcePath}\\mail.eml";
|
||||
|
||||
public async Task<string> GetMimeResourcePathAsync(Guid accountId, Guid fileId)
|
||||
{
|
||||
var mimeFolderPath = await _nativeAppService.GetMimeMessageStoragePath().ConfigureAwait(false);
|
||||
var mimeDirectory = Path.Combine(mimeFolderPath, accountId.ToString(), fileId.ToString());
|
||||
|
||||
if (!Directory.Exists(mimeDirectory))
|
||||
Directory.CreateDirectory(mimeDirectory);
|
||||
|
||||
return mimeDirectory;
|
||||
}
|
||||
|
||||
public async Task<bool> IsMimeExistAsync(Guid accountId, Guid fileId)
|
||||
{
|
||||
var resourcePath = await GetMimeResourcePathAsync(accountId, fileId);
|
||||
var completeFilePath = GetEMLPath(resourcePath);
|
||||
|
||||
return File.Exists(completeFilePath);
|
||||
}
|
||||
|
||||
public HtmlPreviewVisitor CreateHTMLPreviewVisitor(MimeMessage message, string mimeLocalPath)
|
||||
{
|
||||
var visitor = new HtmlPreviewVisitor(mimeLocalPath);
|
||||
|
||||
message.Accept(visitor);
|
||||
|
||||
// TODO: Match cid with attachments if any.
|
||||
|
||||
return visitor;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteMimeMessageAsync(Guid accountId, Guid fileId)
|
||||
{
|
||||
var resourcePath = await GetMimeResourcePathAsync(accountId, fileId);
|
||||
var completeFilePath = GetEMLPath(resourcePath);
|
||||
|
||||
if (File.Exists(completeFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(completeFilePath);
|
||||
|
||||
_logger.Information("Mime file deleted for {FileId}", fileId);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Could not delete mime file for {FileId}", fileId);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public MailRenderModel GetMailRenderModel(MimeMessage message, string mimeLocalPath, MailRenderingOptions options = null)
|
||||
{
|
||||
var visitor = CreateHTMLPreviewVisitor(message, mimeLocalPath);
|
||||
|
||||
string finalRenderHtml = visitor.HtmlBody;
|
||||
|
||||
// Check whether we need to purify the generated HTML from visitor.
|
||||
// No need to create HtmlDocument if not required.
|
||||
|
||||
if (options != null && options.IsPurifyingNeeded())
|
||||
{
|
||||
var document = new HtmlAgilityPack.HtmlDocument();
|
||||
document.LoadHtml(visitor.HtmlBody);
|
||||
|
||||
// Clear <img> src attribute.
|
||||
|
||||
if (!options.LoadImages)
|
||||
document.ClearImages();
|
||||
|
||||
if (!options.LoadStyles)
|
||||
document.ClearStyles();
|
||||
|
||||
// Update final HTML.
|
||||
finalRenderHtml = document.DocumentNode.OuterHtml;
|
||||
}
|
||||
|
||||
var renderingModel = new MailRenderModel(finalRenderHtml, options);
|
||||
|
||||
// Create attachments.
|
||||
|
||||
foreach (var attachment in visitor.Attachments)
|
||||
{
|
||||
if (attachment.IsAttachment && attachment is MimePart attachmentPart)
|
||||
{
|
||||
renderingModel.Attachments.Add(attachmentPart);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.Headers.Contains(HeaderId.ListUnsubscribe))
|
||||
{
|
||||
var unsubscribeLinks = message.Headers[HeaderId.ListUnsubscribe]
|
||||
.Normalize()
|
||||
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => x.Trim([' ', '<', '>']));
|
||||
|
||||
// Only two types of unsubscribe links are possible.
|
||||
// So each has it's own property to simplify the usage.
|
||||
renderingModel.UnsubscribeInfo = new UnsubscribeInfo()
|
||||
{
|
||||
HttpLink = unsubscribeLinks.FirstOrDefault(x => x.StartsWith("http", StringComparison.OrdinalIgnoreCase)),
|
||||
MailToLink = unsubscribeLinks.FirstOrDefault(x => x.StartsWith("mailto", StringComparison.OrdinalIgnoreCase)),
|
||||
IsOneClick = message.Headers.Contains(HeaderId.ListUnsubscribePost)
|
||||
};
|
||||
}
|
||||
|
||||
return renderingModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
Wino.Services/ServiceConstants.cs
Normal file
64
Wino.Services/ServiceConstants.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Generic;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public static class ServiceConstants
|
||||
{
|
||||
#region Gmail Constants
|
||||
|
||||
public const string INBOX_LABEL_ID = "INBOX";
|
||||
public const string UNREAD_LABEL_ID = "UNREAD";
|
||||
public const string IMPORTANT_LABEL_ID = "IMPORTANT";
|
||||
public const string STARRED_LABEL_ID = "STARRED";
|
||||
public const string DRAFT_LABEL_ID = "DRAFT";
|
||||
public const string SENT_LABEL_ID = "SENT";
|
||||
public const string SPAM_LABEL_ID = "SPAM";
|
||||
public const string CHAT_LABEL_ID = "CHAT";
|
||||
public const string TRASH_LABEL_ID = "TRASH";
|
||||
|
||||
// Category labels.
|
||||
public const string FORUMS_LABEL_ID = "FORUMS";
|
||||
public const string UPDATES_LABEL_ID = "UPDATES";
|
||||
public const string PROMOTIONS_LABEL_ID = "PROMOTIONS";
|
||||
public const string SOCIAL_LABEL_ID = "SOCIAL";
|
||||
public const string PERSONAL_LABEL_ID = "PERSONAL";
|
||||
|
||||
// Label visibility identifiers.
|
||||
public const string SYSTEM_FOLDER_IDENTIFIER = "system";
|
||||
public const string FOLDER_HIDE_IDENTIFIER = "labelHide";
|
||||
|
||||
public const string CATEGORY_PREFIX = "CATEGORY_";
|
||||
public const string FOLDER_SEPERATOR_STRING = "/";
|
||||
public const char FOLDER_SEPERATOR_CHAR = '/';
|
||||
|
||||
public static Dictionary<string, SpecialFolderType> KnownFolderDictionary = new Dictionary<string, SpecialFolderType>()
|
||||
{
|
||||
{ INBOX_LABEL_ID, SpecialFolderType.Inbox },
|
||||
{ CHAT_LABEL_ID, SpecialFolderType.Chat },
|
||||
{ IMPORTANT_LABEL_ID, SpecialFolderType.Important },
|
||||
{ TRASH_LABEL_ID, SpecialFolderType.Deleted },
|
||||
{ DRAFT_LABEL_ID, SpecialFolderType.Draft },
|
||||
{ SENT_LABEL_ID, SpecialFolderType.Sent },
|
||||
{ SPAM_LABEL_ID, SpecialFolderType.Junk },
|
||||
{ STARRED_LABEL_ID, SpecialFolderType.Starred },
|
||||
{ UNREAD_LABEL_ID, SpecialFolderType.Unread },
|
||||
{ FORUMS_LABEL_ID, SpecialFolderType.Forums },
|
||||
{ UPDATES_LABEL_ID, SpecialFolderType.Updates },
|
||||
{ PROMOTIONS_LABEL_ID, SpecialFolderType.Promotions },
|
||||
{ SOCIAL_LABEL_ID, SpecialFolderType.Social},
|
||||
{ PERSONAL_LABEL_ID, SpecialFolderType.Personal},
|
||||
};
|
||||
|
||||
public static string[] SubCategoryFolderLabelIds =
|
||||
[
|
||||
FORUMS_LABEL_ID,
|
||||
UPDATES_LABEL_ID,
|
||||
PROMOTIONS_LABEL_ID,
|
||||
SOCIAL_LABEL_ID,
|
||||
PERSONAL_LABEL_ID
|
||||
];
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
32
Wino.Services/ServicesContainerSetup.cs
Normal file
32
Wino.Services/ServicesContainerSetup.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Services.Threading;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public static class ServicesContainerSetup
|
||||
{
|
||||
public static void RegisterSharedServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ITranslationService, TranslationService>();
|
||||
services.AddSingleton<IDatabaseService, DatabaseService>();
|
||||
|
||||
services.AddSingleton<IApplicationConfiguration, ApplicationConfiguration>();
|
||||
services.AddSingleton<ILogInitializer, LogInitializer>();
|
||||
services.AddSingleton<ILaunchProtocolService, LaunchProtocolService>();
|
||||
services.AddSingleton<IMimeFileService, MimeFileService>();
|
||||
|
||||
services.AddTransient<IMailService, MailService>();
|
||||
services.AddTransient<IFolderService, FolderService>();
|
||||
services.AddTransient<IAccountService, AccountService>();
|
||||
services.AddTransient<IContactService, ContactService>();
|
||||
services.AddTransient<ISignatureService, SignatureService>();
|
||||
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
|
||||
|
||||
services.AddSingleton<IThreadingStrategyProvider, ThreadingStrategyProvider>();
|
||||
services.AddTransient<IOutlookThreadingStrategy, OutlookThreadingStrategy>();
|
||||
services.AddTransient<IGmailThreadingStrategy, GmailThreadingStrategy>();
|
||||
services.AddTransient<IImapThreadingStrategy, ImapThreadingStrategy>();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Wino.Services/SignatureService.cs
Normal file
58
Wino.Services/SignatureService.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class SignatureService(IDatabaseService databaseService) : BaseDatabaseService(databaseService), ISignatureService
|
||||
{
|
||||
public async Task<AccountSignature> GetSignatureAsync(Guid signatureId)
|
||||
{
|
||||
return await Connection.Table<AccountSignature>().FirstAsync(s => s.Id == signatureId);
|
||||
}
|
||||
|
||||
public async Task<List<AccountSignature>> GetSignaturesAsync(Guid accountId)
|
||||
{
|
||||
return await Connection.Table<AccountSignature>().Where(s => s.MailAccountId == accountId).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<AccountSignature> CreateSignatureAsync(AccountSignature signature)
|
||||
{
|
||||
await Connection.InsertAsync(signature);
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
public async Task<AccountSignature> CreateDefaultSignatureAsync(Guid accountId)
|
||||
{
|
||||
var defaultSignature = new AccountSignature()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MailAccountId = accountId,
|
||||
// TODO: Should be translated?
|
||||
Name = "Wino Default Signature",
|
||||
HtmlBody = @"<p>Sent from <a href=""https://github.com/bkaankose/Wino-Mail/"">Wino Mail</a> for Windows</p>"
|
||||
};
|
||||
|
||||
await Connection.InsertAsync(defaultSignature);
|
||||
|
||||
return defaultSignature;
|
||||
}
|
||||
|
||||
public async Task<AccountSignature> UpdateSignatureAsync(AccountSignature signature)
|
||||
{
|
||||
await Connection.UpdateAsync(signature);
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
public async Task<AccountSignature> DeleteSignatureAsync(AccountSignature signature)
|
||||
{
|
||||
await Connection.DeleteAsync(signature);
|
||||
|
||||
return signature;
|
||||
}
|
||||
}
|
||||
}
|
||||
138
Wino.Services/Threading/APIThreadingStrategy.cs
Normal file
138
Wino.Services/Threading/APIThreadingStrategy.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Services;
|
||||
|
||||
namespace Wino.Services.Threading
|
||||
{
|
||||
public class APIThreadingStrategy : IThreadingStrategy
|
||||
{
|
||||
private readonly IDatabaseService _databaseService;
|
||||
private readonly IFolderService _folderService;
|
||||
|
||||
public APIThreadingStrategy(IDatabaseService databaseService, IFolderService folderService)
|
||||
{
|
||||
_databaseService = databaseService;
|
||||
_folderService = folderService;
|
||||
}
|
||||
|
||||
public virtual bool ShouldThreadWithItem(IMailItem originalItem, IMailItem targetItem)
|
||||
{
|
||||
return originalItem.ThreadId != null && originalItem.ThreadId == targetItem.ThreadId;
|
||||
}
|
||||
|
||||
///<inheritdoc/>
|
||||
public async Task<List<IMailItem>> ThreadItemsAsync(List<MailCopy> items, IMailItemFolder threadingForFolder)
|
||||
{
|
||||
var assignedAccount = items[0].AssignedAccount;
|
||||
|
||||
var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(assignedAccount.Id, SpecialFolderType.Sent);
|
||||
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(assignedAccount.Id, SpecialFolderType.Draft);
|
||||
|
||||
if (sentFolder == null || draftFolder == null) return default;
|
||||
|
||||
// True: Non threaded items.
|
||||
// False: Potentially threaded items.
|
||||
var nonThreadedOrThreadedMails = items
|
||||
.Distinct()
|
||||
.GroupBy(x => string.IsNullOrEmpty(x.ThreadId))
|
||||
.ToDictionary(x => x.Key, x => x);
|
||||
|
||||
_ = nonThreadedOrThreadedMails.TryGetValue(true, out var nonThreadedMails);
|
||||
var isThreadedItems = nonThreadedOrThreadedMails.TryGetValue(false, out var potentiallyThreadedMails);
|
||||
|
||||
List<IMailItem> resultList = nonThreadedMails is null ? [] : [.. nonThreadedMails];
|
||||
|
||||
if (isThreadedItems)
|
||||
{
|
||||
var threadItems = (await GetThreadItemsAsync(potentiallyThreadedMails.Select(x => (x.ThreadId, x.AssignedFolder)).ToList(), assignedAccount.Id, sentFolder.Id, draftFolder.Id))
|
||||
.GroupBy(x => x.ThreadId);
|
||||
|
||||
foreach (var threadItem in threadItems)
|
||||
{
|
||||
if (threadItem.Count() == 1)
|
||||
{
|
||||
resultList.Add(threadItem.First());
|
||||
continue;
|
||||
}
|
||||
|
||||
var thread = new ThreadMailItem();
|
||||
|
||||
foreach (var childThreadItem in threadItem)
|
||||
{
|
||||
if (thread.ThreadItems.Any(a => a.Id == childThreadItem.Id))
|
||||
{
|
||||
// Mail already exist in the thread.
|
||||
// There should be only 1 instance of the mail in the thread.
|
||||
// Make sure we add the correct one.
|
||||
|
||||
// Add the one with threading folder.
|
||||
var threadingFolderItem = threadItem.FirstOrDefault(a => a.Id == childThreadItem.Id && a.FolderId == threadingForFolder.Id);
|
||||
|
||||
if (threadingFolderItem == null) continue;
|
||||
|
||||
// Remove the existing one.
|
||||
thread.ThreadItems.Remove(thread.ThreadItems.First(a => a.Id == childThreadItem.Id));
|
||||
|
||||
// Add the correct one for listing.
|
||||
thread.AddThreadItem(threadingFolderItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
thread.AddThreadItem(childThreadItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (thread.ThreadItems.Count > 1)
|
||||
{
|
||||
resultList.Add(thread);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Don't make threads if the thread has only one item.
|
||||
// Gmail has may have multiple assignments for the same item.
|
||||
|
||||
resultList.Add(thread.ThreadItems.First());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resultList;
|
||||
}
|
||||
|
||||
private async Task<List<MailCopy>> GetThreadItemsAsync(List<(string threadId, MailItemFolder threadingFolder)> potentialThread,
|
||||
Guid accountId,
|
||||
Guid sentFolderId,
|
||||
Guid draftFolderId)
|
||||
{
|
||||
// Only items from the folder that we are threading for, sent and draft folder items must be included.
|
||||
// This is important because deleted items or item assignments that belongs to different folder is
|
||||
// affecting the thread creation here.
|
||||
|
||||
// If the threading is done from Sent or Draft folder, include everything...
|
||||
|
||||
// TODO: Convert to SQLKata query.
|
||||
|
||||
var query = @$"SELECT DISTINCT MC.* FROM MailCopy MC
|
||||
INNER JOIN MailItemFolder MF on MF.Id = MC.FolderId
|
||||
WHERE MF.MailAccountId == '{accountId}' AND
|
||||
({string.Join(" OR ", potentialThread.Select(x => ConditionForItem(x, sentFolderId, draftFolderId)))})";
|
||||
|
||||
return await _databaseService.Connection.QueryAsync<MailCopy>(query);
|
||||
|
||||
static string ConditionForItem((string threadId, MailItemFolder threadingFolder) potentialThread, Guid sentFolderId, Guid draftFolderId)
|
||||
{
|
||||
if (potentialThread.threadingFolder.SpecialFolderType == SpecialFolderType.Draft || potentialThread.threadingFolder.SpecialFolderType == SpecialFolderType.Sent)
|
||||
return $"(MC.ThreadId = '{potentialThread.threadId}')";
|
||||
|
||||
return $"(MC.ThreadId = '{potentialThread.threadId}' AND MC.FolderId IN ('{potentialThread.threadingFolder.Id}','{sentFolderId}','{draftFolderId}'))";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Wino.Services/Threading/GmailThreadingStrategy.cs
Normal file
9
Wino.Services/Threading/GmailThreadingStrategy.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Services.Threading
|
||||
{
|
||||
public class GmailThreadingStrategy : APIThreadingStrategy, IGmailThreadingStrategy
|
||||
{
|
||||
public GmailThreadingStrategy(IDatabaseService databaseService, IFolderService folderService) : base(databaseService, folderService) { }
|
||||
}
|
||||
}
|
||||
179
Wino.Services/Threading/ImapThreadingStrategy.cs
Normal file
179
Wino.Services/Threading/ImapThreadingStrategy.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using SqlKata;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Services.Extensions;
|
||||
|
||||
namespace Wino.Services.Threading
|
||||
{
|
||||
public class ImapThreadingStrategy : BaseDatabaseService, IImapThreadingStrategy
|
||||
{
|
||||
private readonly IFolderService _folderService;
|
||||
|
||||
public ImapThreadingStrategy(IDatabaseService databaseService, IFolderService folderService) : base(databaseService)
|
||||
{
|
||||
_folderService = folderService;
|
||||
}
|
||||
|
||||
private Task<MailCopy> GetReplyParentAsync(IMailItem replyItem, Guid accountId, Guid threadingFolderId, Guid sentFolderId, Guid draftFolderId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(replyItem?.MessageId)) return Task.FromResult<MailCopy>(null);
|
||||
|
||||
var query = new Query("MailCopy")
|
||||
.Distinct()
|
||||
.Take(1)
|
||||
.Join("MailItemFolder", "MailItemFolder.Id", "MailCopy.FolderId")
|
||||
.Where("MailItemFolder.MailAccountId", accountId)
|
||||
.WhereIn("MailItemFolder.Id", new List<Guid> { threadingFolderId, sentFolderId, draftFolderId })
|
||||
.Where("MailCopy.MessageId", replyItem.InReplyTo)
|
||||
.WhereNot("MailCopy.Id", replyItem.Id)
|
||||
.Select("MailCopy.*");
|
||||
|
||||
return Connection.FindWithQueryAsync<MailCopy>(query.GetRawQuery());
|
||||
}
|
||||
|
||||
private Task<MailCopy> GetInReplyToReplyAsync(IMailItem originalItem, Guid accountId, Guid threadingFolderId, Guid sentFolderId, Guid draftFolderId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(originalItem?.MessageId)) return Task.FromResult<MailCopy>(null);
|
||||
|
||||
var query = new Query("MailCopy")
|
||||
.Distinct()
|
||||
.Take(1)
|
||||
.Join("MailItemFolder", "MailItemFolder.Id", "MailCopy.FolderId")
|
||||
.WhereNot("MailCopy.Id", originalItem.Id)
|
||||
.Where("MailItemFolder.MailAccountId", accountId)
|
||||
.Where("MailCopy.InReplyTo", originalItem.MessageId)
|
||||
.WhereIn("MailItemFolder.Id", new List<Guid> { threadingFolderId, sentFolderId, draftFolderId })
|
||||
.Select("MailCopy.*");
|
||||
|
||||
var raq = query.GetRawQuery();
|
||||
|
||||
return Connection.FindWithQueryAsync<MailCopy>(query.GetRawQuery());
|
||||
}
|
||||
|
||||
public async Task<List<IMailItem>> ThreadItemsAsync(List<MailCopy> items, IMailItemFolder threadingForFolder)
|
||||
{
|
||||
var threads = new List<ThreadMailItem>();
|
||||
|
||||
var account = items.First().AssignedAccount;
|
||||
var accountId = account.Id;
|
||||
|
||||
// Child -> Parent approach.
|
||||
|
||||
var mailLookupTable = new Dictionary<string, bool>();
|
||||
|
||||
// Fill up the mail lookup table to prevent double thread creation.
|
||||
foreach (var mail in items)
|
||||
if (!mailLookupTable.ContainsKey(mail.Id))
|
||||
mailLookupTable.Add(mail.Id, false);
|
||||
|
||||
var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, SpecialFolderType.Sent);
|
||||
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, SpecialFolderType.Draft);
|
||||
|
||||
// Threading is not possible. Return items as it is.
|
||||
|
||||
if (sentFolder == null || draftFolder == null) return new List<IMailItem>(items);
|
||||
|
||||
foreach (var replyItem in items)
|
||||
{
|
||||
if (mailLookupTable[replyItem.Id])
|
||||
continue;
|
||||
|
||||
mailLookupTable[replyItem.Id] = true;
|
||||
|
||||
var threadItem = new ThreadMailItem();
|
||||
|
||||
threadItem.AddThreadItem(replyItem);
|
||||
|
||||
var replyToChild = await GetReplyParentAsync(replyItem, accountId, replyItem.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
|
||||
|
||||
// Build up
|
||||
while (replyToChild != null)
|
||||
{
|
||||
replyToChild.AssignedAccount = account;
|
||||
|
||||
if (replyToChild.FolderId == draftFolder.Id)
|
||||
replyToChild.AssignedFolder = draftFolder;
|
||||
|
||||
if (replyToChild.FolderId == sentFolder.Id)
|
||||
replyToChild.AssignedFolder = sentFolder;
|
||||
|
||||
if (replyToChild.FolderId == replyItem.AssignedFolder.Id)
|
||||
replyToChild.AssignedFolder = replyItem.AssignedFolder;
|
||||
|
||||
threadItem.AddThreadItem(replyToChild);
|
||||
|
||||
if (mailLookupTable.ContainsKey(replyToChild.Id))
|
||||
mailLookupTable[replyToChild.Id] = true;
|
||||
|
||||
replyToChild = await GetReplyParentAsync(replyToChild, accountId, replyToChild.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
|
||||
}
|
||||
|
||||
// Build down
|
||||
var replyToParent = await GetInReplyToReplyAsync(replyItem, accountId, replyItem.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
|
||||
|
||||
while (replyToParent != null)
|
||||
{
|
||||
replyToParent.AssignedAccount = account;
|
||||
|
||||
if (replyToParent.FolderId == draftFolder.Id)
|
||||
replyToParent.AssignedFolder = draftFolder;
|
||||
|
||||
if (replyToParent.FolderId == sentFolder.Id)
|
||||
replyToParent.AssignedFolder = sentFolder;
|
||||
|
||||
if (replyToParent.FolderId == replyItem.AssignedFolder.Id)
|
||||
replyToParent.AssignedFolder = replyItem.AssignedFolder;
|
||||
|
||||
threadItem.AddThreadItem(replyToParent);
|
||||
|
||||
if (mailLookupTable.ContainsKey(replyToParent.Id))
|
||||
mailLookupTable[replyToParent.Id] = true;
|
||||
|
||||
replyToParent = await GetInReplyToReplyAsync(replyToParent, accountId, replyToParent.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
|
||||
}
|
||||
|
||||
// It's a thread item.
|
||||
|
||||
if (threadItem.ThreadItems.Count > 1 && !threads.Exists(a => a.Id == threadItem.Id))
|
||||
{
|
||||
threads.Add(threadItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
// False alert. This is not a thread item.
|
||||
mailLookupTable[replyItem.Id] = false;
|
||||
|
||||
// TODO: Here potentially check other algorithms for threading like References.
|
||||
}
|
||||
}
|
||||
|
||||
// At this points all mails in the list belong to single items.
|
||||
// Merge with threads.
|
||||
// Last sorting will be done later on in MailService.
|
||||
|
||||
// Remove single mails that are included in thread.
|
||||
items.RemoveAll(a => mailLookupTable.ContainsKey(a.Id) && mailLookupTable[a.Id]);
|
||||
|
||||
var finalList = new List<IMailItem>(items);
|
||||
|
||||
finalList.AddRange(threads);
|
||||
|
||||
return finalList;
|
||||
}
|
||||
|
||||
public bool ShouldThreadWithItem(IMailItem originalItem, IMailItem targetItem)
|
||||
{
|
||||
bool isChild = originalItem.InReplyTo != null && originalItem.InReplyTo == targetItem.MessageId;
|
||||
bool isParent = originalItem.MessageId != null && originalItem.MessageId == targetItem.InReplyTo;
|
||||
|
||||
return isChild || isParent;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Wino.Services/Threading/OutlookThreadingStrategy.cs
Normal file
13
Wino.Services/Threading/OutlookThreadingStrategy.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Services.Threading
|
||||
{
|
||||
// Outlook and Gmail is using the same threading strategy.
|
||||
// Outlook: ConversationId -> it's set as ThreadId
|
||||
// Gmail: ThreadId
|
||||
|
||||
public class OutlookThreadingStrategy : APIThreadingStrategy, IOutlookThreadingStrategy
|
||||
{
|
||||
public OutlookThreadingStrategy(IDatabaseService databaseService, IFolderService folderService) : base(databaseService, folderService) { }
|
||||
}
|
||||
}
|
||||
31
Wino.Services/ThreadingStrategyProvider.cs
Normal file
31
Wino.Services/ThreadingStrategyProvider.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class ThreadingStrategyProvider : IThreadingStrategyProvider
|
||||
{
|
||||
private readonly IOutlookThreadingStrategy _outlookThreadingStrategy;
|
||||
private readonly IGmailThreadingStrategy _gmailThreadingStrategy;
|
||||
private readonly IImapThreadingStrategy _imapThreadStrategy;
|
||||
|
||||
public ThreadingStrategyProvider(IOutlookThreadingStrategy outlookThreadingStrategy,
|
||||
IGmailThreadingStrategy gmailThreadingStrategy,
|
||||
IImapThreadingStrategy imapThreadStrategy)
|
||||
{
|
||||
_outlookThreadingStrategy = outlookThreadingStrategy;
|
||||
_gmailThreadingStrategy = gmailThreadingStrategy;
|
||||
_imapThreadStrategy = imapThreadStrategy;
|
||||
}
|
||||
|
||||
public IThreadingStrategy GetStrategy(MailProviderType mailProviderType)
|
||||
{
|
||||
return mailProviderType switch
|
||||
{
|
||||
MailProviderType.Outlook or MailProviderType.Office365 => _outlookThreadingStrategy,
|
||||
MailProviderType.Gmail => _gmailThreadingStrategy,
|
||||
_ => _imapThreadStrategy,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
92
Wino.Services/TranslationService.cs
Normal file
92
Wino.Services/TranslationService.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Translations;
|
||||
using Wino.Messaging.Client.Shell;
|
||||
|
||||
namespace Wino.Services
|
||||
{
|
||||
public class TranslationService : ITranslationService
|
||||
{
|
||||
public const AppLanguage DefaultAppLanguage = AppLanguage.English;
|
||||
|
||||
private ILogger _logger = Log.ForContext<TranslationService>();
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
private bool isInitialized = false;
|
||||
|
||||
public TranslationService(IPreferencesService preferencesService)
|
||||
{
|
||||
_preferencesService = preferencesService;
|
||||
}
|
||||
|
||||
// Initialize default language with ignoring current language check.
|
||||
public Task InitializeAsync() => InitializeLanguageAsync(_preferencesService.CurrentLanguage, ignoreCurrentLanguageCheck: true);
|
||||
|
||||
public async Task InitializeLanguageAsync(AppLanguage language, bool ignoreCurrentLanguageCheck = false)
|
||||
{
|
||||
if (!ignoreCurrentLanguageCheck && _preferencesService.CurrentLanguage == language)
|
||||
{
|
||||
_logger.Warning("Changing language is ignored because current language and requested language are same.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (ignoreCurrentLanguageCheck && isInitialized) return;
|
||||
|
||||
var currentDictionary = Translator.Resources;
|
||||
using var resourceStream = currentDictionary.GetLanguageStream(language);
|
||||
|
||||
var stremValue = await new StreamReader(resourceStream).ReadToEndAsync().ConfigureAwait(false);
|
||||
|
||||
var translationLookups = JsonSerializer.Deserialize<Dictionary<string, string>>(stremValue);
|
||||
|
||||
// Insert new translation key-value pairs.
|
||||
// Overwrite existing values for the same keys.
|
||||
|
||||
foreach (var pair in translationLookups)
|
||||
{
|
||||
// Replace existing value.
|
||||
if (currentDictionary.ContainsKey(pair.Key))
|
||||
{
|
||||
currentDictionary[pair.Key] = pair.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentDictionary.Add(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
_preferencesService.CurrentLanguage = language;
|
||||
|
||||
isInitialized = true;
|
||||
WeakReferenceMessenger.Default.Send(new LanguageChanged());
|
||||
}
|
||||
|
||||
public List<AppLanguageModel> GetAvailableLanguages()
|
||||
{
|
||||
return
|
||||
[
|
||||
new AppLanguageModel(AppLanguage.Chinese, "Chinese"),
|
||||
new AppLanguageModel(AppLanguage.Czech, "Czech"),
|
||||
new AppLanguageModel(AppLanguage.Deutsch, "Deutsch"),
|
||||
new AppLanguageModel(AppLanguage.English, "English"),
|
||||
new AppLanguageModel(AppLanguage.French, "French"),
|
||||
new AppLanguageModel(AppLanguage.Italian, "Italian"),
|
||||
new AppLanguageModel(AppLanguage.Greek, "Greek"),
|
||||
new AppLanguageModel(AppLanguage.Indonesian, "Indonesian"),
|
||||
new AppLanguageModel(AppLanguage.Polish, "Polski"),
|
||||
new AppLanguageModel(AppLanguage.PortugeseBrazil, "Portugese-Brazil"),
|
||||
new AppLanguageModel(AppLanguage.Russian, "Russian"),
|
||||
new AppLanguageModel(AppLanguage.Romanian, "Romanian"),
|
||||
new AppLanguageModel(AppLanguage.Spanish, "Spanish"),
|
||||
new AppLanguageModel(AppLanguage.Turkish, "Turkish")
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Wino.Services/Wino.Services.csproj
Normal file
23
Wino.Services/Wino.Services.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<LangVersion>12.0</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.70" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||
<PackageReference Include="Serilog" Version="4.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
|
||||
<PackageReference Include="SqlKata" Version="2.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
|
||||
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user