Initial commit.
This commit is contained in:
22
Wino.Core/Authenticators/BaseAuthenticator.cs
Normal file
22
Wino.Core/Authenticators/BaseAuthenticator.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Authenticators
|
||||
{
|
||||
public abstract class BaseAuthenticator
|
||||
{
|
||||
public abstract MailProviderType ProviderType { get; }
|
||||
|
||||
protected ITokenService TokenService { get; }
|
||||
|
||||
protected BaseAuthenticator(ITokenService tokenService)
|
||||
{
|
||||
TokenService = tokenService;
|
||||
}
|
||||
|
||||
internal Task SaveTokenInternalAsync(MailAccount account, TokenInformation tokenInformation)
|
||||
=> TokenService.SaveTokenInformationAsync(account.Id, tokenInformation);
|
||||
}
|
||||
}
|
||||
34
Wino.Core/Authenticators/CustomAuthenticator.cs
Normal file
34
Wino.Core/Authenticators/CustomAuthenticator.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Authenticators
|
||||
{
|
||||
public class CustomAuthenticator : BaseAuthenticator, IAuthenticator
|
||||
{
|
||||
public CustomAuthenticator(ITokenService tokenService) : base(tokenService) { }
|
||||
|
||||
public override MailProviderType ProviderType => MailProviderType.IMAP4;
|
||||
|
||||
public string ClientId => throw new NotImplementedException(); // Not needed.
|
||||
|
||||
public event EventHandler<string> InteractiveAuthenticationRequired;
|
||||
|
||||
public void CancelAuthorization() { }
|
||||
|
||||
public void ContinueAuthorization(Uri authorizationResponseUri) { }
|
||||
|
||||
public Task<TokenInformation> GenerateTokenAsync(MailAccount account, bool saveToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<TokenInformation> GetTokenAsync(MailAccount account)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
219
Wino.Core/Authenticators/GmailAuthenticator.cs
Normal file
219
Wino.Core/Authenticators/GmailAuthenticator.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Nito.AsyncEx;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Authentication;
|
||||
using Wino.Core.Domain.Models.Authorization;
|
||||
using Wino.Core.Services;
|
||||
using Xamarin.Essentials;
|
||||
|
||||
namespace Wino.Core.Authenticators
|
||||
{
|
||||
public class GmailAuthenticator : BaseAuthenticator, IAuthenticator
|
||||
{
|
||||
public string ClientId { get; } = "973025879644-s7b4ur9p3rlgop6a22u7iuptdc0brnrn.apps.googleusercontent.com";
|
||||
|
||||
private const string TokenEndpoint = "https://www.googleapis.com/oauth2/v4/token";
|
||||
private const string RefreshTokenEndpoint = "https://oauth2.googleapis.com/token";
|
||||
private const string UserInfoEndpoint = "https://gmail.googleapis.com/gmail/v1/users/me/profile";
|
||||
|
||||
public override MailProviderType ProviderType => MailProviderType.Gmail;
|
||||
|
||||
private TaskCompletionSource<Uri> _authorizationCompletionSource = null;
|
||||
private CancellationTokenSource _authorizationCancellationTokenSource = null;
|
||||
|
||||
private readonly INativeAppService _nativeAppService;
|
||||
|
||||
public event EventHandler<string> InteractiveAuthenticationRequired;
|
||||
|
||||
public GmailAuthenticator(ITokenService tokenService, INativeAppService nativeAppService) : base(tokenService)
|
||||
{
|
||||
_nativeAppService = nativeAppService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs tokenization code exchange and retrieves the actual Access - Refresh tokens from Google
|
||||
/// after redirect uri returns from browser.
|
||||
/// </summary>
|
||||
/// <param name="tokenizationRequest">Tokenization request.</param>
|
||||
/// <exception cref="GoogleAuthenticationException">In case of network or parsing related error.</exception>
|
||||
private async Task<TokenInformation> PerformCodeExchangeAsync(GoogleTokenizationRequest tokenizationRequest)
|
||||
{
|
||||
var uri = tokenizationRequest.BuildRequest();
|
||||
|
||||
var content = new StringContent(uri, Encoding.UTF8, "application/x-www-form-urlencoded");
|
||||
|
||||
var handler = new HttpClientHandler()
|
||||
{
|
||||
AllowAutoRedirect = true
|
||||
};
|
||||
|
||||
var client = new HttpClient(handler);
|
||||
|
||||
var response = await client.PostAsync(TokenEndpoint, content);
|
||||
string responseString = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new GoogleAuthenticationException(Translator.Exception_GoogleAuthorizationCodeExchangeFailed);
|
||||
|
||||
var parsed = JObject.Parse(responseString);
|
||||
|
||||
if (parsed.ContainsKey("error"))
|
||||
throw new GoogleAuthenticationException(parsed["error"]["message"].Value<string>());
|
||||
|
||||
var accessToken = parsed["access_token"].Value<string>();
|
||||
var refreshToken = parsed["refresh_token"].Value<string>();
|
||||
var expiresIn = parsed["expires_in"].Value<long>();
|
||||
|
||||
var expirationDate = DateTime.UtcNow.AddSeconds(expiresIn);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
// Get basic user info for UserName.
|
||||
|
||||
var userinfoResponse = await client.GetAsync(UserInfoEndpoint);
|
||||
string userinfoResponseContent = await userinfoResponse.Content.ReadAsStringAsync();
|
||||
|
||||
var parsedUserInfo = JObject.Parse(userinfoResponseContent);
|
||||
|
||||
if (parsedUserInfo.ContainsKey("error"))
|
||||
throw new GoogleAuthenticationException(parsedUserInfo["error"]["message"].Value<string>());
|
||||
|
||||
var username = parsedUserInfo["emailAddress"].Value<string>();
|
||||
|
||||
return new TokenInformation()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Address = username,
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresAt = expirationDate
|
||||
};
|
||||
}
|
||||
|
||||
public void ContinueAuthorization(Uri authorizationResponseUri) => _authorizationCompletionSource?.TrySetResult(authorizationResponseUri);
|
||||
|
||||
public async Task<TokenInformation> GetTokenAsync(MailAccount account)
|
||||
{
|
||||
var cachedToken = await TokenService.GetTokenInformationAsync(account.Id)
|
||||
?? throw new AuthenticationAttentionException(account);
|
||||
|
||||
if (cachedToken.IsExpired)
|
||||
{
|
||||
// Refresh token with new exchanges.
|
||||
// No need to check Username for account.
|
||||
|
||||
var refreshedTokenInfoBase = await RefreshTokenAsync(cachedToken.RefreshToken);
|
||||
|
||||
cachedToken.RefreshTokens(refreshedTokenInfoBase);
|
||||
|
||||
// Save new token and return.
|
||||
await SaveTokenInternalAsync(account, cachedToken);
|
||||
}
|
||||
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
|
||||
public async Task<TokenInformation> GenerateTokenAsync(MailAccount account, bool saveToken)
|
||||
{
|
||||
var authRequest = _nativeAppService.GetGoogleAuthorizationRequest();
|
||||
|
||||
_authorizationCompletionSource = new TaskCompletionSource<Uri>();
|
||||
_authorizationCancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
var authorizationUri = authRequest.BuildRequest(ClientId);
|
||||
|
||||
await Browser.OpenAsync(authorizationUri, BrowserLaunchMode.SystemPreferred);
|
||||
|
||||
Uri responseRedirectUri = null;
|
||||
|
||||
try
|
||||
{
|
||||
responseRedirectUri = await _authorizationCompletionSource.Task.WaitAsync(_authorizationCancellationTokenSource.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw new AuthenticationException(Translator.Exception_AuthenticationCanceled);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_authorizationCancellationTokenSource.Dispose();
|
||||
_authorizationCancellationTokenSource = null;
|
||||
_authorizationCompletionSource = null;
|
||||
}
|
||||
|
||||
authRequest.ValidateAuthorizationCode(responseRedirectUri);
|
||||
|
||||
// Start tokenization.
|
||||
var tokenizationRequest = new GoogleTokenizationRequest(authRequest);
|
||||
var tokenInformation = await PerformCodeExchangeAsync(tokenizationRequest);
|
||||
|
||||
if (saveToken)
|
||||
{
|
||||
await SaveTokenInternalAsync(account, tokenInformation);
|
||||
}
|
||||
|
||||
return tokenInformation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internally exchanges refresh token with a new access token and returns new TokenInformation.
|
||||
/// </summary>
|
||||
/// <param name="refresh_token">Token to be used in refreshing.</param>
|
||||
/// <returns>New TokenInformationBase that has new tokens and expiration date without a username. This token is not saved to database after returned.</returns>
|
||||
private async Task<TokenInformationBase> RefreshTokenAsync(string refresh_token)
|
||||
{
|
||||
// TODO: This doesn't work.
|
||||
var refreshUri = string.Format("client_id={0}&refresh_token={1}&grant_type=refresh_token", ClientId, refresh_token);
|
||||
|
||||
//Uri.EscapeDataString(refreshUri);
|
||||
var content = new StringContent(refreshUri, Encoding.UTF8, "application/x-www-form-urlencoded");
|
||||
|
||||
var client = new HttpClient();
|
||||
|
||||
var response = await client.PostAsync(RefreshTokenEndpoint, content);
|
||||
|
||||
string responseString = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var parsed = JObject.Parse(responseString);
|
||||
|
||||
// TODO: Error parsing is incorrect.
|
||||
if (parsed.ContainsKey("error"))
|
||||
throw new GoogleAuthenticationException(parsed["error_description"].Value<string>());
|
||||
|
||||
var accessToken = parsed["access_token"].Value<string>();
|
||||
|
||||
string activeRefreshToken = refresh_token;
|
||||
|
||||
// Refresh token might not be returned.
|
||||
// In this case older refresh token is still available for new refreshes.
|
||||
// Only change if provided.
|
||||
|
||||
if (parsed.ContainsKey("refresh_token"))
|
||||
{
|
||||
activeRefreshToken = parsed["refresh_token"].Value<string>();
|
||||
}
|
||||
|
||||
var expiresIn = parsed["expires_in"].Value<long>();
|
||||
var expirationDate = DateTime.UtcNow.AddSeconds(expiresIn);
|
||||
|
||||
return new TokenInformationBase()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
ExpiresAt = expirationDate,
|
||||
RefreshToken = activeRefreshToken
|
||||
};
|
||||
}
|
||||
|
||||
public void CancelAuthorization() => _authorizationCancellationTokenSource?.Cancel();
|
||||
}
|
||||
}
|
||||
13
Wino.Core/Authenticators/Office365Authenticator.cs
Normal file
13
Wino.Core/Authenticators/Office365Authenticator.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Authenticators
|
||||
{
|
||||
public class Office365Authenticator : OutlookAuthenticator
|
||||
{
|
||||
public Office365Authenticator(ITokenService tokenService, INativeAppService nativeAppService) : base(tokenService, nativeAppService) { }
|
||||
|
||||
public override MailProviderType ProviderType => MailProviderType.Office365;
|
||||
}
|
||||
}
|
||||
115
Wino.Core/Authenticators/OutlookAuthenticator.cs
Normal file
115
Wino.Core/Authenticators/OutlookAuthenticator.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Identity.Client;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Extensions;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Authenticators
|
||||
{
|
||||
public class OutlookAuthenticator : BaseAuthenticator, IAuthenticator
|
||||
{
|
||||
// Outlook
|
||||
private const string Authority = "https://login.microsoftonline.com/common";
|
||||
|
||||
public string ClientId { get; } = "b19c2035-d740-49ff-b297-de6ec561b208";
|
||||
|
||||
private readonly string[] MailScope = new string[] { "email", "mail.readwrite", "offline_access", "mail.send" };
|
||||
|
||||
public override MailProviderType ProviderType => MailProviderType.Outlook;
|
||||
|
||||
private readonly IPublicClientApplication _publicClientApplication;
|
||||
|
||||
public OutlookAuthenticator(ITokenService tokenService, INativeAppService nativeAppService) : base(tokenService)
|
||||
{
|
||||
var authenticationRedirectUri = nativeAppService.GetWebAuthenticationBrokerUri();
|
||||
|
||||
_publicClientApplication = PublicClientApplicationBuilder.Create(ClientId)
|
||||
.WithAuthority(Authority)
|
||||
.WithRedirectUri(authenticationRedirectUri)
|
||||
.Build();
|
||||
}
|
||||
|
||||
#pragma warning disable S1133 // Deprecated code should be removed
|
||||
[Obsolete("Not used for OutlookAuthenticator.")]
|
||||
#pragma warning restore S1133 // Deprecated code should be removed
|
||||
public void ContinueAuthorization(Uri authorizationResponseUri) { }
|
||||
|
||||
#pragma warning disable S1133 // Deprecated code should be removed
|
||||
[Obsolete("Not used for OutlookAuthenticator.")]
|
||||
#pragma warning restore S1133 // Deprecated code should be removed
|
||||
public void CancelAuthorization() { }
|
||||
|
||||
public async Task<TokenInformation> GetTokenAsync(MailAccount account)
|
||||
{
|
||||
var cachedToken = await TokenService.GetTokenInformationAsync(account.Id)
|
||||
?? throw new AuthenticationAttentionException(account);
|
||||
|
||||
// We have token but it's expired.
|
||||
// Silently refresh the token and save new token.
|
||||
|
||||
if (cachedToken.IsExpired)
|
||||
{
|
||||
var cachedOutlookAccount = (await _publicClientApplication.GetAccountsAsync()).FirstOrDefault(a => a.Username == account.Address);
|
||||
|
||||
// Again, not expected at all...
|
||||
// Force interactive login at this point.
|
||||
|
||||
if (cachedOutlookAccount == null)
|
||||
{
|
||||
// What if interactive login info is for different account?
|
||||
|
||||
return await GenerateTokenAsync(account, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Silently refresh token from cache.
|
||||
|
||||
AuthenticationResult authResult = await _publicClientApplication.AcquireTokenSilent(MailScope, cachedOutlookAccount).ExecuteAsync();
|
||||
|
||||
// Save refreshed token and return
|
||||
var refreshedTokenInformation = authResult.CreateTokenInformation();
|
||||
|
||||
await TokenService.SaveTokenInformationAsync(account.Id, refreshedTokenInformation);
|
||||
|
||||
return refreshedTokenInformation;
|
||||
}
|
||||
}
|
||||
else
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
public async Task<TokenInformation> GenerateTokenAsync(MailAccount account, bool saveToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var authResult = await _publicClientApplication
|
||||
.AcquireTokenInteractive(MailScope)
|
||||
.ExecuteAsync();
|
||||
|
||||
var tokenInformation = authResult.CreateTokenInformation();
|
||||
|
||||
if (saveToken)
|
||||
{
|
||||
await SaveTokenInternalAsync(account, tokenInformation);
|
||||
}
|
||||
|
||||
return tokenInformation;
|
||||
}
|
||||
catch (MsalClientException msalClientException)
|
||||
{
|
||||
if (msalClientException.ErrorCode == "authentication_canceled" || msalClientException.ErrorCode == "access_denied")
|
||||
throw new AccountSetupCanceledException();
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
throw new AuthenticationException(Translator.Exception_UnknowErrorDuringAuthentication, new Exception(Translator.Exception_TokenGenerationFailed));
|
||||
}
|
||||
}
|
||||
}
|
||||
40
Wino.Core/Authenticators/YahooAuthenticator.cs
Normal file
40
Wino.Core/Authenticators/YahooAuthenticator.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Authenticators
|
||||
{
|
||||
public class YahooAuthenticator : BaseAuthenticator, IAuthenticator
|
||||
{
|
||||
public YahooAuthenticator(ITokenService tokenService) : base(tokenService) { }
|
||||
|
||||
public override MailProviderType ProviderType => MailProviderType.Yahoo;
|
||||
|
||||
public string ClientId => throw new NotImplementedException();
|
||||
|
||||
public event EventHandler<string> InteractiveAuthenticationRequired;
|
||||
|
||||
public void CancelAuthorization()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void ContinueAuthorization(Uri authorizationResponseUri)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<TokenInformation> GenerateTokenAsync(MailAccount account, bool saveToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<TokenInformation> GetTokenAsync(MailAccount account)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Wino.Core/CoreContainerSetup.cs
Normal file
46
Wino.Core/CoreContainerSetup.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog.Core;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration.Processors;
|
||||
using Wino.Core.Integration.Threading;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core
|
||||
{
|
||||
public static class CoreContainerSetup
|
||||
{
|
||||
public static void RegisterCoreServices(this IServiceCollection services)
|
||||
{
|
||||
var loggerLevelSwitcher = new LoggingLevelSwitch();
|
||||
|
||||
services.AddSingleton(loggerLevelSwitcher);
|
||||
services.AddSingleton<ILogInitializer, LogInitializer>();
|
||||
|
||||
services.AddSingleton<ITranslationService, TranslationService>();
|
||||
services.AddSingleton<IDatabaseService, DatabaseService>();
|
||||
services.AddSingleton<IWinoSynchronizerFactory, WinoSynchronizerFactory>();
|
||||
services.AddSingleton<IThreadingStrategyProvider, ThreadingStrategyProvider>();
|
||||
services.AddSingleton<IMimeFileService, MimeFileService>();
|
||||
|
||||
services.AddTransient<IDefaultChangeProcessor, DefaultChangeProcessor>();
|
||||
services.AddTransient<ITokenService, TokenService>();
|
||||
services.AddTransient<IProviderService, ProviderService>();
|
||||
services.AddTransient<IFolderService, FolderService>();
|
||||
services.AddTransient<IMailService, MailService>();
|
||||
services.AddTransient<IAccountService, AccountService>();
|
||||
services.AddTransient<IContactService, ContactService>();
|
||||
services.AddTransient<ISignatureService, SignatureService>();
|
||||
services.AddTransient<IWinoRequestProcessor, WinoRequestProcessor>();
|
||||
services.AddTransient<IWinoRequestDelegator, WinoRequestDelegator>();
|
||||
services.AddTransient<IImapTestService, ImapTestService>();
|
||||
services.AddTransient<IAuthenticationProvider, AuthenticationProvider>();
|
||||
services.AddTransient<IAutoDiscoveryService, AutoDiscoveryService>();
|
||||
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
|
||||
services.AddTransient<IFontService, FontService>();
|
||||
|
||||
services.AddTransient<OutlookThreadingStrategy>();
|
||||
services.AddTransient<GmailThreadingStrategy>();
|
||||
services.AddTransient<ImapThreadStrategy>();
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Wino.Core/Extensions/FolderTreeExtensions.cs
Normal file
54
Wino.Core/Extensions/FolderTreeExtensions.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.MenuItems;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class FolderTreeExtensions
|
||||
{
|
||||
public static AccountMenuItem GetAccountMenuTree(this AccountFolderTree accountTree, IMenuItem parentMenuItem = null)
|
||||
{
|
||||
var accountMenuItem = new AccountMenuItem(accountTree.Account, parentMenuItem);
|
||||
|
||||
foreach (var structure in accountTree.Folders)
|
||||
{
|
||||
var tree = GetMenuItemByFolderRecursive(structure, accountMenuItem, null);
|
||||
|
||||
accountMenuItem.SubMenuItems.Add(tree);
|
||||
}
|
||||
|
||||
|
||||
// Create flat folder hierarchy for ease of access.
|
||||
accountMenuItem.FlattenedFolderHierarchy = ListExtensions
|
||||
.FlattenBy(accountMenuItem.SubMenuItems, a => a.SubMenuItems)
|
||||
.Where(a => a is FolderMenuItem)
|
||||
.Cast<FolderMenuItem>()
|
||||
.ToList();
|
||||
|
||||
return accountMenuItem;
|
||||
}
|
||||
|
||||
private static MenuItemBase<IMailItemFolder, FolderMenuItem> GetMenuItemByFolderRecursive(IMailItemFolder structure, AccountMenuItem parentAccountMenuItem, IMenuItem parentFolderItem)
|
||||
{
|
||||
MenuItemBase<IMailItemFolder, FolderMenuItem> parentMenuItem = new FolderMenuItem(structure, parentAccountMenuItem.Parameter, parentAccountMenuItem);
|
||||
|
||||
var childStructures = structure.ChildFolders;
|
||||
|
||||
foreach (var childFolder in childStructures)
|
||||
{
|
||||
if (childFolder == null) continue;
|
||||
|
||||
// Folder menu item.
|
||||
var subChildrenFolderTree = GetMenuItemByFolderRecursive(childFolder, parentAccountMenuItem, parentMenuItem);
|
||||
|
||||
if (subChildrenFolderTree is FolderMenuItem folderItem)
|
||||
{
|
||||
parentMenuItem.SubMenuItems.Add(folderItem);
|
||||
}
|
||||
}
|
||||
|
||||
return parentMenuItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
162
Wino.Core/Extensions/GoogleIntegratorExtensions.cs
Normal file
162
Wino.Core/Extensions/GoogleIntegratorExtensions.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Web;
|
||||
using Google.Apis.Gmail.v1.Data;
|
||||
using MimeKit;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class GoogleIntegratorExtensions
|
||||
{
|
||||
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";
|
||||
|
||||
private const string SYSTEM_FOLDER_IDENTIFIER = "system";
|
||||
private const string FOLDER_HIDE_IDENTIFIER = "labelHide";
|
||||
|
||||
private static Dictionary<string, SpecialFolderType> KnownFolderDictioanry = new Dictionary<string, SpecialFolderType>()
|
||||
{
|
||||
{ INBOX_LABEL_ID, SpecialFolderType.Inbox },
|
||||
{ "CHAT", SpecialFolderType.Chat },
|
||||
{ IMPORTANT_LABEL_ID, SpecialFolderType.Important },
|
||||
{ "TRASH", SpecialFolderType.Deleted },
|
||||
{ DRAFT_LABEL_ID, SpecialFolderType.Draft },
|
||||
{ SENT_LABEL_ID, SpecialFolderType.Sent },
|
||||
{ "SPAM", SpecialFolderType.Junk },
|
||||
{ STARRED_LABEL_ID, SpecialFolderType.Starred },
|
||||
{ UNREAD_LABEL_ID, SpecialFolderType.Unread },
|
||||
{ "FORUMS", SpecialFolderType.Forums },
|
||||
{ "UPDATES", SpecialFolderType.Updates },
|
||||
{ "PROMOTIONS", SpecialFolderType.Promotions },
|
||||
{ "SOCIAL", SpecialFolderType.Social},
|
||||
{ "PERSONAL", SpecialFolderType.Personal},
|
||||
};
|
||||
|
||||
public static MailItemFolder GetLocalFolder(this Label label, Guid accountId)
|
||||
{
|
||||
var unchangedFolderName = label.Name;
|
||||
|
||||
if (label.Name.StartsWith("CATEGORY_"))
|
||||
label.Name = label.Name.Replace("CATEGORY_", "");
|
||||
|
||||
bool isSpecialFolder = KnownFolderDictioanry.ContainsKey(label.Name);
|
||||
bool isAllCapital = label.Name.All(a => char.IsUpper(a));
|
||||
|
||||
var specialFolderType = isSpecialFolder ? KnownFolderDictioanry[label.Name] : SpecialFolderType.Other;
|
||||
|
||||
return new MailItemFolder()
|
||||
{
|
||||
TextColorHex = label.Color?.TextColor,
|
||||
BackgroundColorHex = label.Color?.BackgroundColor,
|
||||
FolderName = isAllCapital ? char.ToUpper(label.Name[0]) + label.Name.Substring(1).ToLower() : label.Name, // Capitilize only first letter.
|
||||
RemoteFolderId = label.Id,
|
||||
Id = Guid.NewGuid(),
|
||||
MailAccountId = accountId,
|
||||
IsSynchronizationEnabled = true,
|
||||
SpecialFolderType = specialFolderType,
|
||||
IsSystemFolder = label.Type == SYSTEM_FOLDER_IDENTIFIER,
|
||||
IsSticky = isSpecialFolder && specialFolderType != SpecialFolderType.Category && !unchangedFolderName.StartsWith("CATEGORY"),
|
||||
IsHidden = label.LabelListVisibility == FOLDER_HIDE_IDENTIFIER,
|
||||
|
||||
// By default, all special folders update unread count in the UI except Trash.
|
||||
ShowUnreadCount = specialFolderType != SpecialFolderType.Deleted || specialFolderType != SpecialFolderType.Other
|
||||
};
|
||||
}
|
||||
|
||||
public static bool GetIsDraft(this Message message)
|
||||
=> message?.LabelIds?.Any(a => a == DRAFT_LABEL_ID) ?? false;
|
||||
|
||||
public static bool GetIsUnread(this Message message)
|
||||
=> message?.LabelIds?.Any(a => a == UNREAD_LABEL_ID) ?? false;
|
||||
|
||||
public static bool GetIsFocused(this Message message)
|
||||
=> message?.LabelIds?.Any(a => a == IMPORTANT_LABEL_ID) ?? false;
|
||||
|
||||
public static bool GetIsFlagged(this Message message)
|
||||
=> message?.LabelIds?.Any(a => a == STARRED_LABEL_ID) ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Returns MailCopy out of native Gmail message and converted MimeMessage of that native messaage.
|
||||
/// </summary>
|
||||
/// <param name="gmailMessage">Gmail Message</param>
|
||||
/// <param name="mimeMessage">MimeMessage representation of that native message.</param>
|
||||
/// <returns>MailCopy object that is ready to be inserted to database.</returns>
|
||||
public static MailCopy AsMailCopy(this Message gmailMessage, MimeMessage mimeMessage)
|
||||
{
|
||||
bool isUnread = gmailMessage.GetIsUnread();
|
||||
bool isFocused = gmailMessage.GetIsFocused();
|
||||
bool isFlagged = gmailMessage.GetIsFlagged();
|
||||
bool isDraft = gmailMessage.GetIsDraft();
|
||||
|
||||
return new MailCopy()
|
||||
{
|
||||
CreationDate = mimeMessage.Date.UtcDateTime,
|
||||
Subject = HttpUtility.HtmlDecode(mimeMessage.Subject),
|
||||
FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage),
|
||||
FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage),
|
||||
PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet),
|
||||
ThreadId = gmailMessage.ThreadId,
|
||||
Importance = (MailImportance)mimeMessage.Importance,
|
||||
Id = gmailMessage.Id,
|
||||
IsDraft = isDraft,
|
||||
HasAttachments = mimeMessage.Attachments.Any(),
|
||||
IsRead = !isUnread,
|
||||
IsFlagged = isFlagged,
|
||||
IsFocused = isFocused,
|
||||
InReplyTo = mimeMessage.InReplyTo,
|
||||
MessageId = mimeMessage.MessageId,
|
||||
References = mimeMessage.References.GetReferences(),
|
||||
FileId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
public static Tuple<MailCopy, MimeMessage, IEnumerable<string>> GetMailDetails(this Message message)
|
||||
{
|
||||
MimeMessage mimeMessage = message.GetGmailMimeMessage();
|
||||
|
||||
if (mimeMessage == null)
|
||||
{
|
||||
// This should never happen.
|
||||
Debugger.Break();
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
bool isUnread = message.GetIsUnread();
|
||||
bool isFocused = message.GetIsFocused();
|
||||
bool isFlagged = message.GetIsFlagged();
|
||||
bool isDraft = message.GetIsDraft();
|
||||
|
||||
var mailCopy = new MailCopy()
|
||||
{
|
||||
CreationDate = mimeMessage.Date.UtcDateTime,
|
||||
Subject = HttpUtility.HtmlDecode(mimeMessage.Subject),
|
||||
FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage),
|
||||
FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage),
|
||||
PreviewText = HttpUtility.HtmlDecode(message.Snippet),
|
||||
ThreadId = message.ThreadId,
|
||||
Importance = (MailImportance)mimeMessage.Importance,
|
||||
Id = message.Id,
|
||||
IsDraft = isDraft,
|
||||
HasAttachments = mimeMessage.Attachments.Any(),
|
||||
IsRead = !isUnread,
|
||||
IsFlagged = isFlagged,
|
||||
IsFocused = isFocused,
|
||||
InReplyTo = mimeMessage.InReplyTo,
|
||||
MessageId = mimeMessage.MessageId,
|
||||
References = mimeMessage.References.GetReferences()
|
||||
};
|
||||
|
||||
return new Tuple<MailCopy, MimeMessage, IEnumerable<string>>(mailCopy, mimeMessage, message.LabelIds);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
121
Wino.Core/Extensions/HtmlAgilityPackExtensions.cs
Normal file
121
Wino.Core/Extensions/HtmlAgilityPackExtensions.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using HtmlAgilityPack;
|
||||
|
||||
namespace Wino.Core.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Wino.Core/Extensions/ListExtensions.cs
Normal file
58
Wino.Core/Extensions/ListExtensions.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class ListExtensions
|
||||
{
|
||||
public static IEnumerable<T> FlattenBy<T>(this IEnumerable<T> nodes, Func<T, IEnumerable<T>> selector)
|
||||
{
|
||||
if (nodes.Any() == false)
|
||||
return nodes;
|
||||
|
||||
var descendants = nodes
|
||||
.SelectMany(selector)
|
||||
.FlattenBy(selector);
|
||||
|
||||
return nodes.Concat(descendants);
|
||||
}
|
||||
|
||||
public static IEnumerable<IBatchChangeRequest> CreateBatch(this IEnumerable<IGrouping<MailSynchronizerOperation, IRequestBase>> items)
|
||||
{
|
||||
IBatchChangeRequest batch = null;
|
||||
|
||||
foreach (var group in items)
|
||||
{
|
||||
var key = group.Key;
|
||||
}
|
||||
|
||||
yield return batch;
|
||||
}
|
||||
|
||||
public static void AddSorted<T>(this List<T> @this, T item) where T : IComparable<T>
|
||||
{
|
||||
if (@this.Count == 0)
|
||||
{
|
||||
@this.Add(item);
|
||||
return;
|
||||
}
|
||||
if (@this[@this.Count - 1].CompareTo(item) <= 0)
|
||||
{
|
||||
@this.Add(item);
|
||||
return;
|
||||
}
|
||||
if (@this[0].CompareTo(item) >= 0)
|
||||
{
|
||||
@this.Insert(0, item);
|
||||
return;
|
||||
}
|
||||
int index = @this.BinarySearch(item);
|
||||
if (index < 0)
|
||||
index = ~index;
|
||||
@this.Insert(index, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Wino.Core/Extensions/LongExtensions.cs
Normal file
58
Wino.Core/Extensions/LongExtensions.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class LongExtensions
|
||||
{
|
||||
// Returns the human-readable file size for an arbitrary, 64-bit file size
|
||||
// The default format is "0.### XB", e.g. "4.2 KB" or "1.434 GB"
|
||||
public static string GetBytesReadable(this long i)
|
||||
{
|
||||
// Get absolute value
|
||||
long absolute_i = (i < 0 ? -i : i);
|
||||
// Determine the suffix and readable value
|
||||
string suffix;
|
||||
double readable;
|
||||
if (absolute_i >= 0x1000000000000000) // Exabyte
|
||||
{
|
||||
suffix = "EB";
|
||||
readable = (i >> 50);
|
||||
}
|
||||
else if (absolute_i >= 0x4000000000000) // Petabyte
|
||||
{
|
||||
suffix = "PB";
|
||||
readable = (i >> 40);
|
||||
}
|
||||
else if (absolute_i >= 0x10000000000) // Terabyte
|
||||
{
|
||||
suffix = "TB";
|
||||
readable = (i >> 30);
|
||||
}
|
||||
else if (absolute_i >= 0x40000000) // Gigabyte
|
||||
{
|
||||
suffix = "GB";
|
||||
readable = (i >> 20);
|
||||
}
|
||||
else if (absolute_i >= 0x100000) // Megabyte
|
||||
{
|
||||
suffix = "MB";
|
||||
readable = (i >> 10);
|
||||
}
|
||||
else if (absolute_i >= 0x400) // Kilobyte
|
||||
{
|
||||
suffix = "KB";
|
||||
readable = i;
|
||||
}
|
||||
else
|
||||
{
|
||||
return i.ToString("0 B"); // Byte
|
||||
}
|
||||
// Divide by 1024 to get fractional value
|
||||
readable = (readable / 1024);
|
||||
// Return formatted number with suffix
|
||||
return readable.ToString("0.# ") + suffix;
|
||||
}
|
||||
}
|
||||
}
|
||||
187
Wino.Core/Extensions/MailkitClientExtensions.cs
Normal file
187
Wino.Core/Extensions/MailkitClientExtensions.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using MailKit;
|
||||
using MimeKit;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.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;
|
||||
|
||||
// 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 mime)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Wino.Core/Extensions/MailkitExtensions.cs
Normal file
22
Wino.Core/Extensions/MailkitExtensions.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using MailKit;
|
||||
using Wino.Core.Domain.Entities;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class MailkitExtensions
|
||||
{
|
||||
public static MailItemFolder GetLocalFolder(this IMailFolder mailkitMailFolder)
|
||||
{
|
||||
return new MailItemFolder()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FolderName = mailkitMailFolder.Name,
|
||||
RemoteFolderId = mailkitMailFolder.FullName,
|
||||
ParentRemoteFolderId = mailkitMailFolder.ParentFolder?.FullName,
|
||||
SpecialFolderType = Domain.Enums.SpecialFolderType.Other,
|
||||
IsSynchronizationEnabled = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
52
Wino.Core/Extensions/MimeExtensions.cs
Normal file
52
Wino.Core/Extensions/MimeExtensions.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Google.Apis.Gmail.v1.Data;
|
||||
using MimeKit;
|
||||
using MimeKit.IO;
|
||||
using MimeKit.IO.Filters;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class MimeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns MimeKit.MimeMessage instance for this GMail Message's Raw content.
|
||||
/// </summary>
|
||||
/// <param name="message">GMail message.</param>
|
||||
public static MimeMessage GetGmailMimeMessage(this Message message)
|
||||
{
|
||||
if (message == null || message.Raw == null)
|
||||
return null;
|
||||
|
||||
// Gmail raw is not base64 but base64Safe. We need to remove this HTML things.
|
||||
var base64Encoded = message.Raw.Replace(",", "=").Replace("-", "+").Replace("_", "/");
|
||||
|
||||
byte[] bytes = Encoding.ASCII.GetBytes(base64Encoded);
|
||||
|
||||
var stream = new MemoryStream(bytes);
|
||||
|
||||
// This method will dispose outer stream.
|
||||
|
||||
using (stream)
|
||||
{
|
||||
using var filtered = new FilteredStream(stream);
|
||||
filtered.Add(DecoderFilter.Create(ContentEncoding.Base64));
|
||||
|
||||
return MimeMessage.Load(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
public static AddressInformation ToAddressInformation(this MailboxAddress address)
|
||||
{
|
||||
if (address == null)
|
||||
return new AddressInformation() { Name = Translator.UnknownSender, Address = Translator.UnknownAddress };
|
||||
|
||||
if (string.IsNullOrEmpty(address.Name))
|
||||
address.Name = address.Address;
|
||||
|
||||
return new AddressInformation() { Name = address.Name, Address = address.Address };
|
||||
}
|
||||
}
|
||||
}
|
||||
65
Wino.Core/Extensions/OutlookIntegratorExtensions.cs
Normal file
65
Wino.Core/Extensions/OutlookIntegratorExtensions.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using Microsoft.Graph.Models;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class OutlookIntegratorExtensions
|
||||
{
|
||||
public static MailItemFolder GetLocalFolder(this MailFolder nativeFolder, Guid accountId)
|
||||
{
|
||||
return new MailItemFolder()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FolderName = nativeFolder.DisplayName,
|
||||
RemoteFolderId = nativeFolder.Id,
|
||||
ParentRemoteFolderId = nativeFolder.ParentFolderId,
|
||||
IsSynchronizationEnabled = true,
|
||||
MailAccountId = accountId,
|
||||
IsHidden = nativeFolder.IsHidden.GetValueOrDefault()
|
||||
};
|
||||
}
|
||||
|
||||
public static bool GetIsDraft(this Message message)
|
||||
=> message != null && message.IsDraft.GetValueOrDefault();
|
||||
|
||||
public static bool GetIsRead(this Message message)
|
||||
=> message != null && message.IsRead.GetValueOrDefault();
|
||||
|
||||
public static bool GetIsFocused(this Message message)
|
||||
=> message?.InferenceClassification != null && message.InferenceClassification.Value == InferenceClassificationType.Focused;
|
||||
|
||||
public static bool GetIsFlagged(this Message message)
|
||||
=> message?.Flag?.FlagStatus != null && message.Flag.FlagStatus == FollowupFlagStatus.Flagged;
|
||||
|
||||
public static MailCopy AsMailCopy(this Message outlookMessage)
|
||||
{
|
||||
bool isDraft = GetIsDraft(outlookMessage);
|
||||
|
||||
var mailCopy = new MailCopy()
|
||||
{
|
||||
MessageId = outlookMessage.InternetMessageId,
|
||||
IsFlagged = GetIsFlagged(outlookMessage),
|
||||
IsFocused = GetIsFocused(outlookMessage),
|
||||
Importance = !outlookMessage.Importance.HasValue ? MailImportance.Normal : (MailImportance)outlookMessage.Importance.Value,
|
||||
IsRead = GetIsRead(outlookMessage),
|
||||
IsDraft = isDraft,
|
||||
CreationDate = outlookMessage.ReceivedDateTime.GetValueOrDefault().DateTime,
|
||||
HasAttachments = outlookMessage.HasAttachments.GetValueOrDefault(),
|
||||
PreviewText = outlookMessage.BodyPreview,
|
||||
Id = outlookMessage.Id,
|
||||
ThreadId = outlookMessage.ConversationId,
|
||||
FromName = outlookMessage.From?.EmailAddress?.Name,
|
||||
FromAddress = outlookMessage.From?.EmailAddress?.Address,
|
||||
Subject = outlookMessage.Subject,
|
||||
FileId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
if (mailCopy.IsDraft)
|
||||
mailCopy.DraftId = mailCopy.ThreadId;
|
||||
|
||||
return mailCopy;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Wino.Core/Extensions/SqlKataExtensions.cs
Normal file
15
Wino.Core/Extensions/SqlKataExtensions.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using SqlKata;
|
||||
using SqlKata.Compilers;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class SqlKataExtensions
|
||||
{
|
||||
private static SqliteCompiler Compiler = new SqliteCompiler();
|
||||
|
||||
public static string GetRawQuery(this Query query)
|
||||
{
|
||||
return Compiler.Compile(query).ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Wino.Core/Extensions/StringExtensions.cs
Normal file
22
Wino.Core/Extensions/StringExtensions.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class StringExtensions
|
||||
{
|
||||
public static bool Contains(this string source, string toCheck, StringComparison comp)
|
||||
{
|
||||
return source?.IndexOf(toCheck, comp) >= 0;
|
||||
}
|
||||
|
||||
public static string ReplaceFirst(this string text, string search, string replace)
|
||||
{
|
||||
int pos = text.IndexOf(search);
|
||||
if (pos < 0)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
return text.Substring(0, pos) + replace + text.Substring(pos + search.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Wino.Core/Extensions/TokenizationExtensions.cs
Normal file
31
Wino.Core/Extensions/TokenizationExtensions.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using Microsoft.Identity.Client;
|
||||
using Wino.Core.Domain.Entities;
|
||||
|
||||
namespace Wino.Core.Extensions
|
||||
{
|
||||
public static class TokenizationExtensions
|
||||
{
|
||||
public static TokenInformation CreateTokenInformation(this AuthenticationResult clientBuilderResult)
|
||||
{
|
||||
var expirationDate = clientBuilderResult.ExpiresOn.UtcDateTime;
|
||||
var accesToken = clientBuilderResult.AccessToken;
|
||||
var userName = clientBuilderResult.Account.Username;
|
||||
|
||||
// MSAL does not expose refresh token for security reasons.
|
||||
// This token info will be created without refresh token.
|
||||
// but OutlookIntegrator will ask for publicApplication to refresh it
|
||||
// in case of expiration.
|
||||
|
||||
var tokenInfo = new TokenInformation()
|
||||
{
|
||||
ExpiresAt = expirationDate,
|
||||
AccessToken = accesToken,
|
||||
Address = userName,
|
||||
Id = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
return tokenInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Wino.Core/GlobalSuppressions.cs
Normal file
10
Wino.Core/GlobalSuppressions.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
// This file is used by Code Analysis to maintain SuppressMessage
|
||||
// attributes that are applied to this project.
|
||||
// Project-level suppressions either have no target or are given
|
||||
// a specific target and scoped to a namespace, type, member, etc.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "<Pending>", Scope = "member", Target = "~P:Wino.Core.Models.IMailDisplayInformation.asd")]
|
||||
[assembly: SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions", Justification = "<Pending>", Scope = "member", Target = "~M:Wino.Core.Services.WinoRequestProcessor.PrepareRequestsAsync(Wino.Core.Domain.Enums.MailOperation,System.Collections.Generic.IEnumerable{System.String})~System.Threading.Tasks.Task{System.Collections.Generic.List{Wino.Core.Abstractions.Interfaces.Data.IWinoChangeRequest}}")]
|
||||
[assembly: SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions", Justification = "<Pending>", Scope = "member", Target = "~M:Wino.Core.Services.SynchronizationWorker.QueueAsync(System.Collections.Generic.IEnumerable{Wino.Core.Abstractions.Interfaces.Data.IWinoChangeRequest})")]
|
||||
29
Wino.Core/Http/GmailClientMessageHandler.cs
Normal file
29
Wino.Core/Http/GmailClientMessageHandler.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Apis.Http;
|
||||
using Wino.Core.Domain.Entities;
|
||||
|
||||
namespace Wino.Core.Http
|
||||
{
|
||||
internal class GmailClientMessageHandler : ConfigurableMessageHandler
|
||||
{
|
||||
public Func<Task<TokenInformation>> TokenRetrieveDelegate { get; }
|
||||
|
||||
public GmailClientMessageHandler(Func<Task<TokenInformation>> tokenRetrieveDelegate) : base(new HttpClientHandler())
|
||||
{
|
||||
TokenRetrieveDelegate = tokenRetrieveDelegate;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tokenizationTask = TokenRetrieveDelegate.Invoke();
|
||||
var tokenInformation = await tokenizationTask;
|
||||
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenInformation.AccessToken);
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Wino.Core/Http/MicrosoftImmutableIdHandler.cs
Normal file
19
Wino.Core/Http/MicrosoftImmutableIdHandler.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Wino.Core.Http
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds additional Prefer header for immutable id support in the Graph service client.
|
||||
/// </summary>
|
||||
public class MicrosoftImmutableIdHandler : DelegatingHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Prefer", "IdType=\"ImmutableId\"");
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Wino.Core/Http/MicrosoftJsonContractResolver.cs
Normal file
36
Wino.Core/Http/MicrosoftJsonContractResolver.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace Wino.Core.Http
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// We need to generate HttpRequestMessage for batch requests, and sometimes we need to
|
||||
/// serialize content as json. However, some of the fields like 'ODataType' must be ignored
|
||||
/// in order PATCH requests to succeed. Therefore Microsoft account synchronizer uses
|
||||
/// special JsonSerializerSettings for ignoring some of the properties.
|
||||
/// </summary>
|
||||
public class MicrosoftJsonContractResolver : DefaultContractResolver
|
||||
{
|
||||
private readonly HashSet<string> ignoreProps = new HashSet<string>()
|
||||
{
|
||||
"ODataType"
|
||||
};
|
||||
|
||||
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
|
||||
{
|
||||
JsonProperty property = base.CreateProperty(member, memberSerialization);
|
||||
|
||||
if (ignoreProps.Contains(property.PropertyName))
|
||||
{
|
||||
property.ShouldSerialize = _ => false;
|
||||
}
|
||||
|
||||
return property;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Wino.Core/Http/MicrosoftTokenProvider.cs
Normal file
33
Wino.Core/Http/MicrosoftTokenProvider.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Kiota.Abstractions.Authentication;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Http
|
||||
{
|
||||
public class MicrosoftTokenProvider : IAccessTokenProvider
|
||||
{
|
||||
private readonly MailAccount _account;
|
||||
private readonly IAuthenticator _authenticator;
|
||||
|
||||
public MicrosoftTokenProvider(MailAccount account, IAuthenticator authenticator)
|
||||
{
|
||||
_account = account;
|
||||
_authenticator = authenticator;
|
||||
}
|
||||
|
||||
public AllowedHostsValidator AllowedHostsValidator { get; }
|
||||
|
||||
public async Task<string> GetAuthorizationTokenAsync(Uri uri,
|
||||
Dictionary<string, object> additionalAuthenticationContext = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await _authenticator.GetTokenAsync(_account).ConfigureAwait(false);
|
||||
|
||||
return token?.AccessToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
130
Wino.Core/Integration/BaseMailIntegrator.cs
Normal file
130
Wino.Core/Integration/BaseMailIntegrator.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Net.Imap;
|
||||
using MoreLinq;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
|
||||
namespace Wino.Core.Integration
|
||||
{
|
||||
public abstract class BaseMailIntegrator<TNativeRequestType>
|
||||
{
|
||||
/// <summary>
|
||||
/// How many items per single HTTP call can be modified.
|
||||
/// </summary>
|
||||
public abstract uint BatchModificationSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// How many items must be downloaded per folder when the folder is first synchronized.
|
||||
/// </summary>
|
||||
public abstract uint InitialMessageDownloadCountPerFolder { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a batched HttpBundle without a response for a collection of MailItem.
|
||||
/// </summary>
|
||||
/// <param name="batchChangeRequest">Generated batch request.</param>
|
||||
/// <param name="action">An action to get the native request from the MailItem.</param>
|
||||
/// <returns>Collection of http bundle that contains batch and native request.</returns>
|
||||
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateBatchedHttpBundleFromGroup(
|
||||
IBatchChangeRequest batchChangeRequest,
|
||||
Func<IEnumerable<IRequest>, TNativeRequestType> action)
|
||||
{
|
||||
if (batchChangeRequest.Items == null) yield break;
|
||||
|
||||
var groupedItems = batchChangeRequest.Items.Batch((int)BatchModificationSize);
|
||||
|
||||
foreach (var group in groupedItems)
|
||||
yield return new HttpRequestBundle<TNativeRequestType>(action(group), batchChangeRequest);
|
||||
}
|
||||
|
||||
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateBatchedHttpBundle(
|
||||
IBatchChangeRequest batchChangeRequest,
|
||||
Func<IRequest, TNativeRequestType> action)
|
||||
{
|
||||
if (batchChangeRequest.Items == null) yield break;
|
||||
|
||||
var groupedItems = batchChangeRequest.Items.Batch((int)BatchModificationSize);
|
||||
|
||||
foreach (var group in groupedItems)
|
||||
foreach (var item in group)
|
||||
yield return new HttpRequestBundle<TNativeRequestType>(action(item), item);
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a single HttpBundle without a response for a collection of MailItem.
|
||||
/// </summary>
|
||||
/// <param name="batchChangeRequest">Batch request</param>
|
||||
/// <param name="action">An action to get the native request from the MailItem</param>
|
||||
/// <returns>Collection of http bundle that contains batch and native request.</returns>
|
||||
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateHttpBundle(
|
||||
IBatchChangeRequest batchChangeRequest,
|
||||
Func<IRequest, TNativeRequestType> action)
|
||||
{
|
||||
if (batchChangeRequest.Items == null) yield break;
|
||||
|
||||
foreach (var item in batchChangeRequest.Items)
|
||||
yield return new HttpRequestBundle<TNativeRequestType>(action(item), batchChangeRequest);
|
||||
}
|
||||
|
||||
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateHttpBundle<TResponseType>(
|
||||
IBatchChangeRequest batchChangeRequest,
|
||||
Func<IRequest, TNativeRequestType> action)
|
||||
{
|
||||
if (batchChangeRequest.Items == null) yield break;
|
||||
|
||||
foreach (var item in batchChangeRequest.Items)
|
||||
yield return new HttpRequestBundle<TNativeRequestType, TResponseType>(action(item), item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates HttpBundle with TResponse of expected response type from the http call for each of the items in the batch.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">Expected http response type after the call.</typeparam>
|
||||
/// <param name="batchChangeRequest">Generated batch request.</param>
|
||||
/// <param name="action">An action to get the native request from the MailItem.</param>
|
||||
/// <returns>Collection of http bundle that contains batch and native request.</returns>
|
||||
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateHttpBundleWithResponse<TResponse>(
|
||||
IBatchChangeRequest batchChangeRequest,
|
||||
Func<IRequest, TNativeRequestType> action)
|
||||
{
|
||||
if (batchChangeRequest.Items == null) yield break;
|
||||
|
||||
foreach (var item in batchChangeRequest.Items)
|
||||
yield return new HttpRequestBundle<TNativeRequestType, TResponse>(action(item), batchChangeRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a batched HttpBundle with TResponse of expected response type from the http call for each of the items in the batch.
|
||||
/// Func will be executed for each item separately in the batch request.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">Expected http response type after the call.</typeparam>
|
||||
/// <param name="batchChangeRequest">Generated batch request.</param>
|
||||
/// <param name="action">An action to get the native request from the MailItem.</param>
|
||||
/// <returns>Collection of http bundle that contains batch and native request.</returns>
|
||||
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateBatchedHttpBundle<TResponse>(
|
||||
IBatchChangeRequest batchChangeRequest,
|
||||
Func<IRequest, TNativeRequestType> action)
|
||||
{
|
||||
if (batchChangeRequest.Items == null) yield break;
|
||||
|
||||
var groupedItems = batchChangeRequest.Items.Batch((int)BatchModificationSize);
|
||||
|
||||
foreach (var group in groupedItems)
|
||||
foreach (var item in group)
|
||||
yield return new HttpRequestBundle<TNativeRequestType, TResponse>(action(item), item);
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
public IEnumerable<IRequestBundle<ImapRequest>> CreateTaskBundle(Func<ImapClient, Task> value, IRequestBase request)
|
||||
{
|
||||
var imapreq = new ImapRequest(value, request);
|
||||
|
||||
return [new ImapRequestBundle(imapreq, request)];
|
||||
}
|
||||
}
|
||||
}
|
||||
179
Wino.Core/Integration/ImapClientPool.cs
Normal file
179
Wino.Core/Integration/ImapClientPool.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Net.Proxy;
|
||||
using MailKit.Security;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
|
||||
namespace Wino.Core.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides a pooling mechanism for ImapClient.
|
||||
/// Makes sure that we don't have too many connections to the server.
|
||||
/// Rents a connected & authenticated client from the pool all the time.
|
||||
/// TODO: Keeps the clients alive by sending NOOP command periodically.
|
||||
/// TODO: Listens to the Inbox folder for new messages.
|
||||
/// </summary>
|
||||
/// <param name="customServerInformation">Connection/Authentication info to be used to configure ImapClient.</param>
|
||||
public class ImapClientPool
|
||||
{
|
||||
// Hardcoded implementation details for ID extension if the server supports.
|
||||
// Some providers like Chinese 126 require Id to be sent before authentication.
|
||||
// We don't expose any customer data here. Therefore it's safe for now.
|
||||
// Later on maybe we can make it configurable and leave it to the user with passing
|
||||
// real implementation details.
|
||||
|
||||
private readonly ImapImplementation _implementation = new ImapImplementation()
|
||||
{
|
||||
Version = "1.0",
|
||||
OS = "Windows",
|
||||
Vendor = "Wino"
|
||||
};
|
||||
|
||||
private const int MaxPoolSize = 5;
|
||||
|
||||
private readonly ConcurrentBag<ImapClient> _clients = [];
|
||||
private readonly SemaphoreSlim _semaphore = new(MaxPoolSize);
|
||||
private readonly CustomServerInformation _customServerInformation;
|
||||
private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
|
||||
|
||||
public ImapClientPool(CustomServerInformation customServerInformation)
|
||||
{
|
||||
_customServerInformation = customServerInformation;
|
||||
}
|
||||
|
||||
private async Task EnsureConnectivityAsync(ImapClient client, bool isCreatedNew)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureConnectedAsync(client);
|
||||
|
||||
if (isCreatedNew && client.IsConnected)
|
||||
{
|
||||
// Activate supported pre-auth capabilities.
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
|
||||
await client.CompressAsync();
|
||||
|
||||
// Identify if the server supports ID extension.
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
|
||||
await client.IdentifyAsync(_implementation);
|
||||
}
|
||||
|
||||
await EnsureAuthenticatedAsync(client);
|
||||
|
||||
if (isCreatedNew && client.IsAuthenticated)
|
||||
{
|
||||
// Activate post-auth capabilities.
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
|
||||
await client.EnableQuickResyncAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ImapClientPoolException(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release it even if it fails.
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ImapClient> GetClientAsync()
|
||||
{
|
||||
await _semaphore.WaitAsync();
|
||||
|
||||
if (_clients.TryTake(out ImapClient item))
|
||||
{
|
||||
await EnsureConnectivityAsync(item, false);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
var client = CreateNewClient();
|
||||
|
||||
await EnsureConnectivityAsync(client, true);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
public void Release(ImapClient item)
|
||||
{
|
||||
if (item != null)
|
||||
{
|
||||
_clients.Add(item);
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public ImapClient CreateNewClient()
|
||||
{
|
||||
var client = new ImapClient();
|
||||
|
||||
HttpProxyClient proxyClient = null;
|
||||
|
||||
// Add proxy client if exists.
|
||||
if (!string.IsNullOrEmpty(_customServerInformation.ProxyServer))
|
||||
{
|
||||
proxyClient = new HttpProxyClient(_customServerInformation.ProxyServer, int.Parse(_customServerInformation.ProxyServerPort));
|
||||
}
|
||||
|
||||
client.ProxyClient = proxyClient;
|
||||
|
||||
_logger.Debug("Created new ImapClient. Current clients: {Count}", _clients.Count);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private SecureSocketOptions GetSocketOptions(ImapConnectionSecurity connectionSecurity)
|
||||
=> connectionSecurity switch
|
||||
{
|
||||
ImapConnectionSecurity.Auto => SecureSocketOptions.Auto,
|
||||
ImapConnectionSecurity.None => SecureSocketOptions.None,
|
||||
ImapConnectionSecurity.StartTls => SecureSocketOptions.StartTlsWhenAvailable,
|
||||
ImapConnectionSecurity.SslTls => SecureSocketOptions.SslOnConnect,
|
||||
_ => SecureSocketOptions.None
|
||||
};
|
||||
|
||||
public async Task EnsureConnectedAsync(ImapClient client)
|
||||
{
|
||||
if (client.IsConnected) return;
|
||||
|
||||
await client.ConnectAsync(_customServerInformation.IncomingServer,
|
||||
int.Parse(_customServerInformation.IncomingServerPort),
|
||||
GetSocketOptions(_customServerInformation.IncomingServerSocketOption));
|
||||
}
|
||||
|
||||
public async Task EnsureAuthenticatedAsync(ImapClient client)
|
||||
{
|
||||
if (client.IsAuthenticated) return;
|
||||
|
||||
switch (_customServerInformation.IncomingAuthenticationMethod)
|
||||
{
|
||||
case ImapAuthenticationMethod.Auto:
|
||||
break;
|
||||
case ImapAuthenticationMethod.None:
|
||||
break;
|
||||
case ImapAuthenticationMethod.NormalPassword:
|
||||
break;
|
||||
case ImapAuthenticationMethod.EncryptedPassword:
|
||||
break;
|
||||
case ImapAuthenticationMethod.Ntlm:
|
||||
break;
|
||||
case ImapAuthenticationMethod.CramMd5:
|
||||
break;
|
||||
case ImapAuthenticationMethod.DigestMd5:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
await client.AuthenticateAsync(_customServerInformation.IncomingServerUsername, _customServerInformation.IncomingServerPassword);
|
||||
}
|
||||
}
|
||||
}
|
||||
124
Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
Normal file
124
Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using MimeKit;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Integration.Processors
|
||||
{
|
||||
/// <summary>
|
||||
/// Database change processor that handles common operations for all synchronizers.
|
||||
/// When a synchronizer detects a change, it should call the appropriate method in this class to reflect the change in the database.
|
||||
/// Different synchronizers might need additional implementations.
|
||||
/// <see cref="IGmailChangeProcessor"/> and <see cref="IOutlookChangeProcessor"/>
|
||||
/// None of the synchronizers can directly change anything in the database.
|
||||
/// </summary>
|
||||
public interface IDefaultChangeProcessor
|
||||
{
|
||||
Task<string> UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string deltaSynchronizationIdentifier);
|
||||
Task<string> UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string deltaSynchronizationIdentifier);
|
||||
|
||||
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
|
||||
Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
|
||||
|
||||
Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead);
|
||||
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
|
||||
|
||||
Task<bool> CreateMailAsync(Guid AccountId, NewMailItemPackage package);
|
||||
Task DeleteMailAsync(Guid accountId, string mailId);
|
||||
|
||||
Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId);
|
||||
Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId);
|
||||
|
||||
Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds);
|
||||
|
||||
Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId);
|
||||
|
||||
// For gmail.
|
||||
Task UpdateFolderStructureAsync(Guid accountId, List<MailItemFolder> allFolders);
|
||||
|
||||
Task DeleteFolderAsync(Guid accountId, string remoteFolderId);
|
||||
Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options);
|
||||
Task InsertFolderAsync(MailItemFolder folder);
|
||||
|
||||
Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId);
|
||||
}
|
||||
|
||||
public interface IGmailChangeProcessor : IDefaultChangeProcessor
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public interface IOutlookChangeProcessor : IDefaultChangeProcessor
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public class DefaultChangeProcessor(IDatabaseService databaseService,
|
||||
IFolderService folderService,
|
||||
IMailService mailService,
|
||||
IAccountService accountService,
|
||||
IMimeFileService mimeFileService) : BaseDatabaseService(databaseService), IDefaultChangeProcessor
|
||||
{
|
||||
private readonly IFolderService _folderService = folderService;
|
||||
private readonly IMailService _mailService = mailService;
|
||||
private readonly IAccountService _accountService = accountService;
|
||||
private readonly IMimeFileService _mimeFileService = mimeFileService;
|
||||
|
||||
public Task<string> UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string synchronizationDeltaIdentifier)
|
||||
=> _accountService.UpdateSynchronizationIdentifierAsync(accountId, synchronizationDeltaIdentifier);
|
||||
|
||||
public Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged)
|
||||
=> _mailService.ChangeFlagStatusAsync(mailCopyId, isFlagged);
|
||||
|
||||
public Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead)
|
||||
=> _mailService.ChangeReadStatusAsync(mailCopyId, isRead);
|
||||
|
||||
public Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
||||
=> _mailService.DeleteAssignmentAsync(accountId, mailCopyId, remoteFolderId);
|
||||
|
||||
public Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
||||
=> _mailService.CreateAssignmentAsync(accountId, mailCopyId, remoteFolderId);
|
||||
|
||||
public Task DeleteMailAsync(Guid accountId, string mailId)
|
||||
=> _mailService.DeleteMailAsync(accountId, mailId);
|
||||
|
||||
public Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
|
||||
=> _mailService.CreateMailAsync(accountId, package);
|
||||
|
||||
// Folder methods
|
||||
public Task UpdateFolderStructureAsync(Guid accountId, List<MailItemFolder> allFolders)
|
||||
=> _folderService.BulkUpdateFolderStructureAsync(accountId, allFolders);
|
||||
|
||||
public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
|
||||
=> _mailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId);
|
||||
|
||||
public Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId)
|
||||
=> _mailService.MapLocalDraftAsync(mailCopyId, newDraftId, newThreadId);
|
||||
|
||||
public Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options)
|
||||
=> _folderService.GetSynchronizationFoldersAsync(options);
|
||||
|
||||
public Task<string> UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string deltaSynchronizationIdentifier)
|
||||
=> _folderService.UpdateFolderDeltaSynchronizationIdentifierAsync(folderId, deltaSynchronizationIdentifier);
|
||||
|
||||
public Task DeleteFolderAsync(Guid accountId, string remoteFolderId)
|
||||
=> _folderService.DeleteFolderAsync(accountId, remoteFolderId);
|
||||
|
||||
public Task InsertFolderAsync(MailItemFolder folder)
|
||||
=> _folderService.InsertFolderAsync(folder);
|
||||
|
||||
public Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds)
|
||||
=> _mailService.GetDownloadedUnreadMailsAsync(accountId, downloadedMailCopyIds);
|
||||
|
||||
public Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
|
||||
=> _folderService.GetKnownUidsForFolderAsync(folderId);
|
||||
|
||||
public Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId)
|
||||
=> _mimeFileService.SaveMimeMessageAsync(fileId, mimeMessage, accountId);
|
||||
}
|
||||
}
|
||||
141
Wino.Core/Integration/Threading/APIThreadingStrategy.cs
Normal file
141
Wino.Core/Integration/Threading/APIThreadingStrategy.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Integration.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;
|
||||
}
|
||||
|
||||
public async Task<List<IMailItem>> ThreadItemsAsync(List<MailCopy> items)
|
||||
{
|
||||
var accountId = items.First().AssignedAccount.Id;
|
||||
|
||||
var threads = new List<ThreadMailItem>();
|
||||
var assignedAccount = items.First().AssignedAccount;
|
||||
|
||||
// TODO: Can be optimized by moving to the caller.
|
||||
var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, Domain.Enums.SpecialFolderType.Sent);
|
||||
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, Domain.Enums.SpecialFolderType.Draft);
|
||||
|
||||
if (sentFolder == null || draftFolder == null) return default;
|
||||
|
||||
// Child -> Parent approach.
|
||||
|
||||
var potentialThreadItems = items.Distinct().Where(a => !string.IsNullOrEmpty(a.ThreadId));
|
||||
|
||||
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);
|
||||
|
||||
foreach (var potentialItem in potentialThreadItems)
|
||||
{
|
||||
if (mailLookupTable[potentialItem.Id])
|
||||
continue;
|
||||
|
||||
mailLookupTable[potentialItem.Id] = true;
|
||||
|
||||
var allThreadItems = await GetThreadItemsAsync(potentialItem.ThreadId, accountId, potentialItem.AssignedFolder, sentFolder.Id, draftFolder.Id);
|
||||
|
||||
if (allThreadItems.Count == 1)
|
||||
{
|
||||
// It's a single item.
|
||||
// Mark as not-processed as thread.
|
||||
|
||||
mailLookupTable[potentialItem.Id] = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Thread item. Mark all items as true in dict.
|
||||
var threadItem = new ThreadMailItem();
|
||||
|
||||
foreach (var childThreadItem in allThreadItems)
|
||||
{
|
||||
if (mailLookupTable.ContainsKey(childThreadItem.Id))
|
||||
mailLookupTable[childThreadItem.Id] = true;
|
||||
|
||||
childThreadItem.AssignedAccount = assignedAccount;
|
||||
childThreadItem.AssignedFolder = await _folderService.GetFolderAsync(childThreadItem.FolderId);
|
||||
|
||||
threadItem.AddThreadItem(childThreadItem);
|
||||
}
|
||||
|
||||
// Multiple mail copy ids from different folders are thing for Gmail.
|
||||
if (threadItem.ThreadItems.Count == 1)
|
||||
mailLookupTable[potentialItem.Id] = false;
|
||||
else
|
||||
threads.Add(threadItem);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
private async Task<List<MailCopy>> GetThreadItemsAsync(string threadId,
|
||||
Guid accountId,
|
||||
MailItemFolder threadingFolder,
|
||||
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.
|
||||
|
||||
string query = string.Empty;
|
||||
|
||||
if (threadingFolder.SpecialFolderType == SpecialFolderType.Draft || threadingFolder.SpecialFolderType == SpecialFolderType.Sent)
|
||||
{
|
||||
query = @$"SELECT DISTINCT MC.* FROM MailCopy MC
|
||||
INNER JOIN MailItemFolder MF on MF.Id = MC.FolderId
|
||||
WHERE MF.MailAccountId == '{accountId}' AND MC.ThreadId = '{threadId}'";
|
||||
}
|
||||
else
|
||||
{
|
||||
query = @$"SELECT DISTINCT MC.* FROM MailCopy MC
|
||||
INNER JOIN MailItemFolder MF on MF.Id = MC.FolderId
|
||||
WHERE MF.MailAccountId == '{accountId}' AND MC.FolderId IN ('{threadingFolder.Id}','{sentFolderId}','{draftFolderId}')
|
||||
AND MC.ThreadId = '{threadId}'";
|
||||
}
|
||||
|
||||
|
||||
return await _databaseService.Connection.QueryAsync<MailCopy>(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Wino.Core/Integration/Threading/GmailThreadingStrategy.cs
Normal file
10
Wino.Core/Integration/Threading/GmailThreadingStrategy.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Integration.Threading
|
||||
{
|
||||
public class GmailThreadingStrategy : APIThreadingStrategy
|
||||
{
|
||||
public GmailThreadingStrategy(IDatabaseService databaseService, IFolderService folderService) : base(databaseService, folderService) { }
|
||||
}
|
||||
}
|
||||
178
Wino.Core/Integration/Threading/ImapThreadStrategy.cs
Normal file
178
Wino.Core/Integration/Threading/ImapThreadStrategy.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using SqlKata;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Extensions;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Integration.Threading
|
||||
{
|
||||
public class ImapThreadStrategy : IThreadingStrategy
|
||||
{
|
||||
private readonly IDatabaseService _databaseService;
|
||||
private readonly IFolderService _folderService;
|
||||
|
||||
public ImapThreadStrategy(IDatabaseService databaseService, IFolderService folderService)
|
||||
{
|
||||
_databaseService = 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 _databaseService.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 _databaseService.Connection.FindWithQueryAsync<MailCopy>(query.GetRawQuery());
|
||||
}
|
||||
|
||||
public async Task<List<IMailItem>> ThreadItemsAsync(List<MailCopy> items)
|
||||
{
|
||||
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, Domain.Enums.SpecialFolderType.Sent);
|
||||
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, Domain.Enums.SpecialFolderType.Draft);
|
||||
|
||||
if (sentFolder == null || draftFolder == null) return default;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Wino.Core/Integration/Threading/OutlookThreadingStrategy.cs
Normal file
14
Wino.Core/Integration/Threading/OutlookThreadingStrategy.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Services;
|
||||
|
||||
namespace Wino.Core.Integration.Threading
|
||||
{
|
||||
// Outlook and Gmail is using the same threading strategy.
|
||||
// Outlook: ConversationId -> it's set as ThreadId
|
||||
// Gmail: ThreadId
|
||||
|
||||
public class OutlookThreadingStrategy : APIThreadingStrategy
|
||||
{
|
||||
public OutlookThreadingStrategy(IDatabaseService databaseService, IFolderService folderService) : base(databaseService, folderService) { }
|
||||
}
|
||||
}
|
||||
85
Wino.Core/MenuItems/AccountMenuItem.cs
Normal file
85
Wino.Core/MenuItems/AccountMenuItem.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public partial class AccountMenuItem : MenuItemBase<MailAccount, MenuItemBase<IMailItemFolder, FolderMenuItem>>, IAccountMenuItem
|
||||
{
|
||||
public List<FolderMenuItem> FlattenedFolderHierarchy { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private int unreadItemCount;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible))]
|
||||
private double synchronizationProgress;
|
||||
|
||||
public bool IsAttentionRequired => AttentionReason != AccountAttentionReason.None;
|
||||
public bool IsSynchronizationProgressVisible => SynchronizationProgress > 0 && SynchronizationProgress < 100;
|
||||
public Guid AccountId => Parameter.Id;
|
||||
|
||||
private AccountAttentionReason attentionReason;
|
||||
|
||||
public AccountAttentionReason AttentionReason
|
||||
{
|
||||
get => attentionReason;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref attentionReason, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(IsAttentionRequired));
|
||||
|
||||
UpdateFixAccountIssueMenuItem();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string AccountName
|
||||
{
|
||||
get => Parameter.Name;
|
||||
set => SetProperty(Parameter.Name, value, Parameter, (u, n) => u.Name = n);
|
||||
}
|
||||
|
||||
public IEnumerable<MailAccount> HoldingAccounts => new List<MailAccount> { Parameter };
|
||||
|
||||
public AccountMenuItem(MailAccount account, IMenuItem parent = null) : base(account, account.Id, parent)
|
||||
{
|
||||
UpdateAccount(account);
|
||||
}
|
||||
|
||||
public void UpdateAccount(MailAccount account)
|
||||
{
|
||||
Parameter = account;
|
||||
AccountName = account.Name;
|
||||
AttentionReason = account.AttentionReason;
|
||||
}
|
||||
|
||||
private void UpdateFixAccountIssueMenuItem()
|
||||
{
|
||||
if (AttentionReason != AccountAttentionReason.None && !SubMenuItems.Any(a => a is FixAccountIssuesMenuItem))
|
||||
{
|
||||
// Add fix issue item if not exists.
|
||||
SubMenuItems.Insert(0, new FixAccountIssuesMenuItem(Parameter, this));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remove existing if issue is resolved.
|
||||
var fixAccountIssueItem = SubMenuItems.FirstOrDefault(a => a is FixAccountIssuesMenuItem);
|
||||
|
||||
if (fixAccountIssueItem != null)
|
||||
{
|
||||
SubMenuItems.Remove(fixAccountIssueItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int GetUnreadItemCountByFolderType(SpecialFolderType specialFolderType)
|
||||
=> FlattenedFolderHierarchy?.Where(a => a.SpecialFolderType == specialFolderType).Sum(a => a.UnreadItemCount) ?? 0;
|
||||
}
|
||||
}
|
||||
16
Wino.Core/MenuItems/FixAccountIssuesMenuItem.cs
Normal file
16
Wino.Core/MenuItems/FixAccountIssuesMenuItem.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public class FixAccountIssuesMenuItem : MenuItemBase<IMailItemFolder, FolderMenuItem>
|
||||
{
|
||||
public MailAccount Account { get; }
|
||||
|
||||
public FixAccountIssuesMenuItem(MailAccount account, IMenuItem parentAccountMenuItem) : base(null, null, parentAccountMenuItem)
|
||||
{
|
||||
Account = account;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
Wino.Core/MenuItems/FolderMenuItem.cs
Normal file
75
Wino.Core/MenuItems/FolderMenuItem.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public partial class FolderMenuItem : MenuItemBase<IMailItemFolder, FolderMenuItem>, IFolderMenuItem
|
||||
{
|
||||
[ObservableProperty]
|
||||
private int unreadItemCount;
|
||||
|
||||
public bool HasTextColor => !string.IsNullOrEmpty(Parameter.TextColorHex);
|
||||
public bool IsMoveTarget => HandlingFolders.All(a => a.IsMoveTarget);
|
||||
|
||||
public SpecialFolderType SpecialFolderType => Parameter.SpecialFolderType;
|
||||
public bool IsSticky => Parameter.IsSticky;
|
||||
public bool IsSystemFolder => Parameter.IsSystemFolder;
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the folder. More and Category folders have localized display names.
|
||||
/// </summary>
|
||||
public string FolderName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Parameter.SpecialFolderType == SpecialFolderType.More)
|
||||
return Translator.MoreFolderNameOverride;
|
||||
else if (Parameter.SpecialFolderType == SpecialFolderType.Category)
|
||||
return Translator.CategoriesFolderNameOverride;
|
||||
else
|
||||
return Parameter.FolderName;
|
||||
}
|
||||
set => SetProperty(Parameter.FolderName, value, Parameter, (u, n) => u.FolderName = n);
|
||||
}
|
||||
|
||||
public bool IsSynchronizationEnabled
|
||||
{
|
||||
get => Parameter.IsSynchronizationEnabled;
|
||||
set => SetProperty(Parameter.IsSynchronizationEnabled, value, Parameter, (u, n) => u.IsSynchronizationEnabled = n);
|
||||
}
|
||||
|
||||
public IEnumerable<IMailItemFolder> HandlingFolders => new List<IMailItemFolder>() { Parameter };
|
||||
|
||||
public MailAccount ParentAccount { get; }
|
||||
|
||||
public string AssignedAccountName => ParentAccount?.Name;
|
||||
|
||||
public bool ShowUnreadCount => Parameter.ShowUnreadCount;
|
||||
|
||||
public FolderMenuItem(IMailItemFolder folderStructure, MailAccount parentAccount, IMenuItem parentMenuItem) : base(folderStructure, folderStructure.Id, parentMenuItem)
|
||||
{
|
||||
ParentAccount = parentAccount;
|
||||
}
|
||||
|
||||
public void UpdateFolder(IMailItemFolder folder)
|
||||
{
|
||||
Parameter = folder;
|
||||
|
||||
OnPropertyChanged(nameof(IsSynchronizationEnabled));
|
||||
OnPropertyChanged(nameof(ShowUnreadCount));
|
||||
OnPropertyChanged(nameof(HasTextColor));
|
||||
OnPropertyChanged(nameof(IsSystemFolder));
|
||||
OnPropertyChanged(nameof(SpecialFolderType));
|
||||
OnPropertyChanged(nameof(IsSticky));
|
||||
OnPropertyChanged(nameof(FolderName));
|
||||
}
|
||||
|
||||
public override string ToString() => FolderName;
|
||||
}
|
||||
}
|
||||
4
Wino.Core/MenuItems/ManageAccountsMenuItem.cs
Normal file
4
Wino.Core/MenuItems/ManageAccountsMenuItem.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public class ManageAccountsMenuItem : MenuItemBase { }
|
||||
}
|
||||
63
Wino.Core/MenuItems/MenuItemBase.cs
Normal file
63
Wino.Core/MenuItems/MenuItemBase.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public partial class MenuItemBase : ObservableObject, IMenuItem
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool _isExpanded;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isSelected;
|
||||
|
||||
public IMenuItem ParentMenuItem { get; }
|
||||
|
||||
public Guid? EntityId { get; }
|
||||
|
||||
public MenuItemBase(Guid? entityId = null, IMenuItem parentMenuItem = null)
|
||||
{
|
||||
EntityId = entityId;
|
||||
ParentMenuItem = parentMenuItem;
|
||||
}
|
||||
|
||||
public void Expand()
|
||||
{
|
||||
// Recursively expand all parent menu items if parent exists, starting from parent.
|
||||
if (ParentMenuItem != null)
|
||||
{
|
||||
IMenuItem parentMenuItem = ParentMenuItem;
|
||||
|
||||
while (parentMenuItem != null)
|
||||
{
|
||||
parentMenuItem.IsExpanded = true;
|
||||
|
||||
parentMenuItem = parentMenuItem.ParentMenuItem;
|
||||
}
|
||||
}
|
||||
|
||||
// Finally expand itself.
|
||||
IsExpanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class MenuItemBase<T> : MenuItemBase
|
||||
{
|
||||
[ObservableProperty]
|
||||
private T _parameter;
|
||||
|
||||
public MenuItemBase(T parameter, Guid? entityId, IMenuItem parentMenuItem = null) : base(entityId, parentMenuItem) => Parameter = parameter;
|
||||
}
|
||||
|
||||
public partial class MenuItemBase<TValue, TCollection> : MenuItemBase<TValue>
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool _isChildSelected;
|
||||
|
||||
protected MenuItemBase(TValue parameter, Guid? entityId, IMenuItem parentMenuItem = null) : base(parameter, entityId, parentMenuItem) { }
|
||||
|
||||
public ObservableCollection<TCollection> SubMenuItems { get; set; } = [];
|
||||
}
|
||||
}
|
||||
175
Wino.Core/MenuItems/MenuItemCollection.cs
Normal file
175
Wino.Core/MenuItems/MenuItemCollection.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
|
||||
{
|
||||
public IEnumerable<IBaseFolderMenuItem> GetFolderItems(Guid folderId)
|
||||
{
|
||||
var rootItems = this.OfType<AccountMenuItem>()
|
||||
.SelectMany(a => a.FlattenedFolderHierarchy)
|
||||
.Where(a => a.Parameter?.Id == folderId)
|
||||
.Cast<IBaseFolderMenuItem>();
|
||||
|
||||
// Accounts that are merged can't exist in the root items.
|
||||
// Therefore if the folder is found in root items, return it without searching inside merged accounts.
|
||||
|
||||
if (rootItems.Any()) return rootItems;
|
||||
|
||||
var mergedItems = this.OfType<MergedAccountMenuItem>()
|
||||
.SelectMany(a => a.SubMenuItems.OfType<MergedAccountFolderMenuItem>()
|
||||
.Where(a => a.Parameter.Any(b => b.Id == folderId)))
|
||||
.Cast<IBaseFolderMenuItem>();
|
||||
|
||||
// Folder is found in the MergedInbox shared folders.
|
||||
if (mergedItems.Any()) return mergedItems;
|
||||
|
||||
// Folder is not in any of the above. Looks inside the individual accounts in merged inbox account menu item.
|
||||
var mergedAccountItems = this.OfType<MergedAccountMenuItem>()
|
||||
.SelectMany(a => a.SubMenuItems.OfType<AccountMenuItem>()
|
||||
.SelectMany(a => a.FlattenedFolderHierarchy)
|
||||
.Where(a => a.Parameter?.Id == folderId))
|
||||
.Cast<IBaseFolderMenuItem>();
|
||||
|
||||
return mergedAccountItems;
|
||||
}
|
||||
|
||||
public IBaseFolderMenuItem GetFolderItem(Guid folderId) => GetFolderItems(folderId).FirstOrDefault();
|
||||
|
||||
public IAccountMenuItem GetAccountMenuItem(Guid accountId)
|
||||
{
|
||||
if (accountId == null) return null;
|
||||
|
||||
if (TryGetRootAccountMenuItem(accountId, out IAccountMenuItem rootAccountMenuItem)) return rootAccountMenuItem;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pattern: Look for root account menu item only. Don't search inside the merged account menu item.
|
||||
public bool TryGetRootAccountMenuItem(Guid accountId, out IAccountMenuItem value)
|
||||
{
|
||||
value = this.OfType<IAccountMenuItem>().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
|
||||
|
||||
value ??= this.OfType<MergedAccountMenuItem>().FirstOrDefault(a => a.EntityId == accountId);
|
||||
|
||||
return value != null;
|
||||
}
|
||||
|
||||
// Pattern: Look for root account menu item only and return the folder menu item inside the account menu item that has specific special folder type.
|
||||
public bool TryGetRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
|
||||
{
|
||||
value = this.OfType<AccountMenuItem>()
|
||||
.Where(a => a.HoldingAccounts.Any(b => b.Id == accountId))
|
||||
.SelectMany(a => a.FlattenedFolderHierarchy)
|
||||
.FirstOrDefault(a => a.Parameter?.SpecialFolderType == specialFolderType);
|
||||
|
||||
return value != null;
|
||||
}
|
||||
|
||||
// Pattern: Look for special folder menu item inside the loaded folders for Windows Mail style menu items.
|
||||
public bool TryGetWindowsStyleRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
|
||||
{
|
||||
value = this.OfType<IBaseFolderMenuItem>()
|
||||
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
|
||||
|
||||
return value != null;
|
||||
}
|
||||
|
||||
// Pattern: Find the merged account menu item and return the special folder menu item that belongs to the merged account menu item.
|
||||
// This will not look for the folders inside individual account menu items inside merged account menu item.
|
||||
public bool TryGetMergedAccountSpecialFolderMenuItem(Guid mergedInboxId, SpecialFolderType specialFolderType, out IBaseFolderMenuItem value)
|
||||
{
|
||||
value = this.OfType<MergedAccountMenuItem>()
|
||||
.Where(a => a.EntityId == mergedInboxId)
|
||||
.SelectMany(a => a.SubMenuItems)
|
||||
.OfType<MergedAccountFolderMenuItem>()
|
||||
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
|
||||
|
||||
return value != null;
|
||||
}
|
||||
|
||||
// Pattern: Find the child account menu item inside the merged account menu item, locate the special folder menu item inside the child account menu item.
|
||||
public bool TryGetMergedAccountFolderMenuItemByAccountId(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
|
||||
{
|
||||
value = this.OfType<MergedAccountMenuItem>()
|
||||
.SelectMany(a => a.SubMenuItems)
|
||||
.OfType<AccountMenuItem>()
|
||||
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))
|
||||
?.FlattenedFolderHierarchy
|
||||
.OfType<FolderMenuItem>()
|
||||
.FirstOrDefault(a => a.Parameter?.SpecialFolderType == specialFolderType);
|
||||
|
||||
return value != null;
|
||||
}
|
||||
|
||||
// Pattern: Find the common folder menu item with special folder type inside the merged account menu item for the given AccountId.
|
||||
public bool TryGetMergedAccountRootFolderMenuItemByAccountId(Guid accountId, SpecialFolderType specialFolderType, out MergedAccountFolderMenuItem value)
|
||||
{
|
||||
value = this.OfType<MergedAccountMenuItem>()
|
||||
.Where(a => a.HoldingAccounts.Any(b => b.Id == accountId))
|
||||
.SelectMany(a => a.SubMenuItems)
|
||||
.OfType<MergedAccountFolderMenuItem>()
|
||||
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
|
||||
|
||||
return value != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips the merged account menu item, but directly returns the Account menu item inside the merged account menu item.
|
||||
/// </summary>
|
||||
/// <param name="accountId">Account id to look for.</param>
|
||||
/// <returns>Direct AccountMenuItem inside the Merged Account menu item if exists.</returns>
|
||||
public AccountMenuItem GetSpecificAccountMenuItem(Guid accountId)
|
||||
{
|
||||
AccountMenuItem accountMenuItem = null;
|
||||
|
||||
accountMenuItem = this.OfType<AccountMenuItem>().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
|
||||
|
||||
// Look for the items inside the merged accounts if regular menu item is not found.
|
||||
accountMenuItem ??= this.OfType<MergedAccountMenuItem>()
|
||||
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))?.SubMenuItems
|
||||
.OfType<AccountMenuItem>()
|
||||
.FirstOrDefault();
|
||||
|
||||
return accountMenuItem;
|
||||
}
|
||||
|
||||
public void ReplaceFolders(IEnumerable<IMenuItem> folders)
|
||||
{
|
||||
ClearFolderAreaMenuItems();
|
||||
|
||||
Items.Add(new SeperatorItem());
|
||||
AddRange(folders);
|
||||
}
|
||||
|
||||
public void AddAccountMenuItem(IAccountMenuItem accountMenuItem)
|
||||
{
|
||||
var lastAccount = Items.OfType<IAccountMenuItem>().LastOrDefault();
|
||||
|
||||
// Index 0 is always the New Mail button.
|
||||
var insertIndex = lastAccount == null ? 1 : Items.IndexOf(lastAccount) + 1;
|
||||
|
||||
Insert(insertIndex, accountMenuItem);
|
||||
}
|
||||
|
||||
private void ClearFolderAreaMenuItems()
|
||||
{
|
||||
var cloneItems = Items.ToList();
|
||||
|
||||
foreach (var item in cloneItems)
|
||||
{
|
||||
if (item is SeperatorItem || item is IBaseFolderMenuItem || item is MergedAccountMoreFolderMenuItem)
|
||||
{
|
||||
item.IsSelected = false;
|
||||
item.IsExpanded = false;
|
||||
|
||||
Remove(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
Wino.Core/MenuItems/MergedAccountFolderMenuItem.cs
Normal file
110
Wino.Core/MenuItems/MergedAccountFolderMenuItem.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
/// <summary>
|
||||
/// Menu item that holds a list of folders under the merged account menu item.
|
||||
/// </summary>
|
||||
public partial class MergedAccountFolderMenuItem : MenuItemBase<List<IMailItemFolder>>, IMergedAccountFolderMenuItem
|
||||
{
|
||||
public SpecialFolderType FolderType { get; }
|
||||
|
||||
public string FolderName { get; private set; }
|
||||
|
||||
// Any of the folders is enough to determine the synchronization enable/disable state.
|
||||
public bool IsSynchronizationEnabled => HandlingFolders.Any(a => a.IsSynchronizationEnabled);
|
||||
public bool IsMoveTarget => HandlingFolders.All(a => a.IsMoveTarget);
|
||||
public IEnumerable<IMailItemFolder> HandlingFolders => Parameter;
|
||||
|
||||
// All folders in the list should have the same type.
|
||||
public SpecialFolderType SpecialFolderType => HandlingFolders.First().SpecialFolderType;
|
||||
|
||||
public bool IsSticky => true;
|
||||
|
||||
public bool IsSystemFolder => true;
|
||||
|
||||
public string AssignedAccountName => MergedInbox?.Name;
|
||||
|
||||
public MergedInbox MergedInbox { get; set; }
|
||||
|
||||
public bool ShowUnreadCount => HandlingFolders?.Any(a => a.ShowUnreadCount) ?? false;
|
||||
|
||||
[ObservableProperty]
|
||||
private int unreadItemCount;
|
||||
|
||||
// Merged account's shared folder menu item does not have an entity id.
|
||||
// Navigations to specific folders are done by explicit folder id if needed.
|
||||
|
||||
public MergedAccountFolderMenuItem(List<IMailItemFolder> parameter, IMenuItem parentMenuItem, MergedInbox mergedInbox) : base(parameter, null, parentMenuItem)
|
||||
{
|
||||
Guard.IsNotNull(mergedInbox, nameof(mergedInbox));
|
||||
Guard.IsNotNull(parameter, nameof(parameter));
|
||||
Guard.HasSizeGreaterThan(parameter, 0, nameof(parameter));
|
||||
|
||||
MergedInbox = mergedInbox;
|
||||
|
||||
SetFolderName();
|
||||
|
||||
// All folders in the list should have the same type.
|
||||
FolderType = parameter[0].SpecialFolderType;
|
||||
}
|
||||
|
||||
private void SetFolderName()
|
||||
{
|
||||
// Folders that hold more than 1 folder belong to merged account.
|
||||
// These folders will be displayed as their localized names based on the
|
||||
// special type they have.
|
||||
|
||||
if (HandlingFolders.Count() > 1)
|
||||
{
|
||||
FolderName = GetSpecialFolderName(HandlingFolders.First());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Folder only holds 1 Id, but it's displayed as merged account folder.
|
||||
FolderName = HandlingFolders.First().FolderName;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSpecialFolderName(IMailItemFolder folder)
|
||||
{
|
||||
var specialType = folder.SpecialFolderType;
|
||||
|
||||
// We only handle 5 different types for combining folders.
|
||||
// Rest of the types are not supported.
|
||||
|
||||
return specialType switch
|
||||
{
|
||||
SpecialFolderType.Inbox => Translator.MergedAccountCommonFolderInbox,
|
||||
SpecialFolderType.Draft => Translator.MergedAccountCommonFolderDraft,
|
||||
SpecialFolderType.Sent => Translator.MergedAccountCommonFolderSent,
|
||||
SpecialFolderType.Deleted => Translator.MergedAccountCommonFolderTrash,
|
||||
SpecialFolderType.Junk => Translator.MergedAccountCommonFolderJunk,
|
||||
SpecialFolderType.Archive => Translator.MergedAccountCommonFolderArchive,
|
||||
_ => folder.FolderName,
|
||||
};
|
||||
}
|
||||
|
||||
public void UpdateFolder(IMailItemFolder folder)
|
||||
{
|
||||
var existingFolder = Parameter.FirstOrDefault(a => a.Id == folder.Id);
|
||||
|
||||
if (existingFolder == null) return;
|
||||
|
||||
Parameter.Remove(existingFolder);
|
||||
Parameter.Add(folder);
|
||||
|
||||
SetFolderName();
|
||||
OnPropertyChanged(nameof(ShowUnreadCount));
|
||||
OnPropertyChanged(nameof(IsSynchronizationEnabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
55
Wino.Core/MenuItems/MergedAccountMenuItem.cs
Normal file
55
Wino.Core/MenuItems/MergedAccountMenuItem.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public partial class MergedAccountMenuItem : MenuItemBase<MergedInbox, IMenuItem>, IAccountMenuItem
|
||||
{
|
||||
public int MergedAccountCount => GetAccountMenuItems().Count();
|
||||
|
||||
public IEnumerable<MailAccount> HoldingAccounts => GetAccountMenuItems()?.SelectMany(a => a.HoldingAccounts);
|
||||
|
||||
[ObservableProperty]
|
||||
private int unreadItemCount;
|
||||
|
||||
[ObservableProperty]
|
||||
private double synchronizationProgress;
|
||||
|
||||
[ObservableProperty]
|
||||
private string mergedAccountName;
|
||||
|
||||
public MergedAccountMenuItem(MergedInbox mergedInbox, IMenuItem parent) : base(mergedInbox, mergedInbox.Id, parent)
|
||||
{
|
||||
MergedAccountName = mergedInbox.Name;
|
||||
}
|
||||
|
||||
public void RefreshFolderItemCount()
|
||||
{
|
||||
UnreadItemCount = GetAccountMenuItems().Select(a => a.GetUnreadItemCountByFolderType(SpecialFolderType.Inbox)).Sum();
|
||||
|
||||
var unreadUpdateFolders = SubMenuItems.OfType<IBaseFolderMenuItem>().Where(a => a.ShowUnreadCount);
|
||||
|
||||
foreach (var folder in unreadUpdateFolders)
|
||||
{
|
||||
folder.UnreadItemCount = GetAccountMenuItems().Select(a => a.GetUnreadItemCountByFolderType(folder.SpecialFolderType)).Sum();
|
||||
}
|
||||
}
|
||||
|
||||
// Accounts are always located in More folder of Merged Inbox menu item.
|
||||
public IEnumerable<AccountMenuItem> GetAccountMenuItems()
|
||||
{
|
||||
var moreFolder = SubMenuItems.OfType<MergedAccountMoreFolderMenuItem>().FirstOrDefault();
|
||||
|
||||
if (moreFolder == null) return default;
|
||||
|
||||
return moreFolder.SubMenuItems.OfType<AccountMenuItem>();
|
||||
}
|
||||
|
||||
public void UpdateAccount(MailAccount account)
|
||||
=> GetAccountMenuItems().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == account.Id))?.UpdateAccount(account);
|
||||
}
|
||||
}
|
||||
12
Wino.Core/MenuItems/MergedAccountMoreFolderMenuItem.cs
Normal file
12
Wino.Core/MenuItems/MergedAccountMoreFolderMenuItem.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public class MergedAccountMoreFolderMenuItem : MenuItemBase<object, IMenuItem>
|
||||
{
|
||||
public MergedAccountMoreFolderMenuItem(object parameter, Guid? entityId, IMenuItem parentMenuItem = null) : base(parameter, entityId, parentMenuItem)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
4
Wino.Core/MenuItems/NewMailMenuItem.cs
Normal file
4
Wino.Core/MenuItems/NewMailMenuItem.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public class NewMailMenuItem : MenuItemBase { }
|
||||
}
|
||||
164
Wino.Core/MenuItems/ObservableRangeCollection.cs
Normal file
164
Wino.Core/MenuItems/ObservableRangeCollection.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a dynamic data collection that provides notifications when items get added, removed, or when the whole list is refreshed.
|
||||
/// </summary>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
public class ObservableRangeCollection<T> : ObservableCollection<T>
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the System.Collections.ObjectModel.ObservableCollection(Of T) class.
|
||||
/// </summary>
|
||||
public ObservableRangeCollection()
|
||||
: base()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the System.Collections.ObjectModel.ObservableCollection(Of T) class that contains elements copied from the specified collection.
|
||||
/// </summary>
|
||||
/// <param name="collection">collection: The collection from which the elements are copied.</param>
|
||||
/// <exception cref="System.ArgumentNullException">The collection parameter cannot be null.</exception>
|
||||
public ObservableRangeCollection(IEnumerable<T> collection)
|
||||
: base(collection)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the elements of the specified collection to the end of the ObservableCollection(Of T).
|
||||
/// </summary>
|
||||
public void AddRange(IEnumerable<T> collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
if (notificationMode != NotifyCollectionChangedAction.Add && notificationMode != NotifyCollectionChangedAction.Reset)
|
||||
throw new ArgumentException("Mode must be either Add or Reset for AddRange.", nameof(notificationMode));
|
||||
if (collection == null)
|
||||
throw new ArgumentNullException(nameof(collection));
|
||||
|
||||
CheckReentrancy();
|
||||
|
||||
var startIndex = Count;
|
||||
|
||||
var itemsAdded = AddArrangeCore(collection);
|
||||
|
||||
if (!itemsAdded)
|
||||
return;
|
||||
|
||||
if (notificationMode == NotifyCollectionChangedAction.Reset)
|
||||
{
|
||||
RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var changedItems = collection is List<T> ? (List<T>)collection : new List<T>(collection);
|
||||
|
||||
RaiseChangeNotificationEvents(
|
||||
action: NotifyCollectionChangedAction.Add,
|
||||
changedItems: changedItems,
|
||||
startingIndex: startIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the first occurence of each item in the specified collection from ObservableCollection(Of T). NOTE: with notificationMode = Remove, removed items starting index is not set because items are not guaranteed to be consecutive.
|
||||
/// </summary>
|
||||
public void RemoveRange(IEnumerable<T> collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Reset)
|
||||
{
|
||||
if (notificationMode != NotifyCollectionChangedAction.Remove && notificationMode != NotifyCollectionChangedAction.Reset)
|
||||
throw new ArgumentException("Mode must be either Remove or Reset for RemoveRange.", nameof(notificationMode));
|
||||
if (collection == null)
|
||||
throw new ArgumentNullException(nameof(collection));
|
||||
|
||||
CheckReentrancy();
|
||||
|
||||
if (notificationMode == NotifyCollectionChangedAction.Reset)
|
||||
{
|
||||
var raiseEvents = false;
|
||||
foreach (var item in collection)
|
||||
{
|
||||
Items.Remove(item);
|
||||
raiseEvents = true;
|
||||
}
|
||||
|
||||
if (raiseEvents)
|
||||
RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var changedItems = new List<T>(collection);
|
||||
for (var i = 0; i < changedItems.Count; i++)
|
||||
{
|
||||
if (!Items.Remove(changedItems[i]))
|
||||
{
|
||||
changedItems.RemoveAt(i); //Can't use a foreach because changedItems is intended to be (carefully) modified
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
if (changedItems.Count == 0)
|
||||
return;
|
||||
|
||||
RaiseChangeNotificationEvents(
|
||||
action: NotifyCollectionChangedAction.Remove,
|
||||
changedItems: changedItems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the current collection and replaces it with the specified item.
|
||||
/// </summary>
|
||||
public void Replace(T item) => ReplaceRange(new T[] { item });
|
||||
|
||||
/// <summary>
|
||||
/// Clears the current collection and replaces it with the specified collection.
|
||||
/// </summary>
|
||||
public void ReplaceRange(IEnumerable<T> collection)
|
||||
{
|
||||
if (collection == null)
|
||||
throw new ArgumentNullException(nameof(collection));
|
||||
|
||||
CheckReentrancy();
|
||||
|
||||
var previouslyEmpty = Items.Count == 0;
|
||||
|
||||
Items.Clear();
|
||||
|
||||
AddArrangeCore(collection);
|
||||
|
||||
var currentlyEmpty = Items.Count == 0;
|
||||
|
||||
if (previouslyEmpty && currentlyEmpty)
|
||||
return;
|
||||
|
||||
RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset);
|
||||
}
|
||||
|
||||
private bool AddArrangeCore(IEnumerable<T> collection)
|
||||
{
|
||||
var itemAdded = false;
|
||||
foreach (var item in collection)
|
||||
{
|
||||
Items.Add(item);
|
||||
itemAdded = true;
|
||||
}
|
||||
return itemAdded;
|
||||
}
|
||||
|
||||
private void RaiseChangeNotificationEvents(NotifyCollectionChangedAction action, List<T>? changedItems = null, int startingIndex = -1)
|
||||
{
|
||||
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||
|
||||
if (changedItems is null)
|
||||
OnCollectionChanged(new NotifyCollectionChangedEventArgs(action));
|
||||
else
|
||||
OnCollectionChanged(new NotifyCollectionChangedEventArgs(action, changedItems: changedItems, startingIndex: startingIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
4
Wino.Core/MenuItems/RateMenuItem.cs
Normal file
4
Wino.Core/MenuItems/RateMenuItem.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public class RateMenuItem : MenuItemBase { }
|
||||
}
|
||||
4
Wino.Core/MenuItems/SeperatorItem.cs
Normal file
4
Wino.Core/MenuItems/SeperatorItem.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public class SeperatorItem : MenuItemBase { }
|
||||
}
|
||||
4
Wino.Core/MenuItems/SettingsItem.cs
Normal file
4
Wino.Core/MenuItems/SettingsItem.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace Wino.Core.MenuItems
|
||||
{
|
||||
public class SettingsItem : MenuItemBase { }
|
||||
}
|
||||
14
Wino.Core/Messages/Accounts/AccountMenuItemExtended.cs
Normal file
14
Wino.Core/Messages/Accounts/AccountMenuItemExtended.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
|
||||
namespace Wino.Core.Messages.Accounts
|
||||
{
|
||||
/// <summary>
|
||||
/// When menu item for the account is requested to be extended.
|
||||
/// Additional properties are also supported to navigate to correct IMailItem.
|
||||
/// </summary>
|
||||
/// <param name="AutoSelectAccount">Account to extend menu item for.</param>
|
||||
/// <param name="FolderId">Folder to select after expansion.</param>
|
||||
/// <param name="NavigateMailItem">Mail item to select if possible in the expanded folder.</param>
|
||||
public record AccountMenuItemExtended(Guid FolderId, IMailItem NavigateMailItem);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Wino.Core.Messages.Accounts
|
||||
{
|
||||
/// <summary>
|
||||
/// When a full menu refresh for accounts menu is requested.
|
||||
/// </summary>
|
||||
public record AccountsMenuRefreshRequested(bool AutomaticallyNavigateFirstItem = true);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Wino.Core.Messages.Authorization
|
||||
{
|
||||
/// <summary>
|
||||
/// When Google authentication makes a callback to the app via protocol activation to the app.
|
||||
/// </summary>
|
||||
/// <param name="AuthorizationResponseUri">Callback Uri that Google returned.</param>
|
||||
public record ProtocolAuthorizationCallbackReceived(Uri AuthorizationResponseUri);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When rendered html is requested to cancel.
|
||||
/// </summary>
|
||||
public record CancelRenderingContentRequested;
|
||||
}
|
||||
7
Wino.Core/Messages/Mails/ClearMailSelectionsRequested.cs
Normal file
7
Wino.Core/Messages/Mails/ClearMailSelectionsRequested.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When reset all mail selections requested.
|
||||
/// </summary>
|
||||
public record ClearMailSelectionsRequested;
|
||||
}
|
||||
10
Wino.Core/Messages/Mails/CreateNewComposeMailRequested.cs
Normal file
10
Wino.Core/Messages/Mails/CreateNewComposeMailRequested.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Wino.Core.Domain.Models.Reader;
|
||||
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When a new composing requested.
|
||||
/// </summary>
|
||||
/// <param name="RenderModel"></param>
|
||||
public record CreateNewComposeMailRequested(MailRenderModel RenderModel);
|
||||
}
|
||||
8
Wino.Core/Messages/Mails/HtmlRenderingRequested.cs
Normal file
8
Wino.Core/Messages/Mails/HtmlRenderingRequested.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When existing a new html is requested to be rendered due to mail selection or signature.
|
||||
/// </summary>
|
||||
/// <param name="HtmlBody">HTML to render in WebView2.</param>
|
||||
public record HtmlRenderingRequested(string HtmlBody);
|
||||
}
|
||||
11
Wino.Core/Messages/Mails/ImapSetupBackNavigationRequested.cs
Normal file
11
Wino.Core/Messages/Mails/ImapSetupBackNavigationRequested.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When IMAP setup dialog requestes back breadcrumb navigation.
|
||||
/// </summary>
|
||||
/// <param name="PageType">Type to go back.</param>
|
||||
/// <param name="Parameter">Back parameters.</param>
|
||||
public record ImapSetupBackNavigationRequested(Type PageType, object Parameter);
|
||||
}
|
||||
10
Wino.Core/Messages/Mails/ImapSetupDismissRequested.cs
Normal file
10
Wino.Core/Messages/Mails/ImapSetupDismissRequested.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Wino.Core.Domain.Entities;
|
||||
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When user asked to dismiss IMAP setup dialog.
|
||||
/// </summary>
|
||||
/// <param name="CompletedServerInformation"> Validated server information that is ready to be saved to database. </param>
|
||||
public record ImapSetupDismissRequested(CustomServerInformation CompletedServerInformation = null);
|
||||
}
|
||||
11
Wino.Core/Messages/Mails/ImapSetupNavigationRequested.cs
Normal file
11
Wino.Core/Messages/Mails/ImapSetupNavigationRequested.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When IMAP setup dialog breadcrumb navigation requested.
|
||||
/// </summary>
|
||||
/// <param name="PageType">Page type to navigate.</param>
|
||||
/// <param name="Parameter">Navigation parameters.</param>
|
||||
public record ImapSetupNavigationRequested(Type PageType, object Parameter);
|
||||
}
|
||||
11
Wino.Core/Messages/Mails/MailItemNavigationRequested.cs
Normal file
11
Wino.Core/Messages/Mails/MailItemNavigationRequested.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When a IMailItem needs to be navigated (or selected)
|
||||
/// </summary>
|
||||
/// <param name="UniqueMailId">UniqueId of the mail to navigate.</param>
|
||||
/// <param name="ScrollToItem">Whether navigated item should be scrolled to or not..</param>
|
||||
public record MailItemNavigationRequested(Guid UniqueMailId, bool ScrollToItem = false);
|
||||
}
|
||||
17
Wino.Core/Messages/Mails/NavigateMailFolderEvent.cs
Normal file
17
Wino.Core/Messages/Mails/NavigateMailFolderEvent.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// Selects the given FolderMenuItem in the shell folders list.
|
||||
/// </summary>
|
||||
public class NavigateMailFolderEvent : NavigateMailFolderEventArgs
|
||||
{
|
||||
public NavigateMailFolderEvent(IBaseFolderMenuItem baseFolderMenuItem, TaskCompletionSource<bool> folderInitLoadAwaitTask = null)
|
||||
: base(baseFolderMenuItem, folderInitLoadAwaitTask)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
6
Wino.Core/Messages/Mails/RefreshUnreadCountsMessage.cs
Normal file
6
Wino.Core/Messages/Mails/RefreshUnreadCountsMessage.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
public record RefreshUnreadCountsMessage(Guid AccountId);
|
||||
}
|
||||
11
Wino.Core/Messages/Mails/SaveAsPDFRequested.cs
Normal file
11
Wino.Core/Messages/Mails/SaveAsPDFRequested.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When mail save as PDF requested.
|
||||
/// </summary>
|
||||
public record SaveAsPDFRequested(string FileSavePath);
|
||||
}
|
||||
8
Wino.Core/Messages/Mails/SelectedMailItemsChanged.cs
Normal file
8
Wino.Core/Messages/Mails/SelectedMailItemsChanged.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Wino.Core.Messages.Mails
|
||||
{
|
||||
/// <summary>
|
||||
/// When selected mail count is changed.
|
||||
/// </summary>
|
||||
/// <param name="SelectedItemCount">New selected mail count.</param>
|
||||
public record SelectedMailItemsChanged(int SelectedItemCount);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Wino.Core.Messages.Navigation
|
||||
{
|
||||
/// <summary>
|
||||
/// When back navigation is requested for breadcrumb pages.
|
||||
/// </summary>
|
||||
public record BackBreadcrumNavigationRequested { }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Messages.Navigation
|
||||
{
|
||||
/// <summary>
|
||||
/// When Breadcrumb control navigation requested.
|
||||
/// </summary>
|
||||
/// <param name="PageTitle">Title to display for the page.</param>
|
||||
/// <param name="PageType">Enum equilavent of the page to navigate.</param>
|
||||
/// <param name="Parameter">Additional parameters to the page.</param>
|
||||
public record BreadcrumbNavigationRequested(string PageTitle, WinoPage PageType, object Parameter = null);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Wino.Core.Messages.Navigation
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigates to settings page.
|
||||
/// </summary>
|
||||
public record NavigateSettingsRequested;
|
||||
}
|
||||
8
Wino.Core/Messages/Shell/ApplicationThemeChanged.cs
Normal file
8
Wino.Core/Messages/Shell/ApplicationThemeChanged.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Wino.Core.Messages.Shell
|
||||
{
|
||||
/// <summary>
|
||||
/// When the application theme changed.
|
||||
/// </summary>
|
||||
/// <param name="IsUnderlyingThemeDark"></param>
|
||||
public record ApplicationThemeChanged(bool IsUnderlyingThemeDark);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using Wino.Core.Domain.Entities;
|
||||
|
||||
namespace Wino.Core.Messages.Shell
|
||||
{
|
||||
/// <summary>
|
||||
/// When
|
||||
/// - There is no selection of any folder for any account
|
||||
/// - Multiple accounts exists
|
||||
/// - User clicked 'Create New Mail'
|
||||
///
|
||||
/// flyout must be presented to pick correct account.
|
||||
/// This message will be picked up by UWP Shell.
|
||||
/// </summary>
|
||||
public record CreateNewMailWithMultipleAccountsRequested(IEnumerable<MailAccount> AllAccounts);
|
||||
}
|
||||
17
Wino.Core/Messages/Shell/InfoBarMessageRequested.cs
Normal file
17
Wino.Core/Messages/Shell/InfoBarMessageRequested.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Messages.Shell
|
||||
{
|
||||
/// <summary>
|
||||
/// For displaying right sliding notification message in shell.
|
||||
/// </summary>
|
||||
/// <param name="Severity">Severity of notification.</param>
|
||||
/// <param name="Title">Title of the message.</param>
|
||||
/// <param name="Message">Message content.</param>
|
||||
public record InfoBarMessageRequested(InfoBarMessageType Severity,
|
||||
string Title,
|
||||
string Message,
|
||||
string ActionButtonTitle = "",
|
||||
Action Action = null);
|
||||
}
|
||||
7
Wino.Core/Messages/Shell/LanguageChanged.cs
Normal file
7
Wino.Core/Messages/Shell/LanguageChanged.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Wino.Core.Messages.Shell
|
||||
{
|
||||
/// <summary>
|
||||
/// When application language is updated.
|
||||
/// </summary>
|
||||
public record LanguageChanged;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Wino.Core.Messages.Shell
|
||||
{
|
||||
public class MailtoProtocolMessageRequested { }
|
||||
}
|
||||
10
Wino.Core/Messages/Shell/NavigationPaneModeChanged.cs
Normal file
10
Wino.Core/Messages/Shell/NavigationPaneModeChanged.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Messages.Shell
|
||||
{
|
||||
/// <summary>
|
||||
/// When navigation pane mode is changed.
|
||||
/// </summary>
|
||||
/// <param name="NewMode">New navigation mode.</param>
|
||||
public record NavigationPaneModeChanged(MenuPaneMode NewMode);
|
||||
}
|
||||
7
Wino.Core/Messages/Shell/ShellStateUpdated.cs
Normal file
7
Wino.Core/Messages/Shell/ShellStateUpdated.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Wino.Core.Messages.Shell
|
||||
{
|
||||
/// <summary>
|
||||
/// When reading mail state or reader pane narrowed state is changed.
|
||||
/// </summary>
|
||||
public record ShellStateUpdated;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Messages.Synchronization
|
||||
{
|
||||
public record AccountSynchronizationCompleted(Guid AccountId, SynchronizationCompletedState Result, Guid? SynchronizationTrackingId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Synchronizers;
|
||||
|
||||
namespace Wino.Core.Messages.Synchronization
|
||||
{
|
||||
/// <summary>
|
||||
/// Emitted when synchronizer state is updated.
|
||||
/// </summary>
|
||||
/// <param name="synchronizer">Account Synchronizer</param>
|
||||
/// <param name="newState">New state.</param>
|
||||
public record AccountSynchronizerStateChanged(IBaseSynchronizer Synchronizer, AccountSynchronizerState NewState);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Messages.Synchronization
|
||||
{
|
||||
/// <summary>
|
||||
/// Triggers a new synchronization if possible.
|
||||
/// </summary>
|
||||
/// <param name="Options">Options for synchronization.</param>
|
||||
public record NewSynchronizationRequested(SynchronizationOptions Options);
|
||||
}
|
||||
248
Wino.Core/Mime/HtmlPreviewVisitor.cs
Normal file
248
Wino.Core/Mime/HtmlPreviewVisitor.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using MimeKit;
|
||||
using MimeKit.Text;
|
||||
using MimeKit.Tnef;
|
||||
|
||||
namespace Wino.Core.Mime
|
||||
{
|
||||
/// <summary>
|
||||
/// Visits a MimeMessage and generates HTML suitable to be rendered by a browser control.
|
||||
/// </summary>
|
||||
public class HtmlPreviewVisitor : MimeVisitor
|
||||
{
|
||||
List<MultipartRelated> stack = new List<MultipartRelated>();
|
||||
List<MimeEntity> attachments = new List<MimeEntity>();
|
||||
|
||||
readonly string tempDir;
|
||||
|
||||
public string Body { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new HtmlPreviewVisitor.
|
||||
/// </summary>
|
||||
/// <param name="tempDirectory">A temporary directory used for storing image files.</param>
|
||||
public HtmlPreviewVisitor(string tempDirectory)
|
||||
{
|
||||
tempDir = tempDirectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The list of attachments that were in the MimeMessage.
|
||||
/// </summary>
|
||||
public IList<MimeEntity> Attachments
|
||||
{
|
||||
get { return attachments; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The HTML string that can be set on the BrowserControl.
|
||||
/// </summary>
|
||||
public string HtmlBody
|
||||
{
|
||||
get { return Body ?? string.Empty; }
|
||||
}
|
||||
|
||||
protected override void VisitMultipartAlternative(MultipartAlternative alternative)
|
||||
{
|
||||
// walk the multipart/alternative children backwards from greatest level of faithfulness to the least faithful
|
||||
for (int i = alternative.Count - 1; i >= 0 && Body == null; i--)
|
||||
alternative[i].Accept(this);
|
||||
}
|
||||
|
||||
protected override void VisitMultipartRelated(MultipartRelated related)
|
||||
{
|
||||
var root = related.Root;
|
||||
|
||||
// push this multipart/related onto our stack
|
||||
stack.Add(related);
|
||||
|
||||
// visit the root document
|
||||
root.Accept(this);
|
||||
|
||||
// pop this multipart/related off our stack
|
||||
stack.RemoveAt(stack.Count - 1);
|
||||
}
|
||||
|
||||
// look up the image based on the img src url within our multipart/related stack
|
||||
bool TryGetImage(string url, out MimePart image)
|
||||
{
|
||||
UriKind kind;
|
||||
int index;
|
||||
Uri uri;
|
||||
|
||||
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
|
||||
kind = UriKind.Absolute;
|
||||
else if (Uri.IsWellFormedUriString(url, UriKind.Relative))
|
||||
kind = UriKind.Relative;
|
||||
else
|
||||
kind = UriKind.RelativeOrAbsolute;
|
||||
|
||||
try
|
||||
{
|
||||
uri = new Uri(url, kind);
|
||||
}
|
||||
catch
|
||||
{
|
||||
image = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = stack.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if ((index = stack[i].IndexOf(uri)) == -1)
|
||||
continue;
|
||||
|
||||
image = stack[i][index] as MimePart;
|
||||
return image != null;
|
||||
}
|
||||
|
||||
image = null;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save the image to our temp directory and return a "file://" url suitable for
|
||||
// the browser control to load.
|
||||
// Note: if you'd rather embed the image data into the HTML, you can construct a
|
||||
// "data:" url instead.
|
||||
string SaveImage(MimePart image)
|
||||
{
|
||||
using (var memory = new MemoryStream())
|
||||
{
|
||||
image.Content.DecodeTo(memory);
|
||||
var buffer = memory.GetBuffer();
|
||||
var length = (int)memory.Length;
|
||||
var base64 = Convert.ToBase64String(buffer, 0, length);
|
||||
|
||||
return string.Format("data:{0};base64,{1}", image.ContentType.MimeType, base64);
|
||||
}
|
||||
|
||||
//string fileName = url
|
||||
// .Replace(':', '_')
|
||||
// .Replace('\\', '_')
|
||||
// .Replace('/', '_');
|
||||
|
||||
//string path = Path.Combine(tempDir, fileName);
|
||||
|
||||
//if (!File.Exists(path))
|
||||
//{
|
||||
// using (var output = File.Create(path))
|
||||
// image.Content.DecodeTo(output);
|
||||
//}
|
||||
|
||||
//return "file://" + path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
// Replaces <img src=...> urls that refer to images embedded within the message with
|
||||
// "file://" urls that the browser control will actually be able to load.
|
||||
void HtmlTagCallback(HtmlTagContext ctx, HtmlWriter htmlWriter)
|
||||
{
|
||||
if (ctx.TagId == HtmlTagId.Image && !ctx.IsEndTag && stack.Count > 0)
|
||||
{
|
||||
ctx.WriteTag(htmlWriter, false);
|
||||
|
||||
// replace the src attribute with a file:// URL
|
||||
foreach (var attribute in ctx.Attributes)
|
||||
{
|
||||
if (attribute.Id == HtmlAttributeId.Src)
|
||||
{
|
||||
MimePart image;
|
||||
string url;
|
||||
|
||||
if (!TryGetImage(attribute.Value, out image))
|
||||
{
|
||||
htmlWriter.WriteAttribute(attribute);
|
||||
continue;
|
||||
}
|
||||
|
||||
url = SaveImage(image);
|
||||
|
||||
htmlWriter.WriteAttributeName(attribute.Name);
|
||||
htmlWriter.WriteAttributeValue(url);
|
||||
}
|
||||
else
|
||||
{
|
||||
htmlWriter.WriteAttribute(attribute);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (ctx.TagId == HtmlTagId.Body && !ctx.IsEndTag)
|
||||
{
|
||||
ctx.WriteTag(htmlWriter, false);
|
||||
|
||||
// add and/or replace oncontextmenu="return false;"
|
||||
foreach (var attribute in ctx.Attributes)
|
||||
{
|
||||
if (attribute.Name.ToLowerInvariant() == "oncontextmenu")
|
||||
continue;
|
||||
|
||||
htmlWriter.WriteAttribute(attribute);
|
||||
}
|
||||
|
||||
htmlWriter.WriteAttribute("oncontextmenu", "return false;");
|
||||
}
|
||||
else
|
||||
{
|
||||
// pass the tag through to the output
|
||||
ctx.WriteTag(htmlWriter, true);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void VisitTextPart(TextPart entity)
|
||||
{
|
||||
TextConverter converter;
|
||||
|
||||
if (Body != null)
|
||||
{
|
||||
// since we've already found the body, treat this as an attachment
|
||||
attachments.Add(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.IsHtml)
|
||||
{
|
||||
converter = new HtmlToHtml
|
||||
{
|
||||
HtmlTagCallback = HtmlTagCallback
|
||||
};
|
||||
}
|
||||
else if (entity.IsFlowed)
|
||||
{
|
||||
var flowed = new FlowedToHtml();
|
||||
string delsp;
|
||||
|
||||
if (entity.ContentType.Parameters.TryGetValue("delsp", out delsp))
|
||||
flowed.DeleteSpace = delsp.ToLowerInvariant() == "yes";
|
||||
|
||||
converter = flowed;
|
||||
}
|
||||
else
|
||||
{
|
||||
converter = new TextToHtml();
|
||||
}
|
||||
|
||||
Body = converter.Convert(entity.Text);
|
||||
}
|
||||
|
||||
protected override void VisitTnefPart(TnefPart entity)
|
||||
{
|
||||
// extract any attachments in the MS-TNEF part
|
||||
attachments.AddRange(entity.ExtractAttachments());
|
||||
}
|
||||
|
||||
protected override void VisitMessagePart(MessagePart entity)
|
||||
{
|
||||
// treat message/rfc822 parts as attachments
|
||||
attachments.Add(entity);
|
||||
}
|
||||
|
||||
protected override void VisitMimePart(MimePart entity)
|
||||
{
|
||||
// realistically, if we've gotten this far, then we can treat this as an attachment
|
||||
// even if the IsAttachment property is false.
|
||||
attachments.Add(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Wino.Core/Mime/ImapMessageCreationPackage.cs
Normal file
9
Wino.Core/Mime/ImapMessageCreationPackage.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using MailKit;
|
||||
|
||||
namespace Wino.Core.Mime
|
||||
{
|
||||
/// <summary>
|
||||
/// Encapsulates all required information to create a MimeMessage for IMAP synchronizer.
|
||||
/// </summary>
|
||||
public record ImapMessageCreationPackage(IMessageSummary MessageSummary, IMailFolder MailFolder);
|
||||
}
|
||||
36
Wino.Core/Misc/RequestComparer.cs
Normal file
36
Wino.Core/Misc/RequestComparer.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Requests;
|
||||
|
||||
namespace Wino.Core.Misc
|
||||
{
|
||||
/// <summary>
|
||||
/// This is incomplete.
|
||||
/// </summary>
|
||||
internal class RequestComparer : IEqualityComparer<IRequestBase>
|
||||
{
|
||||
public bool Equals(IRequestBase x, IRequestBase y)
|
||||
{
|
||||
if (x is MoveRequest sourceMoveRequest && y is MoveRequest targetMoveRequest)
|
||||
{
|
||||
return sourceMoveRequest.FromFolder.Id == targetMoveRequest.FromFolder.Id && sourceMoveRequest.ToFolder.Id == targetMoveRequest.ToFolder.Id;
|
||||
}
|
||||
else if (x is ChangeFlagRequest sourceFlagRequest && y is ChangeFlagRequest targetFlagRequest)
|
||||
{
|
||||
return sourceFlagRequest.IsFlagged == targetFlagRequest.IsFlagged;
|
||||
}
|
||||
else if (x is MarkReadRequest sourceMarkReadRequest && y is MarkReadRequest targetMarkReadRequest)
|
||||
{
|
||||
return sourceMarkReadRequest.Item.IsRead == targetMarkReadRequest.Item.IsRead;
|
||||
}
|
||||
else if (x is DeleteRequest sourceDeleteRequest && y is DeleteRequest targetDeleteRequest)
|
||||
{
|
||||
return sourceDeleteRequest.MailItem.AssignedFolder.Id == targetDeleteRequest.MailItem.AssignedFolder.Id;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public int GetHashCode(IRequestBase obj) => obj.Operation.GetHashCode();
|
||||
}
|
||||
}
|
||||
39
Wino.Core/Requests/AlwaysMoveToRequest.cs
Normal file
39
Wino.Core/Requests/AlwaysMoveToRequest.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
public record AlwaysMoveToRequest(MailCopy Item, bool MoveToFocused) : RequestBase<BatchMoveRequest>(Item, MailSynchronizerOperation.AlwaysMoveTo)
|
||||
{
|
||||
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
|
||||
=> new BatchAlwaysMoveToRequest(matchingItems, MoveToFocused);
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public record BatchAlwaysMoveToRequest(IEnumerable<IRequest> Items, bool MoveToFocused) : BatchRequestBase(Items, MailSynchronizerOperation.AlwaysMoveTo)
|
||||
{
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Wino.Core/Requests/Bundles/RequestBundle.cs
Normal file
59
Wino.Core/Requests/Bundles/RequestBundle.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Domain.Models.Requests
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Bundle that encapsulates batch request and native request without a response.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">Http type for each integrator. eg. ClientServiceRequest for Gmail and RequestInformation for Microsoft Graph.</typeparam>
|
||||
/// <param name="NativeRequest">Native type to send via http.</param>
|
||||
/// <param name="BatchRequest">Batch request that is generated by base synchronizer.</param>
|
||||
public record HttpRequestBundle<TRequest>(TRequest NativeRequest, IRequestBase Request) : IRequestBundle<TRequest>
|
||||
{
|
||||
public string BundleId { get; set; } = string.Empty;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (Request is IRequest singleRequest)
|
||||
return $"Single {singleRequest.Operation}. No response.";
|
||||
else if (Request is IBatchChangeRequest batchChangeRequest)
|
||||
return $"Batch {batchChangeRequest.Operation} for {batchChangeRequest.Items.Count()} items. No response.";
|
||||
else
|
||||
return "Unknown http request bundle.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle that encapsulates batch request and native request with response.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">Http type for each integrator. eg. ClientServiceRequest for Gmail and RequestInformation for Microsoft Graph.</typeparam>
|
||||
/// <param name="NativeRequest">Native type to send via http.</param>
|
||||
/// <param name="BatchRequest">Batch request that is generated by base synchronizer.</param>
|
||||
public record HttpRequestBundle<TRequest, TResponse>(TRequest NativeRequest, IRequestBase Request) : HttpRequestBundle<TRequest>(NativeRequest, Request)
|
||||
{
|
||||
public async Task<TResponse> DeserializeBundleAsync(HttpResponseMessage httpResponse, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var content = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
return JsonConvert.DeserializeObject<TResponse>(content) ?? throw new InvalidOperationException("Invalid Http Response Deserialization");
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (Request is IRequest singleRequest)
|
||||
return $"Single {singleRequest.Operation}. Expecting '{typeof(TResponse).FullName}' type.";
|
||||
else if (Request is IBatchChangeRequest batchChangeRequest)
|
||||
return $"Batch {batchChangeRequest.Operation} for {batchChangeRequest.Items.Count()} items. Expecting '{typeof(TResponse).FullName}' type.";
|
||||
else
|
||||
return "Unknown http request bundle.";
|
||||
}
|
||||
}
|
||||
}
|
||||
24
Wino.Core/Requests/Bundles/TaskRequestBundle.cs
Normal file
24
Wino.Core/Requests/Bundles/TaskRequestBundle.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Net.Imap;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Requests.Bundles
|
||||
{
|
||||
//public abstract record TaskRequestBundleBase()
|
||||
//{
|
||||
// public abstract Task ExecuteAsync(ImapClient executorImapClient);
|
||||
//}
|
||||
|
||||
//public record TaskRequestBundle(Func<ImapClient, Task> NativeRequest) : TaskRequestBundleBase
|
||||
//{
|
||||
// public override async Task ExecuteAsync(ImapClient executorImapClient) => await NativeRequest(executorImapClient).ConfigureAwait(false);
|
||||
//}
|
||||
|
||||
public record ImapRequest(Func<ImapClient, Task> IntegratorTask, IRequestBase Request) { }
|
||||
|
||||
public record ImapRequestBundle(ImapRequest NativeRequest, IRequestBase Request) : IRequestBundle<ImapRequest>
|
||||
{
|
||||
public string BundleId { get; set; } = Guid.NewGuid().ToString();
|
||||
}
|
||||
}
|
||||
59
Wino.Core/Requests/ChangeFlagRequest.cs
Normal file
59
Wino.Core/Requests/ChangeFlagRequest.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using MoreLinq;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : RequestBase<BatchMoveRequest>(Item, MailSynchronizerOperation.ChangeFlag),
|
||||
ICustomFolderSynchronizationRequest
|
||||
{
|
||||
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
|
||||
|
||||
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
|
||||
=> new BatchChangeFlagRequest(matchingItems, IsFlagged);
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
Item.IsFlagged = IsFlagged;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Item.IsFlagged = !IsFlagged;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
|
||||
}
|
||||
}
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public record BatchChangeFlagRequest(IEnumerable<IRequest> Items, bool IsFlagged) : BatchRequestBase(Items, MailSynchronizerOperation.ChangeFlag)
|
||||
{
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
Items.ForEach(item =>
|
||||
{
|
||||
item.Item.IsFlagged = IsFlagged;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item.Item));
|
||||
});
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Items.ForEach(item =>
|
||||
{
|
||||
item.Item.IsFlagged = !IsFlagged;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item.Item));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
52
Wino.Core/Requests/CreateDraftRequest.cs
Normal file
52
Wino.Core/Requests/CreateDraftRequest.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using MoreLinq;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
public record CreateDraftRequest(DraftPreperationRequest DraftPreperationRequest)
|
||||
: RequestBase<BatchCreateDraftRequest>(DraftPreperationRequest.CreatedLocalDraftCopy, MailSynchronizerOperation.CreateDraft),
|
||||
ICustomFolderSynchronizationRequest
|
||||
{
|
||||
public List<Guid> SynchronizationFolderIds =>
|
||||
[
|
||||
DraftPreperationRequest.CreatedLocalDraftCopy.AssignedFolder.Id
|
||||
];
|
||||
|
||||
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
|
||||
=> new BatchCreateDraftRequest(matchingItems, DraftPreperationRequest);
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// No need for it since Draft folder is automatically navigated and draft item is added + selected.
|
||||
// We only need to revert changes in case of network fails to create the draft.
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item));
|
||||
}
|
||||
}
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public record class BatchCreateDraftRequest(IEnumerable<IRequest> Items, DraftPreperationRequest DraftPreperationRequest)
|
||||
: BatchRequestBase(Items, MailSynchronizerOperation.CreateDraft)
|
||||
{
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// No need for it since Draft folder is automatically navigated and draft item is added + selected.
|
||||
// We only need to revert changes in case of network fails to create the draft.
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item.Item)));
|
||||
}
|
||||
}
|
||||
}
|
||||
49
Wino.Core/Requests/DeleteRequest.cs
Normal file
49
Wino.Core/Requests/DeleteRequest.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using MoreLinq;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard delete request. This request will delete the mail item from the server without moving it to the trash folder.
|
||||
/// </summary>
|
||||
/// <param name="MailItem">Item to delete permanently.</param>
|
||||
public record DeleteRequest(MailCopy MailItem) : RequestBase<BatchDeleteRequest>(MailItem, MailSynchronizerOperation.Delete),
|
||||
ICustomFolderSynchronizationRequest
|
||||
{
|
||||
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
|
||||
|
||||
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
|
||||
=> new BatchDeleteRequest(matchingItems);
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item));
|
||||
}
|
||||
}
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public record class BatchDeleteRequest(IEnumerable<IRequest> Items) : BatchRequestBase(Items, MailSynchronizerOperation.Delete)
|
||||
{
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item.Item)));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailAddedMessage(item.Item)));
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Wino.Core/Requests/MarkReadRequest.cs
Normal file
59
Wino.Core/Requests/MarkReadRequest.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using MoreLinq;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
public record MarkReadRequest(MailCopy Item, bool IsRead) : RequestBase<BatchMarkReadRequest>(Item, MailSynchronizerOperation.MarkRead),
|
||||
ICustomFolderSynchronizationRequest
|
||||
{
|
||||
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
|
||||
|
||||
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
|
||||
=> new BatchMarkReadRequest(matchingItems, IsRead);
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
Item.IsRead = IsRead;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Item.IsRead = !IsRead;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
|
||||
}
|
||||
}
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public record BatchMarkReadRequest(IEnumerable<IRequest> Items, bool IsRead) : BatchRequestBase(Items, MailSynchronizerOperation.MarkRead)
|
||||
{
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
Items.ForEach(item =>
|
||||
{
|
||||
item.Item.IsRead = IsRead;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item.Item));
|
||||
});
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Items.ForEach(item =>
|
||||
{
|
||||
item.Item.IsRead = !IsRead;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item.Item));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Wino.Core/Requests/MoveRequest.cs
Normal file
45
Wino.Core/Requests/MoveRequest.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using MoreLinq;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
public record MoveRequest(MailCopy Item, MailItemFolder FromFolder, MailItemFolder ToFolder)
|
||||
: RequestBase<BatchMoveRequest>(Item, MailSynchronizerOperation.Move), ICustomFolderSynchronizationRequest
|
||||
{
|
||||
public List<Guid> SynchronizationFolderIds => new() { FromFolder.Id, ToFolder.Id };
|
||||
|
||||
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
|
||||
=> new BatchMoveRequest(matchingItems, FromFolder, ToFolder);
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item));
|
||||
}
|
||||
}
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public record BatchMoveRequest(IEnumerable<IRequest> Items, MailItemFolder FromFolder, MailItemFolder ToFolder) : BatchRequestBase(Items, MailSynchronizerOperation.Move)
|
||||
{
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item.Item)));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailAddedMessage(item.Item)));
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Wino.Core/Requests/MoveToFocusedRequest.cs
Normal file
27
Wino.Core/Requests/MoveToFocusedRequest.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
public record MoveToFocusedRequest(MailCopy Item, bool MoveToFocused) : RequestBase<BatchMoveRequest>(Item, MailSynchronizerOperation.Move)
|
||||
{
|
||||
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
|
||||
=> new BatchMoveToFocusedRequest(matchingItems, MoveToFocused);
|
||||
|
||||
public override void ApplyUIChanges() { }
|
||||
|
||||
public override void RevertUIChanges() { }
|
||||
}
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public record BatchMoveToFocusedRequest(IEnumerable<IRequest> Items, bool MoveToFocused) : BatchRequestBase(Items, MailSynchronizerOperation.Move)
|
||||
{
|
||||
public override void ApplyUIChanges() { }
|
||||
|
||||
public override void RevertUIChanges() { }
|
||||
}
|
||||
}
|
||||
19
Wino.Core/Requests/RenameFolderRequest.cs
Normal file
19
Wino.Core/Requests/RenameFolderRequest.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
public record RenameFolderRequest(MailItemFolder Folder) : FolderRequestBase(Folder, MailSynchronizerOperation.RenameFolder)
|
||||
{
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Wino.Core/Requests/SendDraftRequest.cs
Normal file
41
Wino.Core/Requests/SendDraftRequest.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using MoreLinq;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
public record SendDraftRequest(SendDraftPreparationRequest Request) : RequestBase<BatchMarkReadRequest>(Request.MailItem, MailSynchronizerOperation.Send)
|
||||
{
|
||||
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
|
||||
=> new BatchSendDraftRequestRequest(matchingItems, Request);
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item));
|
||||
}
|
||||
}
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public record BatchSendDraftRequestRequest(IEnumerable<IRequest> Items, SendDraftPreparationRequest Request) : BatchRequestBase(Items, MailSynchronizerOperation.Send)
|
||||
{
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item.Item)));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailAddedMessage(item.Item)));
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Wino.Core/Requests/UIMessages.cs
Normal file
25
Wino.Core/Requests/UIMessages.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests
|
||||
{
|
||||
public record MailAddedMessage(MailCopy AddedMail) : IUIMessage;
|
||||
public record MailRemovedMessage(MailCopy RemovedMail) : IUIMessage;
|
||||
public record MailUpdatedMessage(MailCopy UpdatedMail) : IUIMessage;
|
||||
public record MailDownloadedMessage(MailCopy DownloadedMail) : IUIMessage;
|
||||
|
||||
public record FolderAddedMessage(MailItemFolder AddedFolder, MailAccount Account) : IUIMessage;
|
||||
public record FolderRemovedMessage(MailItemFolder RemovedFolder, MailAccount Account) : IUIMessage;
|
||||
public record FolderUpdatedMessage(MailItemFolder UpdatedFolder, MailAccount Account) : IUIMessage;
|
||||
|
||||
public record AccountCreatedMessage(MailAccount Account) : IUIMessage;
|
||||
public record AccountRemovedMessage(MailAccount Account) : IUIMessage;
|
||||
public record AccountUpdatedMessage(MailAccount Account) : IUIMessage;
|
||||
|
||||
public record DraftCreated(MailCopy DraftMail, MailAccount Account) : IUIMessage;
|
||||
public record DraftFailed(MailCopy DraftMail, MailAccount Account) : IUIMessage;
|
||||
public record DraftMapped(string LocalDraftCopyId, string RemoteDraftCopyId) : IUIMessage;
|
||||
|
||||
public record MergedInboxRenamed(Guid MergedInboxId, string NewName) : IUIMessage;
|
||||
}
|
||||
403
Wino.Core/Services/AccountService.cs
Normal file
403
Wino.Core/Services/AccountService.cs
Normal file
@@ -0,0 +1,403 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Serilog;
|
||||
using SqlKata;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Extensions;
|
||||
using Wino.Core.Messages.Accounts;
|
||||
using Wino.Core.Requests;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
public class AccountService : BaseDatabaseService, IAccountService
|
||||
{
|
||||
public IAuthenticator ExternalAuthenticationAuthenticator { get; set; }
|
||||
|
||||
private readonly IAuthenticationProvider _authenticationProvider;
|
||||
private readonly ISignatureService _signatureService;
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
|
||||
private readonly ILogger _logger = Log.ForContext<AccountService>();
|
||||
|
||||
public AccountService(IDatabaseService databaseService,
|
||||
IAuthenticationProvider authenticationProvider,
|
||||
ISignatureService signatureService,
|
||||
IPreferencesService preferencesService) : base(databaseService)
|
||||
{
|
||||
_authenticationProvider = authenticationProvider;
|
||||
_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.GenerateTokenAsync(account, true);
|
||||
|
||||
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>().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;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
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<TokenInformation>().Where(a => a.AccountId == account.Id).DeleteAsync();
|
||||
await Connection.Table<MailItemFolder>().DeleteAsync(a => a.MailAccountId == account.Id);
|
||||
|
||||
if (account.SignatureId != null)
|
||||
await Connection.Table<AccountSignature>().DeleteAsync(a => a.Id == account.SignatureId);
|
||||
|
||||
// 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<MailAccount> GetAccountAsync(Guid accountId)
|
||||
{
|
||||
var account = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id == accountId);
|
||||
|
||||
if (account?.ProviderType == MailProviderType.IMAP4)
|
||||
account.ServerInformation = await GetAccountCustomServerInformationAsync(account.Id);
|
||||
|
||||
account.Preferences = await GetAccountPreferencesAsync(account.Id);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public Task<CustomServerInformation> GetAccountCustomServerInformationAsync(Guid accountId)
|
||||
=> Connection.Table<CustomServerInformation>().FirstOrDefaultAsync(a => a.AccountId == accountId);
|
||||
|
||||
public async Task UpdateAccountAsync(MailAccount account)
|
||||
{
|
||||
if (account.Preferences == null)
|
||||
{
|
||||
Debugger.Break();
|
||||
}
|
||||
|
||||
await Connection.UpdateAsync(account.Preferences);
|
||||
await Connection.UpdateAsync(account);
|
||||
|
||||
ReportUIChange(new AccountUpdatedMessage(account));
|
||||
}
|
||||
|
||||
public async Task CreateAccountAsync(MailAccount account, TokenInformation tokenInformation, 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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (isMicrosoftProvider)
|
||||
account.Preferences.IsFocusedInboxEnabled = true;
|
||||
|
||||
await Connection.InsertAsync(preferences);
|
||||
|
||||
// Create default signature.
|
||||
var defaultSignature = await _signatureService.CreateDefaultSignatureAsync(account.Id);
|
||||
|
||||
account.SignatureId = defaultSignature.Id;
|
||||
|
||||
if (customServerInformation != null)
|
||||
await Connection.InsertAsync(customServerInformation);
|
||||
|
||||
if (tokenInformation != null)
|
||||
await Connection.InsertAsync(tokenInformation);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_logger.Debug("Updating synchronization identifier for {Name}. From: {SynchronizationDeltaIdentifier} To: {NewIdentifier}", account.Name, account.SynchronizationDeltaIdentifier, newIdentifier);
|
||||
account.SynchronizationDeltaIdentifier = newIdentifier;
|
||||
|
||||
await UpdateAccountAsync(account);
|
||||
}
|
||||
|
||||
return account.SynchronizationDeltaIdentifier;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
34
Wino.Core/Services/AuthenticationProvider.cs
Normal file
34
Wino.Core/Services/AuthenticationProvider.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using Wino.Core.Authenticators;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using IAuthenticationProvider = Wino.Core.Domain.Interfaces.IAuthenticationProvider;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
public class AuthenticationProvider : IAuthenticationProvider
|
||||
{
|
||||
private readonly INativeAppService _nativeAppService;
|
||||
private readonly ITokenService _tokenService;
|
||||
|
||||
public AuthenticationProvider(INativeAppService nativeAppService, ITokenService tokenService)
|
||||
{
|
||||
_nativeAppService = nativeAppService;
|
||||
_tokenService = tokenService;
|
||||
}
|
||||
|
||||
public IAuthenticator GetAuthenticator(MailProviderType providerType)
|
||||
{
|
||||
return providerType switch
|
||||
{
|
||||
MailProviderType.Outlook => new OutlookAuthenticator(_tokenService, _nativeAppService),
|
||||
MailProviderType.Office365 => new Office365Authenticator(_tokenService, _nativeAppService),
|
||||
MailProviderType.Gmail => new GmailAuthenticator(_tokenService, _nativeAppService),
|
||||
MailProviderType.Yahoo => new YahooAuthenticator(_tokenService),
|
||||
MailProviderType.IMAP4 => new CustomAuthenticator(_tokenService),
|
||||
_ => throw new ArgumentException(Translator.Exception_UnsupportedProvider),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
56
Wino.Core/Services/AutoDiscoveryService.cs
Normal file
56
Wino.Core/Services/AutoDiscoveryService.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.AutoDiscovery;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// We have 2 methods to do auto discovery.
|
||||
/// 1. Use https://emailsettings.firetrust.com/settings?q={address} API
|
||||
/// 2. TODO: Thunderbird auto discovery file.
|
||||
/// </summary>
|
||||
public class AutoDiscoveryService : IAutoDiscoveryService
|
||||
{
|
||||
private const string FiretrustURL = " https://emailsettings.firetrust.com/settings?q=";
|
||||
|
||||
// TODO: Try Thunderbird Auto Discovery as second approach.
|
||||
|
||||
public Task<AutoDiscoverySettings> GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings)
|
||||
=> GetSettingsFromFiretrustAsync(autoDiscoveryMinimalSettings.Email);
|
||||
|
||||
private async Task<AutoDiscoverySettings> GetSettingsFromFiretrustAsync(string mailAddress)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var response = await client.GetAsync($"{FiretrustURL}{mailAddress}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await DeserializeFiretrustResponse(response);
|
||||
else
|
||||
{
|
||||
Log.Warning($"Firetrust AutoDiscovery failed. ({response.StatusCode})");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoverySettings> DeserializeFiretrustResponse(HttpResponseMessage response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
return JsonConvert.DeserializeObject<AutoDiscoverySettings>(content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to deserialize Firetrust response.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Wino.Core/Services/BaseDatabaseService.cs
Normal file
22
Wino.Core/Services/BaseDatabaseService.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.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);
|
||||
}
|
||||
}
|
||||
54
Wino.Core/Services/ContactService.cs
Normal file
54
Wino.Core/Services/ContactService.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MimeKit;
|
||||
using SqlKata;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Extensions;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
public interface IContactService
|
||||
{
|
||||
Task<List<AddressInformation>> GetAddressInformationAsync(string queryText);
|
||||
Task<AddressInformation> GetAddressInformationByAddressAsync(string address);
|
||||
Task SaveAddressInformationAsync(MimeMessage message);
|
||||
}
|
||||
|
||||
public class ContactService : BaseDatabaseService, IContactService
|
||||
{
|
||||
public ContactService(IDatabaseService databaseService) : base(databaseService) { }
|
||||
|
||||
public Task<List<AddressInformation>> GetAddressInformationAsync(string queryText)
|
||||
{
|
||||
if (queryText == null || queryText.Length < 2)
|
||||
return Task.FromResult<List<AddressInformation>>(null);
|
||||
|
||||
var query = new Query(nameof(AddressInformation));
|
||||
query.WhereContains("Address", queryText);
|
||||
query.OrWhereContains("Name", queryText);
|
||||
|
||||
var rawLikeQuery = query.GetRawQuery();
|
||||
|
||||
return Connection.QueryAsync<AddressInformation>(rawLikeQuery);
|
||||
}
|
||||
|
||||
public async Task<AddressInformation> GetAddressInformationByAddressAsync(string address)
|
||||
{
|
||||
return await Connection.Table<AddressInformation>().Where(a => a.Address == address).FirstOrDefaultAsync()
|
||||
?? new AddressInformation() { Name = address, Address = address };
|
||||
}
|
||||
|
||||
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 AddressInformation() { Name = a.Name, Address = a.Address });
|
||||
|
||||
foreach (var info in addressInformations)
|
||||
await Connection.InsertOrReplaceAsync(info).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
182
Wino.Core/Services/ContextMenuItemService.cs
Normal file
182
Wino.Core/Services/ContextMenuItemService.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
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.Core.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 isAllFlagged = selectedMailItems.All(a => a.IsFlagged);
|
||||
bool isAllRead = selectedMailItems.All(a => a.IsRead);
|
||||
bool isAllUnread = selectedMailItems.All(a => !a.IsRead);
|
||||
|
||||
if (isAllRead)
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread));
|
||||
else
|
||||
{
|
||||
if (!isAllUnread)
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread));
|
||||
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead));
|
||||
}
|
||||
|
||||
if (isAllFlagged)
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
|
||||
else
|
||||
{
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
|
||||
operationList.Add(MailOperationMenuItem.Create(MailOperation.SetFlag));
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
69
Wino.Core/Services/DatabaseService.cs
Normal file
69
Wino.Core/Services/DatabaseService.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
public interface IDatabaseService : IInitializeAsync
|
||||
{
|
||||
SQLiteAsyncConnection Connection { get; }
|
||||
}
|
||||
|
||||
public class DatabaseService : IDatabaseService
|
||||
{
|
||||
private string DatabaseName => "Wino.db";
|
||||
|
||||
private bool _isInitialized = false;
|
||||
private readonly IAppInitializerService _appInitializerService;
|
||||
|
||||
public SQLiteAsyncConnection Connection { get; private set; }
|
||||
|
||||
public DatabaseService(IAppInitializerService appInitializerService)
|
||||
{
|
||||
_appInitializerService = appInitializerService;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (_isInitialized)
|
||||
return;
|
||||
|
||||
var applicationData = _appInitializerService.GetApplicationDataFolder();
|
||||
var databaseFileName = Path.Combine(applicationData, DatabaseName);
|
||||
|
||||
Connection = new SQLiteAsyncConnection(databaseFileName)
|
||||
{
|
||||
// Enable for debugging sqlite.
|
||||
Trace = true,
|
||||
Tracer = new Action<string>((t) =>
|
||||
{
|
||||
// Debug.WriteLine(t);
|
||||
// Log.Debug(t);
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
await CreateTablesAsync();
|
||||
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
private async Task CreateTablesAsync()
|
||||
{
|
||||
await Connection.CreateTablesAsync(CreateFlags.None,
|
||||
typeof(MailCopy),
|
||||
typeof(MailItemFolder),
|
||||
typeof(MailAccount),
|
||||
typeof(TokenInformation),
|
||||
typeof(AddressInformation),
|
||||
typeof(CustomServerInformation),
|
||||
typeof(AccountSignature),
|
||||
typeof(MergedInbox),
|
||||
typeof(MailAccountPreferences)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
592
Wino.Core/Services/FolderService.cs
Normal file
592
Wino.Core/Services/FolderService.cs
Normal file
@@ -0,0 +1,592 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MoreLinq;
|
||||
using Serilog;
|
||||
using SqlKata;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities;
|
||||
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.Synchronization;
|
||||
using Wino.Core.Extensions;
|
||||
using Wino.Core.Requests;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
public class FolderService : BaseDatabaseService, IFolderService
|
||||
{
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly IMimeFileService _mimeFileService;
|
||||
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,
|
||||
IMimeFileService mimeFileService) : base(databaseService)
|
||||
{
|
||||
_accountService = accountService;
|
||||
_mimeFileService = mimeFileService;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
=> Connection.Table<MailItemFolder>().Where(a => a.MailAccountId == accountId).ToListAsync();
|
||||
|
||||
public async Task UpdateCustomServerMailListAsync(Guid accountId, List<MailItemFolder> folders)
|
||||
{
|
||||
var account = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id == accountId);
|
||||
|
||||
if (account == null)
|
||||
return;
|
||||
|
||||
// IMAP servers don't have unique identifier for folders all the time.
|
||||
// We'll map them with parent-name relation.
|
||||
|
||||
var currentFolders = await GetFoldersAsync(accountId);
|
||||
|
||||
// These folders don't exist anymore. Remove them.
|
||||
var localRemoveFolders = currentFolders.ExceptBy(folders, a => a.RemoteFolderId);
|
||||
|
||||
foreach (var currentFolder in currentFolders)
|
||||
{
|
||||
// Check if we have this folder locally.
|
||||
var remotelyExistFolder = folders.FirstOrDefault(a => a.RemoteFolderId == currentFolder.RemoteFolderId
|
||||
&& a.ParentRemoteFolderId == currentFolder.ParentRemoteFolderId);
|
||||
|
||||
if (remotelyExistFolder == null)
|
||||
{
|
||||
// This folder is removed.
|
||||
// Remove everything for this folder.
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
var currentFolder = await Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.RemoteFolderId == folder.RemoteFolderId);
|
||||
|
||||
// Nothing is changed, it's still the same folder.
|
||||
// Just update Id of the folder.
|
||||
|
||||
if (currentFolder != null)
|
||||
folder.Id = currentFolder.Id;
|
||||
|
||||
await Connection.InsertOrReplaceAsync(folder);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
var account = await _accountService.GetAccountAsync(accountId);
|
||||
|
||||
if (account == null)
|
||||
throw new ArgumentNullException(nameof(account));
|
||||
|
||||
// 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));
|
||||
|
||||
await _accountService.UpdateAccountAsync(account);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#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);
|
||||
|
||||
ReportUIChange(new FolderAddedMessage(folder, account));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("Folder {Id} - {FolderName} already exists. Updating.", folder.Id, folder.FolderName);
|
||||
|
||||
await UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateFolderAsync(MailItemFolder folder)
|
||||
{
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.Warning("Folder is null. Cannot update.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var account = await _accountService.GetAccountAsync(folder.MailAccountId).ConfigureAwait(false);
|
||||
if (account == null)
|
||||
{
|
||||
_logger.Warning("Account with id {MailAccountId} does not exist. Cannot update folder.", folder.MailAccountId);
|
||||
return;
|
||||
}
|
||||
|
||||
#if !DEBUG // Annoying
|
||||
_logger.Debug("Updating folder {FolderName}", folder.Id, folder.FolderName);
|
||||
#endif
|
||||
|
||||
await Connection.UpdateAsync(folder).ConfigureAwait(false);
|
||||
|
||||
ReportUIChange(new FolderUpdatedMessage(folder, account));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
ReportUIChange(new FolderRemovedMessage(folder, account));
|
||||
}
|
||||
|
||||
#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 SetSpecialFolderAsync(Guid folderId, SpecialFolderType type)
|
||||
=> await Connection.ExecuteAsync("UPDATE MailItemFolder SET SpecialFolderType = ? WHERE Id = ?", type, folderId);
|
||||
|
||||
public async Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options)
|
||||
{
|
||||
var folders = new List<MailItemFolder>();
|
||||
|
||||
if (options.Type == SynchronizationType.Inbox)
|
||||
{
|
||||
var inboxFolder = await GetSpecialFolderByAccountIdAsync(options.AccountId, SpecialFolderType.Inbox);
|
||||
var sentFolder = await GetSpecialFolderByAccountIdAsync(options.AccountId, SpecialFolderType.Sent);
|
||||
var draftFolder = await GetSpecialFolderByAccountIdAsync(options.AccountId, SpecialFolderType.Draft);
|
||||
|
||||
// For properly creating threads we need Sent and Draft to be synchronized as well.
|
||||
|
||||
if (sentFolder != null && sentFolder.IsSynchronizationEnabled)
|
||||
{
|
||||
folders.Add(sentFolder);
|
||||
}
|
||||
|
||||
if (draftFolder != null && draftFolder.IsSynchronizationEnabled)
|
||||
{
|
||||
folders.Add(draftFolder);
|
||||
}
|
||||
|
||||
// User might've disabled inbox synchronization somehow...
|
||||
if (inboxFolder != null && inboxFolder.IsSynchronizationEnabled)
|
||||
{
|
||||
folders.Add(inboxFolder);
|
||||
}
|
||||
}
|
||||
else if (options.Type == SynchronizationType.Full)
|
||||
{
|
||||
// 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 if (options.Type == SynchronizationType.Custom)
|
||||
{
|
||||
// Only get the specified and enabled folders.
|
||||
|
||||
var synchronizationFolders = await Connection.Table<MailItemFolder>()
|
||||
.Where(a => a.MailAccountId == options.AccountId && a.IsSynchronizationEnabled && options.SynchronizationFolderIds.Contains(a.Id))
|
||||
.ToListAsync();
|
||||
|
||||
folders.AddRange(synchronizationFolders);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
public Task<MailItemFolder> GetFolderAsync(Guid accountId, string remoteFolderId)
|
||||
=> Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.RemoteFolderId == remoteFolderId);
|
||||
|
||||
// v2
|
||||
public async Task BulkUpdateFolderStructureAsync(Guid accountId, List<MailItemFolder> allFolders)
|
||||
{
|
||||
var existingFolders = await GetFoldersAsync(accountId).ConfigureAwait(false);
|
||||
|
||||
var foldersToInsert = allFolders.ExceptBy(existingFolders, a => a.RemoteFolderId);
|
||||
var foldersToDelete = existingFolders.ExceptBy(allFolders, a => a.RemoteFolderId);
|
||||
var foldersToUpdate = allFolders.Except(foldersToInsert).Except(foldersToDelete);
|
||||
|
||||
_logger.Debug("Found {0} folders to insert, {1} folders to update and {2} folders to delete.",
|
||||
foldersToInsert.Count(),
|
||||
foldersToUpdate.Count(),
|
||||
foldersToDelete.Count());
|
||||
|
||||
foreach (var folder in foldersToInsert)
|
||||
{
|
||||
await InsertFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var folder in foldersToUpdate)
|
||||
{
|
||||
await UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var folder in foldersToDelete)
|
||||
{
|
||||
await DeleteFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string synchronizationIdentifier)
|
||||
{
|
||||
var folder = await GetFolderAsync(folderId).ConfigureAwait(false);
|
||||
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.Warning("Folder with id {FolderId} does not exist.", folderId);
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
folder.DeltaToken = synchronizationIdentifier;
|
||||
|
||||
await UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
|
||||
return synchronizationIdentifier;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Inbox folder is always included for account menu item unread count.
|
||||
public Task<List<MailItemFolder>> GetUnreadUpdateFoldersAsync(Guid accountId)
|
||||
=> Connection.Table<MailItemFolder>().Where(a => a.MailAccountId == accountId && (a.ShowUnreadCount || a.SpecialFolderType == SpecialFolderType.Inbox)).ToListAsync();
|
||||
|
||||
public async Task TestAsync()
|
||||
{
|
||||
var account = new MailAccount()
|
||||
{
|
||||
Address = "test@test.com",
|
||||
ProviderType = MailProviderType.Gmail,
|
||||
Name = "Test Account",
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
await Connection.InsertAsync(account);
|
||||
|
||||
var pref = new MailAccountPreferences
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = account.Id
|
||||
};
|
||||
|
||||
await Connection.InsertAsync(pref);
|
||||
|
||||
ReportUIChange(new AccountCreatedMessage(account));
|
||||
}
|
||||
|
||||
public async Task<bool> IsInboxAvailableForAccountAsync(Guid accountId)
|
||||
=> (await Connection.Table<MailItemFolder>()
|
||||
.Where(a => a.SpecialFolderType == SpecialFolderType.Inbox && a.MailAccountId == accountId)
|
||||
.CountAsync()) == 1;
|
||||
}
|
||||
}
|
||||
50
Wino.Core/Services/FontService.cs
Normal file
50
Wino.Core/Services/FontService.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Reader;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
public class FontService : IFontService
|
||||
{
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
private ILogger _logger = Log.ForContext<FontService>();
|
||||
|
||||
private readonly List<ReaderFontModel> _availableFonts =
|
||||
[
|
||||
new ReaderFontModel(ReaderFont.Arial, "Arial"),
|
||||
new ReaderFontModel(ReaderFont.Calibri, "Calibri"),
|
||||
new ReaderFontModel(ReaderFont.TimesNewRoman, "Times New Roman"),
|
||||
new ReaderFontModel(ReaderFont.TrebuchetMS, "Trebuchet MS"),
|
||||
new ReaderFontModel(ReaderFont.Tahoma, "Tahoma"),
|
||||
new ReaderFontModel(ReaderFont.Verdana, "Verdana"),
|
||||
new ReaderFontModel(ReaderFont.Georgia, "Georgia"),
|
||||
new ReaderFontModel(ReaderFont.CourierNew, "Courier New")
|
||||
];
|
||||
|
||||
public FontService(IPreferencesService preferencesService)
|
||||
{
|
||||
_preferencesService = preferencesService;
|
||||
}
|
||||
|
||||
public List<ReaderFontModel> GetReaderFonts() => _availableFonts;
|
||||
|
||||
public void ChangeReaderFont(ReaderFont font)
|
||||
{
|
||||
_preferencesService.ReaderFont = font;
|
||||
|
||||
_logger.Information("Default reader font is changed to {Font}", font);
|
||||
}
|
||||
|
||||
public void ChangeReaderFontSize(int size)
|
||||
{
|
||||
_preferencesService.ReaderFontSize = size;
|
||||
|
||||
_logger.Information("Default reader font size is changed to {Size}", size);
|
||||
}
|
||||
|
||||
public ReaderFontModel GetCurrentReaderFont() => _availableFonts.Find(f => f.Font == _preferencesService.ReaderFont);
|
||||
public int GetCurrentReaderFontSize() => _preferencesService.ReaderFontSize;
|
||||
}
|
||||
}
|
||||
53
Wino.Core/Services/ImapTestService.cs
Normal file
53
Wino.Core/Services/ImapTestService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
public class ImapTestService : IImapTestService
|
||||
{
|
||||
public const string ProtocolLogFileName = "ImapProtocolLog.log";
|
||||
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
private readonly IAppInitializerService _appInitializerService;
|
||||
|
||||
public ImapTestService(IPreferencesService preferencesService, IAppInitializerService appInitializerService)
|
||||
{
|
||||
_preferencesService = preferencesService;
|
||||
_appInitializerService = appInitializerService;
|
||||
}
|
||||
|
||||
public async Task TestImapConnectionAsync(CustomServerInformation serverInformation)
|
||||
{
|
||||
ImapClient client = null;
|
||||
|
||||
if (_preferencesService.IsMailkitProtocolLoggerEnabled)
|
||||
{
|
||||
// Create new file for protocol logger.
|
||||
|
||||
var localAppFolderPath = _appInitializerService.GetApplicationDataFolder();
|
||||
|
||||
var logFile = Path.Combine(localAppFolderPath, ProtocolLogFileName);
|
||||
|
||||
if (File.Exists(logFile))
|
||||
File.Delete(logFile);
|
||||
|
||||
var stream = File.Create(logFile);
|
||||
|
||||
client = new ImapClient(new ProtocolLogger(stream));
|
||||
}
|
||||
else
|
||||
client = new ImapClient();
|
||||
|
||||
using (client)
|
||||
{
|
||||
// todo: test connection
|
||||
// await client.InitializeAsync(serverInformation);
|
||||
await client.DisconnectAsync(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Wino.Core/Services/LogInitializer.cs
Normal file
41
Wino.Core/Services/LogInitializer.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.IO;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
public class LogInitializer : ILogInitializer
|
||||
{
|
||||
public const string WinoLogFileName = "WinoDiagnostics.log";
|
||||
|
||||
private readonly LoggingLevelSwitch _levelSwitch = new LoggingLevelSwitch();
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
|
||||
public LogInitializer(IPreferencesService preferencesService)
|
||||
{
|
||||
_preferencesService = preferencesService;
|
||||
|
||||
RefreshLoggingLevel();
|
||||
}
|
||||
|
||||
public void RefreshLoggingLevel()
|
||||
{
|
||||
_levelSwitch.MinimumLevel = _preferencesService.IsLoggingEnabled ? Serilog.Events.LogEventLevel.Debug : Serilog.Events.LogEventLevel.Fatal;
|
||||
}
|
||||
|
||||
public void SetupLogger(string logFolderPath)
|
||||
{
|
||||
string logFilePath = Path.Combine(logFolderPath, WinoLogFileName);
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.ControlledBy(_levelSwitch)
|
||||
.WriteTo.File(logFilePath)
|
||||
.WriteTo.Debug()
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithExceptionDetails()
|
||||
.CreateLogger();
|
||||
}
|
||||
}
|
||||
}
|
||||
833
Wino.Core/Services/MailService.cs
Normal file
833
Wino.Core/Services/MailService.cs
Normal file
@@ -0,0 +1,833 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MimeKit;
|
||||
using MimeKit.Text;
|
||||
using MoreLinq;
|
||||
using Serilog;
|
||||
using SqlKata;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Comparers;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Extensions;
|
||||
using Wino.Core.Requests;
|
||||
|
||||
namespace Wino.Core.Services
|
||||
{
|
||||
public class MailService : BaseDatabaseService, IMailService
|
||||
{
|
||||
private const int ItemLoadCount = 20;
|
||||
|
||||
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 ILogger _logger = Log.ForContext<MailService>();
|
||||
|
||||
public MailService(IDatabaseService databaseService,
|
||||
IFolderService folderService,
|
||||
IContactService contactService,
|
||||
IAccountService accountService,
|
||||
ISignatureService signatureService,
|
||||
IThreadingStrategyProvider threadingStrategyProvider,
|
||||
IMimeFileService mimeFileService) : base(databaseService)
|
||||
{
|
||||
_folderService = folderService;
|
||||
_contactService = contactService;
|
||||
_accountService = accountService;
|
||||
_signatureService = signatureService;
|
||||
_threadingStrategyProvider = threadingStrategyProvider;
|
||||
_mimeFileService = mimeFileService;
|
||||
}
|
||||
|
||||
public async Task<MailCopy> CreateDraftAsync(MailAccount composerAccount,
|
||||
MimeMessage createdDraftMimeMessage,
|
||||
MimeMessage replyingMimeMessage = null,
|
||||
IMailItem replyingMailItem = null)
|
||||
{
|
||||
bool isImapAccount = composerAccount.ServerInformation != null;
|
||||
|
||||
string fromName;
|
||||
|
||||
if (isImapAccount)
|
||||
fromName = composerAccount.ServerInformation.DisplayName;
|
||||
else
|
||||
{
|
||||
var composerContact = await _contactService.GetAddressInformationByAddressAsync(composerAccount.Address);
|
||||
|
||||
fromName = composerContact?.Name ?? composerAccount.Address;
|
||||
}
|
||||
|
||||
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 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 = composerAccount.Address,
|
||||
FromName = fromName,
|
||||
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.
|
||||
bool isReplying = replyingMimeMessage != null;
|
||||
|
||||
if (isReplying)
|
||||
{
|
||||
if (replyingMimeMessage.References != null)
|
||||
copy.References = string.Join(",", replyingMimeMessage.References);
|
||||
|
||||
if (!string.IsNullOrEmpty(replyingMimeMessage.MessageId))
|
||||
copy.InReplyTo = replyingMimeMessage.MessageId;
|
||||
|
||||
if (!string.IsNullOrEmpty(replyingMailItem?.ThreadId))
|
||||
copy.ThreadId = replyingMailItem.ThreadId;
|
||||
}
|
||||
|
||||
await Connection.InsertAsync(copy);
|
||||
|
||||
|
||||
await _mimeFileService.SaveMimeMessageAsync(copy.FileId, createdDraftMimeMessage, composerAccount.Id);
|
||||
|
||||
ReportUIChange(new DraftCreated(copy, composerAccount));
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
public Task<List<string>> GetMailIdsByFolderIdAsync(Guid folderId)
|
||||
=> Connection.QueryScalarsAsync<string>("SELECT Id FROM MailCopy WHERE FolderId = ?", folderId);
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var query = BuildMailFetchQuery(options);
|
||||
|
||||
var mails = await Connection.QueryAsync<MailCopy>(query);
|
||||
|
||||
// Fill in assigned account and folder for each mail.
|
||||
// To speed things up a bit, we'll load account and assigned folder in groups
|
||||
// to reduce the query time.
|
||||
|
||||
var groupedByFolders = mails.GroupBy(a => a.FolderId);
|
||||
|
||||
foreach (var group in groupedByFolders)
|
||||
{
|
||||
MailItemFolder folderAssignment = null;
|
||||
MailAccount accountAssignment = null;
|
||||
|
||||
folderAssignment = await _folderService.GetFolderAsync(group.Key).ConfigureAwait(false);
|
||||
|
||||
if (folderAssignment != null)
|
||||
{
|
||||
accountAssignment = await _accountService.GetAccountAsync(folderAssignment.MailAccountId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
group.ForEach(a =>
|
||||
{
|
||||
a.AssignedFolder = folderAssignment;
|
||||
a.AssignedAccount = accountAssignment;
|
||||
});
|
||||
}
|
||||
|
||||
// Remove items that has no assigned account or folder.
|
||||
mails.RemoveAll(a => a.AssignedAccount == null || a.AssignedFolder == null);
|
||||
|
||||
// Each account items must be threaded separately.
|
||||
|
||||
if (options.CreateThreads)
|
||||
{
|
||||
var threadedItems = new List<IMailItem>();
|
||||
|
||||
var groupedByAccounts = mails.GroupBy(a => a.AssignedAccount.Id);
|
||||
|
||||
foreach (var group in groupedByAccounts)
|
||||
{
|
||||
if (!group.Any()) continue;
|
||||
|
||||
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.ToList());
|
||||
|
||||
if (accountThreadedItems != null)
|
||||
{
|
||||
threadedItems.AddRange(accountThreadedItems);
|
||||
}
|
||||
}
|
||||
|
||||
threadedItems.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer());
|
||||
|
||||
return threadedItems;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Threading is disabled. Just return everything as it is.
|
||||
|
||||
mails.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer());
|
||||
|
||||
return new List<IMailItem>(mails);
|
||||
}
|
||||
}
|
||||
|
||||
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 async Task LoadAssignedPropertiesAsync(MailCopy mailCopy)
|
||||
{
|
||||
if (mailCopy == null) return;
|
||||
|
||||
// Load AssignedAccount and AssignedFolder.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
await DeleteMailInternalAsync(mailItem).ConfigureAwait(false);
|
||||
|
||||
// Delete mime file.
|
||||
// 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 _mimeFileService.DeleteMimeMessageAsync(accountId, mailItem.FileId);
|
||||
}
|
||||
}
|
||||
|
||||
#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 Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
|
||||
|
||||
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)
|
||||
{
|
||||
if (mailCopy == null)
|
||||
{
|
||||
_logger.Warning("Null mail passed to DeleteMailAsync call.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Deleting mail {Id} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
|
||||
|
||||
await Connection.DeleteAsync(mailCopy).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.Information("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.Information("Skipped updating mail because it is already in the desired state.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task ChangeReadStatusAsync(string mailCopyId, bool isRead)
|
||||
=> UpdateAllMailCopiesAsync(mailCopyId, (item) =>
|
||||
{
|
||||
item.IsRead = isRead;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
public Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged)
|
||||
=> UpdateAllMailCopiesAsync(mailCopyId, (item) =>
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// 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).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.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;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MimeMessage> CreateDraftMimeMessageAsync(Guid accountId, 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 messageUniqueId = Guid.NewGuid();
|
||||
|
||||
var message = new MimeMessage()
|
||||
{
|
||||
Headers = { { Constants.WinoLocalDraftHeader, messageUniqueId.ToString() } }
|
||||
};
|
||||
|
||||
var builder = new BodyBuilder();
|
||||
|
||||
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
|
||||
|
||||
if (account == null)
|
||||
{
|
||||
_logger.Warning("Can't create draft mime message because account {AccountId} does not exist.", accountId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var reason = draftCreationOptions.Reason;
|
||||
var referenceMessage = draftCreationOptions.ReferenceMimeMessage;
|
||||
|
||||
// For API synchronizers we should get this from contacts.
|
||||
if (account.ServerInformation == null)
|
||||
{
|
||||
var fromContact = await _contactService.GetAddressInformationByAddressAsync(account.Address).ConfigureAwait(false)
|
||||
?? new AddressInformation() { Name = account.Address, Address = account.Address };
|
||||
|
||||
message.From.Add(new MailboxAddress(fromContact.Name, fromContact.Address));
|
||||
}
|
||||
else
|
||||
{
|
||||
// For IMAP synchronizer, we have already Display Name in the settings.
|
||||
message.From.Add(new MailboxAddress(account.ServerInformation.DisplayName, account.ServerInformation.Address));
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Find self and remove
|
||||
var self = message.To.FirstOrDefault(a => a is MailboxAddress mailboxAddress && mailboxAddress.Address == account.Address);
|
||||
|
||||
if (self != null)
|
||||
message.To.Remove(self);
|
||||
|
||||
message.Cc.AddRange(referenceMessage.Cc);
|
||||
}
|
||||
|
||||
// Manage "ThreadId-ConversationId"
|
||||
if (!string.IsNullOrEmpty(referenceMessage.MessageId))
|
||||
{
|
||||
message.InReplyTo = referenceMessage.MessageId;
|
||||
|
||||
foreach (var id in referenceMessage.References)
|
||||
message.References.Add(id);
|
||||
|
||||
message.References.Add(referenceMessage.MessageId);
|
||||
}
|
||||
|
||||
message.Headers.Add("Thread-Topic", referenceMessage.Subject);
|
||||
}
|
||||
|
||||
var previewer = new HtmlTextPreviewer();
|
||||
|
||||
if (reason == DraftCreationReason.Forward)
|
||||
{
|
||||
var visitor = _mimeFileService.CreateHTMLPreviewVisitor(referenceMessage, string.Empty);
|
||||
visitor.Visit(referenceMessage);
|
||||
|
||||
builder.HtmlBody = visitor.HtmlBody;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add signature if any.
|
||||
var accountSignature = await _signatureService.GetAccountSignatureAsync(account.Id);
|
||||
|
||||
if (accountSignature != null)
|
||||
{
|
||||
// Leave some space for new mail content.
|
||||
|
||||
builder.HtmlBody = @$"<html><br><br>{accountSignature.HtmlBody}</html>";
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(builder.HtmlBody))
|
||||
{
|
||||
builder.TextBody = HtmlAgilityPackExtensions.GetPreviewText(builder.HtmlBody);
|
||||
}
|
||||
|
||||
message.Body = builder.ToMessageBody();
|
||||
|
||||
// Apply mail-to protocol parameters if exists.
|
||||
|
||||
if (draftCreationOptions.MailtoParameters != null)
|
||||
{
|
||||
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoSubjectParameterKey, out string subjectParameter))
|
||||
message.Subject = subjectParameter;
|
||||
|
||||
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoBodyParameterKey, out string bodyParameter))
|
||||
{
|
||||
builder.TextBody = bodyParameter;
|
||||
builder.HtmlBody = bodyParameter;
|
||||
|
||||
message.Body = builder.ToMessageBody();
|
||||
}
|
||||
|
||||
InternetAddressList ExtractRecipients(string parameterValue)
|
||||
{
|
||||
var list = new InternetAddressList();
|
||||
|
||||
var splittedRecipients = parameterValue.Split(',');
|
||||
|
||||
foreach (var recipient in splittedRecipients)
|
||||
list.Add(new MailboxAddress(recipient, recipient));
|
||||
|
||||
return list;
|
||||
|
||||
}
|
||||
|
||||
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoToParameterKey, out string toParameter))
|
||||
message.To.AddRange(ExtractRecipients(toParameter));
|
||||
|
||||
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoCCParameterKey, out string ccParameter))
|
||||
message.Cc.AddRange(ExtractRecipients(ccParameter));
|
||||
|
||||
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoBCCParameterKey, out string bccParameter))
|
||||
message.Bcc.AddRange(ExtractRecipients(bccParameter));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update TextBody from existing HtmlBody if exists.
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user