Cleaning up the solution. Separating Shared.WinRT, Services and Synchronization. Removing synchronization from app. Reducing bundle size by 45mb.

This commit is contained in:
Burak Kaan Köse
2024-07-21 05:45:02 +02:00
parent f112f369a7
commit 495885e006
523 changed files with 2254 additions and 2375 deletions

View File

@@ -0,0 +1,22 @@
using System.Threading.Tasks;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
namespace Wino.Services.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);
}
}

View File

@@ -0,0 +1,216 @@
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Wino.Domain;
using Wino.Domain.Exceptions;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Exceptions;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Authentication;
using Wino.Domain.Models.Authorization;
namespace Wino.Services.Authenticators
{
public class GmailAuthenticator : BaseAuthenticator, IGmailAuthenticator
{
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 = JsonNode.Parse(responseString).AsObject();
if (parsed.ContainsKey("error"))
throw new GoogleAuthenticationException(parsed["error"]["message"].GetValue<string>());
var accessToken = parsed["access_token"].GetValue<string>();
var refreshToken = parsed["refresh_token"].GetValue<string>();
var expiresIn = parsed["expires_in"].GetValue<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 = JsonNode.Parse(userinfoResponseContent).AsObject();
if (parsedUserInfo.ContainsKey("error"))
throw new GoogleAuthenticationException(parsedUserInfo["error"]["message"].GetValue<string>());
var username = parsedUserInfo["emailAddress"].GetValue<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 _nativeAppService.LaunchUriAsync(new Uri(authorizationUri));
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 = JsonNode.Parse(responseString).AsObject();
// TODO: Error parsing is incorrect.
if (parsed.ContainsKey("error"))
throw new GoogleAuthenticationException(parsed["error_description"].GetValue<string>());
var accessToken = parsed["access_token"].GetValue<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"].GetValue<string>();
}
var expiresIn = parsed["expires_in"].GetValue<long>();
var expirationDate = DateTime.UtcNow.AddSeconds(expiresIn);
return new TokenInformationBase()
{
AccessToken = accessToken,
ExpiresAt = expirationDate,
RefreshToken = activeRefreshToken
};
}
public void CancelAuthorization() => _authorizationCancellationTokenSource?.Cancel();
}
}

View File

@@ -0,0 +1,12 @@
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
namespace Wino.Services.Authenticators
{
public class Office365Authenticator : OutlookAuthenticator
{
public Office365Authenticator(ITokenService tokenService, INativeAppService nativeAppService) : base(tokenService, nativeAppService) { }
public override MailProviderType ProviderType => MailProviderType.Office365;
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Identity.Client;
using Wino.Domain;
using Wino.Domain.Exceptions;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Services.Extensions;
namespace Wino.Services.Authenticators
{
public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
{
// 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();
var outlookAppBuilder = PublicClientApplicationBuilder.Create(ClientId)
.WithAuthority(Authority);
#if WINDOWS_UWP
outlookAppBuilder.WithRedirectUri(authenticationRedirectUri);
#else
outlookAppBuilder.WithDefaultRedirectUri();
#endif
_publicClientApplication = outlookAppBuilder.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));
}
}
}

View File

@@ -0,0 +1,120 @@
using System;
using System.IO;
using System.Linq;
using HtmlAgilityPack;
namespace Wino.Services.Extensions
{
public static class HtmlAgilityPackExtensions
{
/// <summary>
/// Clears out the src attribute for all `img` and `v:fill` tags.
/// </summary>
/// <param name="document"></param>
public static void ClearImages(this HtmlDocument document)
{
if (document.DocumentNode.InnerHtml.Contains("<img"))
{
foreach (var eachNode in document.DocumentNode.SelectNodes("//img"))
{
eachNode.Attributes.Remove("src");
}
}
}
/// <summary>
/// Removes `style` tags from the document.
/// </summary>
/// <param name="document"></param>
public static void ClearStyles(this HtmlDocument document)
{
document.DocumentNode
.Descendants()
.Where(n => n.Name.Equals("script", StringComparison.OrdinalIgnoreCase)
|| n.Name.Equals("style", StringComparison.OrdinalIgnoreCase)
|| n.Name.Equals("#comment", StringComparison.OrdinalIgnoreCase))
.ToList()
.ForEach(n => n.Remove());
}
/// <summary>
/// Returns plain text from the HTML content.
/// </summary>
/// <param name="htmlContent">Content to get preview from.</param>
/// <returns>Text body for the html.</returns>
public static string GetPreviewText(string htmlContent)
{
if (string.IsNullOrEmpty(htmlContent)) return string.Empty;
HtmlDocument doc = new HtmlDocument();
doc.LoadHtml(htmlContent);
StringWriter sw = new StringWriter();
ConvertTo(doc.DocumentNode, sw);
sw.Flush();
return sw.ToString().Replace(Environment.NewLine, "");
}
private static void ConvertContentTo(HtmlNode node, TextWriter outText)
{
foreach (HtmlNode subnode in node.ChildNodes)
{
ConvertTo(subnode, outText);
}
}
private static void ConvertTo(HtmlNode node, TextWriter outText)
{
string html;
switch (node.NodeType)
{
case HtmlNodeType.Comment:
// don't output comments
break;
case HtmlNodeType.Document:
ConvertContentTo(node, outText);
break;
case HtmlNodeType.Text:
// script and style must not be output
string parentName = node.ParentNode.Name;
if (parentName == "script" || parentName == "style")
break;
// get text
html = ((HtmlTextNode)node).Text;
// is it in fact a special closing node output as text?
if (HtmlNode.IsOverlappedClosingElement(html))
break;
// check the text is meaningful and not a bunch of whitespaces
if (html.Trim().Length > 0)
{
outText.Write(HtmlEntity.DeEntitize(html));
}
break;
case HtmlNodeType.Element:
switch (node.Name)
{
case "p":
// treat paragraphs as crlf
outText.Write("\r\n");
break;
case "br":
outText.Write("\r\n");
break;
}
if (node.HasChildNodes)
{
ConvertContentTo(node, outText);
}
break;
}
}
}
}

View File

@@ -0,0 +1,15 @@
using SqlKata;
using SqlKata.Compilers;
namespace Wino.Services.Extensions
{
public static class SqlKataExtensions
{
private static SqliteCompiler Compiler = new SqliteCompiler();
public static string GetRawQuery(this Query query)
{
return Compiler.Compile(query).ToString();
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using Microsoft.Identity.Client;
using Wino.Domain.Entities;
namespace Wino.Services.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;
}
}
}

View File

@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MimeKit;
using Wino.Domain.Entities;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Synchronization;
using Wino.Services.Services;
namespace Wino.Services.Processors
{
public class DefaultChangeProcessor(IDatabaseService databaseService,
IFolderService folderService,
IMailService mailService,
IAccountService accountService,
IMimeFileService mimeFileService) : BaseDatabaseService(databaseService), IDefaultChangeProcessor
{
protected IMailService MailService = mailService;
protected IFolderService FolderService = folderService;
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);
public Task<List<MailItemFolder>> GetExistingFoldersAsync(Guid accountId)
=> FolderService.GetFoldersAsync(accountId);
public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
=> MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId);
public Task<List<MailItemFolder>> GetLocalFoldersAsync(Guid accountId)
=> FolderService.GetFoldersAsync(accountId);
public Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options)
=> FolderService.GetSynchronizationFoldersAsync(options);
public Task DeleteFolderAsync(Guid accountId, string remoteFolderId)
=> FolderService.DeleteFolderAsync(accountId, remoteFolderId);
public Task InsertFolderAsync(MailItemFolder folder)
=> FolderService.InsertFolderAsync(folder);
public Task UpdateFolderAsync(MailItemFolder folder)
=> FolderService.UpdateFolderAsync(folder);
public Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds)
=> MailService.GetDownloadedUnreadMailsAsync(accountId, downloadedMailCopyIds);
public Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId)
=> _mimeFileService.SaveMimeMessageAsync(fileId, mimeMessage, accountId);
public Task UpdateFolderLastSyncDateAsync(Guid folderId)
=> FolderService.UpdateFolderLastSyncDateAsync(folderId);
}
}

View File

@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using Wino.Domain.Interfaces;
namespace Wino.Services.Processors
{
public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcessor
{
public GmailChangeProcessor(IDatabaseService databaseService, IFolderService folderService, IMailService mailService, IAccountService accountService, IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, accountService, mimeFileService)
{
}
public Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId)
=> MailService.MapLocalDraftAsync(mailCopyId, newDraftId, newThreadId);
}
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wino.Domain.Interfaces;
namespace Wino.Services.Processors
{
public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor
{
public ImapChangeProcessor(IDatabaseService databaseService,
IFolderService folderService,
IMailService mailService,
IAccountService accountService,
IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, accountService, mimeFileService)
{
}
public Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId) => FolderService.GetKnownUidsForFolderAsync(folderId);
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Threading.Tasks;
using Wino.Domain.Interfaces;
namespace Wino.Services.Processors
{
public class OutlookChangeProcessor(IDatabaseService databaseService,
IFolderService folderService,
IMailService mailService,
IAccountService accountService,
IMimeFileService mimeFileService) : DefaultChangeProcessor(databaseService, folderService, mailService, accountService, mimeFileService), IOutlookChangeProcessor
{
public Task<bool> IsMailExistsAsync(string messageId)
=> MailService.IsMailExistsAsync(messageId);
public Task UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string synchronizationIdentifier)
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET DeltaToken = ? WHERE Id = ?", synchronizationIdentifier, folderId);
}
}

View File

@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Wino.Domain;
using Wino.Domain.Exceptions;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Folders;
using Wino.Domain.Models.MailItem;
using Wino.Services.Requests;
using Wino.Services.Services;
namespace Wino.Services.Processors
{
/// <summary>
/// Intermediary processor for converting a user action to executable Wino requests.
/// Primarily responsible for batching requests by AccountId and FolderId.
/// </summary>
public class WinoRequestProcessor : BaseDatabaseService, IWinoRequestProcessor
{
private readonly IFolderService _folderService;
private readonly IKeyPressService _keyPressService;
private readonly IPreferencesService _preferencesService;
private readonly IAccountService _accountService;
private readonly IDialogService _dialogService;
private readonly IMailService _mailService;
/// <summary>
/// Set of rules that defines which action should be executed if user wants to toggle an action.
/// </summary>
private readonly List<ToggleRequestRule> _toggleRequestRules =
[
new ToggleRequestRule(MailOperation.MarkAsRead, MailOperation.MarkAsUnread, new Func<IMailItem, bool>((item) => item.IsRead)),
new ToggleRequestRule(MailOperation.MarkAsUnread, MailOperation.MarkAsRead, new Func<IMailItem, bool>((item) => !item.IsRead)),
new ToggleRequestRule(MailOperation.SetFlag, MailOperation.ClearFlag, new Func<IMailItem, bool>((item) => item.IsFlagged)),
new ToggleRequestRule(MailOperation.ClearFlag, MailOperation.SetFlag, new Func<IMailItem, bool>((item) => !item.IsFlagged)),
];
public WinoRequestProcessor(IDatabaseService databaseService,
IFolderService folderService,
IKeyPressService keyPressService,
IPreferencesService preferencesService,
IAccountService accountService,
IDialogService dialogService,
IMailService mailService) : base(databaseService)
{
_folderService = folderService;
_keyPressService = keyPressService;
_preferencesService = preferencesService;
_accountService = accountService;
_dialogService = dialogService;
_mailService = mailService;
}
public async Task<List<IRequest>> PrepareRequestsAsync(MailOperationPreperationRequest preperationRequest)
{
var action = preperationRequest.Action;
var moveTargetStructure = preperationRequest.MoveTargetFolder;
// Ask confirmation for permanent delete operation.
// Drafts are always hard deleted without any protection.
if (!preperationRequest.IgnoreHardDeleteProtection && (action == MailOperation.SoftDelete && _keyPressService.IsShiftKeyPressed() || action == MailOperation.HardDelete))
{
if (_preferencesService.IsHardDeleteProtectionEnabled)
{
var shouldDelete = await _dialogService.ShowHardDeleteConfirmationAsync();
if (!shouldDelete) return default;
}
action = MailOperation.HardDelete;
}
// Make sure there is a move target folder if action is move.
// Let user pick a folder to move from the dialog.
if (action == MailOperation.Move && moveTargetStructure == null)
{
// TODO: Handle multiple accounts for move operation.
// What happens if we move 2 different mails from 2 different accounts?
var accountId = preperationRequest.MailItems.FirstOrDefault().AssignedAccount.Id;
moveTargetStructure = await _dialogService.PickFolderAsync(accountId, PickFolderReason.Move, _folderService);
if (moveTargetStructure == null)
return default;
}
var requests = new List<IRequest>();
foreach (var item in preperationRequest.MailItems)
{
var singleRequest = await GetSingleRequestAsync(item, action, moveTargetStructure, preperationRequest.ToggleExecution);
if (singleRequest == null) continue;
requests.Add(singleRequest);
}
return requests;
}
private async Task<IRequest> GetSingleRequestAsync(MailCopy mailItem, MailOperation action, IMailItemFolder moveTargetStructure, bool shouldToggleActions)
{
if (mailItem.AssignedAccount == null) throw new ArgumentException(Translator.Exception_NullAssignedAccount);
if (mailItem.AssignedFolder == null) throw new ArgumentException(Translator.Exception_NullAssignedFolder);
// Rule: Soft deletes from Trash folder must perform Hard Delete.
if (action == MailOperation.SoftDelete && mailItem.AssignedFolder.SpecialFolderType == SpecialFolderType.Deleted)
action = MailOperation.HardDelete;
// Rule: SoftDelete draft items must be performed as hard delete.
if (action == MailOperation.SoftDelete && mailItem.IsDraft)
action = MailOperation.HardDelete;
// Rule: Soft/Hard deletes on local drafts are always discard local draft.
if ((action == MailOperation.SoftDelete || action == MailOperation.HardDelete) && mailItem.IsLocalDraft)
action = MailOperation.DiscardLocalDraft;
// Rule: Toggle actions must be reverted if ToggleExecution is passed true.
if (shouldToggleActions)
{
var toggleRule = _toggleRequestRules.Find(a => a.SourceAction == action);
if (toggleRule != null && toggleRule.Condition(mailItem))
{
action = toggleRule.TargetAction;
}
}
if (action == MailOperation.MarkAsRead)
return new MarkReadRequest(mailItem, true);
else if (action == MailOperation.MarkAsUnread)
return new MarkReadRequest(mailItem, false);
else if (action == MailOperation.SetFlag)
return new ChangeFlagRequest(mailItem, true);
else if (action == MailOperation.ClearFlag)
return new ChangeFlagRequest(mailItem, false);
else if (action == MailOperation.HardDelete)
return new DeleteRequest(mailItem);
else if (action == MailOperation.Move)
{
if (moveTargetStructure == null)
throw new InvalidMoveTargetException();
// TODO
// Rule: You can't move items to non-move target folders;
// Rule: You can't move items from a folder to itself.
//if (!moveTargetStructure.IsMoveTarget || moveTargetStructure.FolderId == mailItem.AssignedFolder.Id)
// throw new InvalidMoveTargetException();
var pickedFolderItem = await _folderService.GetFolderAsync(moveTargetStructure.Id);
return new MoveRequest(mailItem, mailItem.AssignedFolder, pickedFolderItem);
}
else if (action == MailOperation.Archive)
{
// For IMAP and Outlook: Validate archive folder exists.
// Gmail doesn't need archive folder existence.
MailItemFolder archiveFolder = null;
bool shouldRequireArchiveFolder = mailItem.AssignedAccount.ProviderType == MailProviderType.Outlook
|| mailItem.AssignedAccount.ProviderType == MailProviderType.IMAP4
|| mailItem.AssignedAccount.ProviderType == MailProviderType.Office365;
if (shouldRequireArchiveFolder)
{
archiveFolder = await _folderService.GetSpecialFolderByAccountIdAsync(mailItem.AssignedAccount.Id, SpecialFolderType.Archive)
?? throw new UnavailableSpecialFolderException(SpecialFolderType.Archive, mailItem.AssignedAccount.Id);
}
return new ArchiveRequest(true, mailItem, mailItem.AssignedFolder, archiveFolder);
}
else if (action == MailOperation.MarkAsNotJunk)
{
var inboxFolder = await _folderService.GetSpecialFolderByAccountIdAsync(mailItem.AssignedAccount.Id, SpecialFolderType.Inbox)
?? throw new UnavailableSpecialFolderException(SpecialFolderType.Inbox, mailItem.AssignedAccount.Id);
return new MoveRequest(mailItem, mailItem.AssignedFolder, inboxFolder);
}
else if (action == MailOperation.UnArchive)
{
var inboxFolder = await _folderService.GetSpecialFolderByAccountIdAsync(mailItem.AssignedAccount.Id, SpecialFolderType.Inbox)
?? throw new UnavailableSpecialFolderException(SpecialFolderType.Inbox, mailItem.AssignedAccount.Id);
return new ArchiveRequest(false, mailItem, mailItem.AssignedFolder, inboxFolder);
}
else if (action == MailOperation.SoftDelete)
{
var trashFolder = await _folderService.GetSpecialFolderByAccountIdAsync(mailItem.AssignedAccount.Id, SpecialFolderType.Deleted)
?? throw new UnavailableSpecialFolderException(SpecialFolderType.Deleted, mailItem.AssignedAccount.Id);
return new MoveRequest(mailItem, mailItem.AssignedFolder, trashFolder);
}
else if (action == MailOperation.MoveToJunk)
{
var junkFolder = await _folderService.GetSpecialFolderByAccountIdAsync(mailItem.AssignedAccount.Id, SpecialFolderType.Junk)
?? throw new UnavailableSpecialFolderException(SpecialFolderType.Junk, mailItem.AssignedAccount.Id);
return new MoveRequest(mailItem, mailItem.AssignedFolder, junkFolder);
}
else if (action == MailOperation.AlwaysMoveToFocused || action == MailOperation.AlwaysMoveToOther)
return new AlwaysMoveToRequest(mailItem, action == MailOperation.AlwaysMoveToFocused);
else if (action == MailOperation.DiscardLocalDraft)
await _mailService.DeleteMailAsync(mailItem.AssignedAccount.Id, mailItem.Id);
else
throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedAction, action));
return null;
}
public async Task<IRequestBase> PrepareFolderRequestAsync(FolderOperationPreperationRequest request)
{
if (request == null || request.Folder == null) return default;
IRequestBase change = null;
var folder = request.Folder;
var operation = request.Action;
switch (request.Action)
{
case FolderOperation.Pin:
case FolderOperation.Unpin:
await _folderService.ChangeStickyStatusAsync(folder.Id, operation == FolderOperation.Pin);
break;
case FolderOperation.Rename:
var newFolderName = await _dialogService.ShowTextInputDialogAsync(folder.FolderName, Translator.DialogMessage_RenameFolderTitle, Translator.DialogMessage_RenameFolderMessage, Translator.FolderOperation_Rename);
if (!string.IsNullOrEmpty(newFolderName))
{
change = new RenameFolderRequest(folder, folder.FolderName, newFolderName);
}
break;
case FolderOperation.Empty:
var mailsToDelete = await _mailService.GetMailsByFolderIdAsync(folder.Id).ConfigureAwait(false);
change = new EmptyFolderRequest(folder, mailsToDelete);
break;
case FolderOperation.MarkAllAsRead:
var unreadItems = await _mailService.GetUnreadMailsByFolderIdAsync(folder.Id).ConfigureAwait(false);
if (unreadItems.Any())
change = new MarkFolderAsReadRequest(folder, unreadItems);
break;
//case FolderOperation.Delete:
// var isConfirmed = await _dialogService.ShowConfirmationDialogAsync($"'{folderStructure.FolderName}' is going to be deleted. Do you want to continue?", "Are you sure?", "Yes delete.");
// if (isConfirmed)
// change = new DeleteFolderRequest(accountId, folderStructure.RemoteFolderId, folderStructure.FolderId);
// break;
//default:
// throw new NotImplementedException();
}
return change;
}
}
}

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.ComponentModel;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Requests;
namespace Wino.Services.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()
{
}
}
}

View File

@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.Requests
{
/// <summary>
/// Archive message request.
/// By default, the message will be moved to the Archive folder.
/// For Gmail, 'Archive' label will be removed from the message.
/// </summary>
/// <param name="IsArchiving">Whether are archiving or unarchiving</param>
/// <param name="Item">Mail to archive</param>
/// <param name="FromFolder">Source folder.</param>
/// <param name="ToFolder">Optional Target folder. Required for ImapSynchronizer and OutlookSynchronizer.</param>
public record ArchiveRequest(bool IsArchiving, MailCopy Item, MailItemFolder FromFolder, MailItemFolder ToFolder = null) : RequestBase<BatchArchiveRequest>(Item, MailSynchronizerOperation.Archive), ICustomFolderSynchronizationRequest
{
public List<Guid> SynchronizationFolderIds
{
get
{
var folderIds = new List<Guid> { FromFolder.Id };
if (ToFolder != null)
{
folderIds.Add(ToFolder.Id);
}
return folderIds;
}
}
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
=> new BatchArchiveRequest(IsArchiving, 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 BatchArchiveRequest(bool IsArchiving, IEnumerable<IRequest> Items, MailItemFolder FromFolder, MailItemFolder ToFolder = null) : BatchRequestBase(Items, MailSynchronizerOperation.Archive)
{
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)));
}
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Wino.Domain.Interfaces;
namespace Wino.Services.Requests.Bundles
{
/// <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 JsonSerializer.Deserialize<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.";
}
}
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Threading.Tasks;
using MailKit.Net.Imap;
using Wino.Domain.Interfaces;
namespace Wino.Services.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();
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.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));
});
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.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)));
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.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)));
}
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.Requests
{
public record EmptyFolderRequest(MailItemFolder Folder, List<MailCopy> MailsToDelete) : FolderRequestBase(Folder, MailSynchronizerOperation.EmptyFolder), ICustomFolderSynchronizationRequest
{
public override void ApplyUIChanges()
{
foreach (var item in MailsToDelete)
{
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item));
}
}
public override void RevertUIChanges()
{
foreach (var item in MailsToDelete)
{
WeakReferenceMessenger.Default.Send(new MailAddedMessage(item));
}
}
public List<Guid> SynchronizationFolderIds => [Folder.Id];
}
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.Requests
{
public record MarkFolderAsReadRequest(MailItemFolder Folder, List<MailCopy> MailsToMarkRead) : FolderRequestBase(Folder, MailSynchronizerOperation.MarkFolderRead), ICustomFolderSynchronizationRequest
{
public override void ApplyUIChanges()
{
foreach (var item in MailsToMarkRead)
{
item.IsRead = true;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item));
}
}
public override void RevertUIChanges()
{
foreach (var item in MailsToMarkRead)
{
item.IsRead = false;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item));
}
}
public override bool DelayExecution => false;
public List<Guid> SynchronizationFolderIds => [Folder.Id];
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.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));
});
}
}
}

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.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)));
}
}
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.ComponentModel;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Requests;
namespace Wino.Services.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() { }
}
}

View File

@@ -0,0 +1,23 @@
using CommunityToolkit.Mvvm.Messaging;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.Requests
{
public record RenameFolderRequest(MailItemFolder Folder, string CurrentFolderName, string NewFolderName) : FolderRequestBase(Folder, MailSynchronizerOperation.RenameFolder)
{
public override void ApplyUIChanges()
{
Folder.FolderName = NewFolderName;
WeakReferenceMessenger.Default.Send(new FolderRenamed(Folder));
}
public override void RevertUIChanges()
{
Folder.FolderName = CurrentFolderName;
WeakReferenceMessenger.Default.Send(new FolderRenamed(Folder));
}
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Requests;
using Wino.Messaging.Server;
namespace Wino.Services.Requests
{
public record SendDraftRequest(SendDraftPreparationRequest Request)
: RequestBase<BatchMarkReadRequest>(Request.MailItem, MailSynchronizerOperation.Send),
ICustomFolderSynchronizationRequest
{
public List<Guid> SynchronizationFolderIds
{
get
{
var folderIds = new List<Guid> { Request.DraftFolder.Id };
if (Request.SentFolder != null)
{
folderIds.Add(Request.SentFolder.Id);
}
return folderIds;
}
}
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)));
}
public override bool DelayExecution => true;
}
}

View File

@@ -0,0 +1,438 @@
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.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Messaging.Client.Accounts;
using Wino.Messaging.Server;
using Wino.Services.Extensions;
namespace Wino.Services.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>().OrderBy(a => a.Order).ToListAsync();
foreach (var account in accounts)
{
// Load IMAP server configuration.
if (account.ProviderType == MailProviderType.IMAP4)
account.ServerInformation = await GetAccountCustomServerInformationAsync(account.Id);
// Load MergedInbox information.
if (account.MergedInboxId != null)
account.MergedInbox = await GetMergedInboxInformationAsync(account.MergedInboxId.Value);
account.Preferences = await GetAccountPreferencesAsync(account.Id);
}
return accounts;
}
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);
await Connection.Table<AccountSignature>().DeleteAsync(a => a.MailAccountId == account.Id);
// Account belongs to a merged inbox.
// In case of there'll be a single account in the merged inbox, remove the merged inbox as well.
if (account.MergedInboxId != null)
{
var mergedInboxAccountCount = await Connection.Table<MailAccount>().Where(a => a.MergedInboxId == account.MergedInboxId.Value).CountAsync();
// There will be only one account in the merged inbox. Remove the link for the other account as well.
if (mergedInboxAccountCount == 2)
{
var query = new Query("MailAccount")
.Where("MergedInboxId", account.MergedInboxId.Value)
.AsUpdate(new
{
MergedInboxId = (Guid?)null
});
await Connection.ExecuteAsync(query.GetRawQuery()).ConfigureAwait(false);
}
}
if (account.ProviderType == MailProviderType.IMAP4)
await Connection.Table<CustomServerInformation>().DeleteAsync(a => a.AccountId == account.Id);
if (account.Preferences != null)
await Connection.DeleteAsync(account.Preferences);
await Connection.DeleteAsync(account);
// Clear out or set up a new startup entity id.
// Next account after the deleted one will be the startup account.
if (_preferencesService.StartupEntityId == account.Id || _preferencesService.StartupEntityId == account.MergedInboxId)
{
var firstNonStartupAccount = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id != account.Id);
if (firstNonStartupAccount != null)
{
_preferencesService.StartupEntityId = firstNonStartupAccount.Id;
}
else
{
_preferencesService.StartupEntityId = null;
}
}
ReportUIChange(new AccountRemovedMessage(account));
}
public async Task<MailAccount> GetAccountAsync(Guid accountId)
{
var account = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id == accountId);
if (account == null)
{
_logger.Error("Could not find account with id {AccountId}", accountId);
}
else
{
if (account.ProviderType == MailProviderType.IMAP4)
account.ServerInformation = await GetAccountCustomServerInformationAsync(account.Id);
account.Preferences = await GetAccountPreferencesAsync(account.Id);
return account;
}
return null;
}
public Task<CustomServerInformation> GetAccountCustomServerInformationAsync(Guid accountId)
=> Connection.Table<CustomServerInformation>().FirstOrDefaultAsync(a => a.AccountId == accountId);
public async Task UpdateAccountAsync(MailAccount account)
{
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;
}
else
{
// Set the order of the account.
// This can be changed by the user later in manage accounts page.
account.Order = accountCount;
}
await Connection.InsertAsync(account);
var preferences = new MailAccountPreferences()
{
Id = Guid.NewGuid(),
AccountId = account.Id,
IsNotificationsEnabled = true,
ShouldAppendMessagesToSentFolder = false
};
account.Preferences = preferences;
// Outlook & Office 365 supports Focused inbox. Enabled by default.
bool isMicrosoftProvider = account.ProviderType == MailProviderType.Outlook || account.ProviderType == MailProviderType.Office365;
// TODO: This should come from account settings API.
// Wino doesn't have MailboxSettings yet.
if (isMicrosoftProvider)
account.Preferences.IsFocusedInboxEnabled = true;
// Setup default signature.
var defaultSignature = await _signatureService.CreateDefaultSignatureAsync(account.Id);
account.Preferences.SignatureIdForNewMessages = defaultSignature.Id;
account.Preferences.SignatureIdForFollowingMessages = defaultSignature.Id;
account.Preferences.IsSignatureEnabled = true;
await Connection.InsertAsync(preferences);
if (customServerInformation != null)
await Connection.InsertAsync(customServerInformation);
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;
}
public async Task UpdateAccountOrdersAsync(Dictionary<Guid, int> accountIdOrderPair)
{
foreach (var pair in accountIdOrderPair)
{
var account = await GetAccountAsync(pair.Key);
if (account == null)
{
_logger.Information("Could not find account with id {Key} for reordering. It may be a linked account.", pair.Key);
continue;
}
account.Order = pair.Value;
await Connection.UpdateAsync(account);
}
Messenger.Send(new AccountMenuItemsReordered(accountIdOrderPair));
}
}
}

View File

@@ -0,0 +1,13 @@
using Wino.Domain.Interfaces;
namespace Wino.Services.Services
{
public class ApplicationConfiguration : IApplicationConfiguration
{
public const string SharedFolderName = "WinoShared";
public string ApplicationDataFolderPath { get; set; }
public string PublisherSharedFolderPath { get; set; }
}
}

View File

@@ -0,0 +1,38 @@
using System;
using Wino.Domain;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using IAuthenticationProvider = Wino.Domain.Interfaces.IAuthenticationProvider;
namespace Wino.Services.Services
{
public class AuthenticationProvider : IAuthenticationProvider
{
private readonly INativeAppService _nativeAppService;
private readonly ITokenService _tokenService;
private readonly IOutlookAuthenticator _outlookAuthenticator;
private readonly IGmailAuthenticator _gmailAuthenticator;
public AuthenticationProvider(INativeAppService nativeAppService,
ITokenService tokenService,
IOutlookAuthenticator outlookAuthenticator,
IGmailAuthenticator gmailAuthenticator)
{
_nativeAppService = nativeAppService;
_tokenService = tokenService;
_outlookAuthenticator = outlookAuthenticator;
_gmailAuthenticator = gmailAuthenticator;
}
public IAuthenticator GetAuthenticator(MailProviderType providerType)
{
return providerType switch
{
MailProviderType.Outlook => _outlookAuthenticator,
MailProviderType.Office365 => _outlookAuthenticator,
MailProviderType.Gmail => _gmailAuthenticator,
_ => throw new ArgumentException(Translator.Exception_UnsupportedProvider),
};
}
}
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Serilog;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.AutoDiscovery;
namespace Wino.Services.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 JsonSerializer.Deserialize<AutoDiscoverySettings>(content);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to deserialize Firetrust response.");
}
return null;
}
}
}

View File

@@ -0,0 +1,22 @@
using CommunityToolkit.Mvvm.Messaging;
using SQLite;
using Wino.Domain.Interfaces;
namespace Wino.Services.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, IServerMessage
=> Messenger.Send(message);
}
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MimeKit;
using SqlKata;
using Wino.Domain.Entities;
using Wino.Domain.Interfaces;
using Wino.Services.Extensions;
namespace Wino.Services.Services
{
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);
}
}
}

View File

@@ -0,0 +1,182 @@
using System.Collections.Generic;
using System.Linq;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Folders;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Menus;
namespace Wino.Services.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;
}
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.IO;
using System.Threading.Tasks;
using SQLite;
using Wino.Domain.Entities;
using Wino.Domain.Interfaces;
namespace Wino.Services.Services
{
public class DatabaseService : IDatabaseService
{
private const string DatabaseName = "Wino172.db";
private bool _isInitialized = false;
private readonly IApplicationConfiguration _folderConfiguration;
public SQLiteAsyncConnection Connection { get; private set; }
public DatabaseService(IApplicationConfiguration folderConfiguration)
{
_folderConfiguration = folderConfiguration;
}
public async Task InitializeAsync()
{
if (_isInitialized)
return;
var publisherCacheFolder = _folderConfiguration.PublisherSharedFolderPath;
var databaseFileName = Path.Combine(publisherCacheFolder, DatabaseName);
Connection = new SQLiteAsyncConnection(databaseFileName)
{
// 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)
);
}
}
}

View File

@@ -0,0 +1,531 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Serilog;
using SqlKata;
using Wino.Domain;
using Wino.Domain;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Extensions;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Accounts;
using Wino.Domain.Models.Folders;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Synchronization;
using Wino.Messaging.Server;
using Wino.Services.Extensions;
namespace Wino.Services.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)
{
var query = new Query(nameof(MailItemFolder))
.Where(nameof(MailItemFolder.MailAccountId), accountId)
.OrderBy(nameof(MailItemFolder.SpecialFolderType));
return Connection.QueryAsync<MailItemFolder>(query.GetRawQuery());
}
public Task<List<MailItemFolder>> GetVisibleFoldersAsync(Guid accountId)
{
var query = new Query(nameof(MailItemFolder))
.Where(nameof(MailItemFolder.MailAccountId), accountId)
.Where(nameof(MailItemFolder.IsHidden), false)
.OrderBy(nameof(MailItemFolder.SpecialFolderType));
return Connection.QueryAsync<MailItemFolder>(query.GetRawQuery());
}
public async Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
{
var folder = await GetFolderAsync(folderId);
if (folder == null) return default;
var mailCopyIds = await GetMailCopyIdsByFolderIdAsync(folderId);
// Make sure we don't include Ids that doesn't have uid separator.
// Local drafts might not have it for example.
return new List<uint>(mailCopyIds.Where(a => a.Contains(Constants.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),
UpdateSystemFolderInternalAsync(configuration.ArchiveFolder, SpecialFolderType.Archive));
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);
Messenger.Send(new FolderSynchronizationEnabled(localFolder));
}
}
#region Repository Calls
public async Task InsertFolderAsync(MailItemFolder folder)
{
if (folder == null)
{
_logger.Warning("Folder is null. Cannot insert.");
return;
}
var account = await _accountService.GetAccountAsync(folder.MailAccountId);
if (account == null)
{
_logger.Warning("Account with id {MailAccountId} does not exist. Cannot insert folder.", folder.MailAccountId);
return;
}
var existingFolder = await GetFolderAsync(folder.Id).ConfigureAwait(false);
// IMAP servers don't have unique identifier for folders all the time.
// So we'll try to match them with remote folder id and account id relation.
// If we have a match, we'll update the folder instead of inserting.
existingFolder ??= await GetFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
if (existingFolder == null)
{
_logger.Debug("Inserting folder {Id} - {FolderName}", folder.Id, folder.FolderName, folder.MailAccountId);
await Connection.InsertAsync(folder).ConfigureAwait(false);
}
else
{
// TODO: This is not alright. We should've updated the folder instead of inserting.
// Now we need to match the properties that user might've set locally.
folder.Id = existingFolder.Id;
folder.IsSticky = existingFolder.IsSticky;
folder.SpecialFolderType = existingFolder.SpecialFolderType;
folder.ShowUnreadCount = existingFolder.ShowUnreadCount;
folder.TextColorHex = existingFolder.TextColorHex;
folder.BackgroundColorHex = existingFolder.BackgroundColorHex;
_logger.Debug("Folder {Id} - {FolderName} already exists. Updating.", folder.Id, folder.FolderName);
await UpdateFolderAsync(folder).ConfigureAwait(false);
}
}
public async Task UpdateFolderAsync(MailItemFolder folder)
{
if (folder == null)
{
_logger.Warning("Folder is null. Cannot update.");
return;
}
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;
}
_logger.Debug("Updating folder {FolderName}", folder.Id, folder.FolderName);
await Connection.UpdateAsync(folder).ConfigureAwait(false);
}
private async Task DeleteFolderAsync(MailItemFolder folder)
{
if (folder == null)
{
_logger.Warning("Folder is null. Cannot delete.");
return;
}
var account = await _accountService.GetAccountAsync(folder.MailAccountId).ConfigureAwait(false);
if (account == null)
{
_logger.Warning("Account with id {MailAccountId} does not exist. Cannot delete folder.", folder.MailAccountId);
return;
}
_logger.Debug("Deleting folder {FolderName}", folder.FolderName);
await Connection.DeleteAsync(folder).ConfigureAwait(false);
// Delete all existing mails from this folder.
await Connection.ExecuteAsync("DELETE FROM MailCopy WHERE FolderId = ?", folder.Id);
// TODO: Delete MIME messages from the disk.
}
#endregion
private Task<List<string>> GetMailCopyIdsByFolderIdAsync(Guid folderId)
{
var query = new Query("MailCopy")
.Where("FolderId", folderId)
.Select("Id");
return Connection.QueryScalarsAsync<string>(query.GetRawQuery());
}
public async Task<List<MailFolderPairMetadata>> GetMailFolderPairMetadatasAsync(IEnumerable<string> mailCopyIds)
{
// Get all assignments for all items.
var query = new Query(nameof(MailCopy))
.Join(nameof(MailItemFolder), $"{nameof(MailCopy)}.FolderId", $"{nameof(MailItemFolder)}.Id")
.WhereIn($"{nameof(MailCopy)}.Id", mailCopyIds)
.SelectRaw($"{nameof(MailCopy)}.Id as MailCopyId, {nameof(MailItemFolder)}.Id as FolderId, {nameof(MailItemFolder)}.RemoteFolderId as RemoteFolderId")
.Distinct();
var rowQuery = query.GetRawQuery();
return await Connection.QueryAsync<MailFolderPairMetadata>(rowQuery);
}
public Task<List<MailFolderPairMetadata>> GetMailFolderPairMetadatasAsync(string mailCopyId)
=> GetMailFolderPairMetadatasAsync(new List<string>() { mailCopyId });
public async Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options)
{
var folders = new List<MailItemFolder>();
if (options.Type == SynchronizationType.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();
// Order is important for moving.
// By implementation, removing mail folders must be synchronized first. Requests are made in that order for custom sync.
// eg. Moving item from Folder A to Folder B. If we start syncing Folder B first, we might miss adding assignment for Folder A.
folders.AddRange(synchronizationFolders.OrderBy(a => options.SynchronizationFolderIds.IndexOf(a.Id)));
}
return folders;
}
public Task<MailItemFolder> GetFolderAsync(Guid accountId, string remoteFolderId)
=> Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.RemoteFolderId == remoteFolderId);
public async Task DeleteFolderAsync(Guid accountId, string remoteFolderId)
{
var folder = await GetFolderAsync(accountId, remoteFolderId);
if (folder == null)
{
_logger.Warning("Folder with id {RemoteFolderId} does not exist. Delete folder canceled.", remoteFolderId);
return;
}
await DeleteFolderAsync(folder).ConfigureAwait(false);
}
public async Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount)
{
var localFolder = await GetFolderAsync(folderId);
if (localFolder != null)
{
localFolder.ShowUnreadCount = showUnreadCount;
await UpdateFolderAsync(localFolder).ConfigureAwait(false);
}
}
public async Task<bool> IsInboxAvailableForAccountAsync(Guid accountId)
=> await Connection.Table<MailItemFolder>()
.Where(a => a.SpecialFolderType == SpecialFolderType.Inbox && a.MailAccountId == accountId)
.CountAsync() == 1;
public Task UpdateFolderLastSyncDateAsync(Guid folderId)
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET LastSynchronizedDate = ? WHERE Id = ?", DateTime.UtcNow, folderId);
public Task<List<UnreadItemCountResult>> GetUnreadItemCountResultsAsync(IEnumerable<Guid> accountIds)
{
var query = new Query(nameof(MailCopy))
.Join(nameof(MailItemFolder), $"{nameof(MailCopy)}.FolderId", $"{nameof(MailItemFolder)}.Id")
.WhereIn($"{nameof(MailItemFolder)}.MailAccountId", accountIds)
.Where($"{nameof(MailCopy)}.IsRead", 0)
.Where($"{nameof(MailItemFolder)}.ShowUnreadCount", 1)
.SelectRaw($"{nameof(MailItemFolder)}.Id as FolderId, {nameof(MailItemFolder)}.SpecialFolderType as SpecialFolderType, count (DISTINCT {nameof(MailCopy)}.Id) as UnreadItemCount, {nameof(MailItemFolder)}.MailAccountId as AccountId")
.GroupBy($"{nameof(MailItemFolder)}.Id");
return Connection.QueryAsync<UnreadItemCountResult>(query.GetRawQuery());
}
public Task<List<MailItemFolder>> GetChildFoldersAsync(Guid accountId, string parentRemoteFolderId)
{
var query = new Query(nameof(MailItemFolder))
.Where(nameof(MailItemFolder.ParentRemoteFolderId), parentRemoteFolderId)
.Where(nameof(MailItemFolder.MailAccountId), accountId)
.Where(nameof(MailItemFolder.IsHidden), false);
return Connection.QueryAsync<MailItemFolder>(query.GetRawQuery());
}
}
}

View File

@@ -0,0 +1,50 @@
using System.Collections.Generic;
using Serilog;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Reader;
namespace Wino.Services.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;
}
}

View File

@@ -0,0 +1,10 @@
using Wino.Domain.Interfaces;
using Wino.Services.Extensions;
namespace Wino.Services.Services
{
public class HtmlPreviewer : IHtmlPreviewer
{
public string GetHtmlPreview(string htmlContent) => HtmlAgilityPackExtensions.GetPreviewText(htmlContent);
}
}

View File

@@ -0,0 +1,39 @@
using System.IO;
using Serilog;
using Serilog.Core;
using Serilog.Exceptions;
using Wino.Domain.Interfaces;
namespace Wino.Services.Services
{
public class LogInitializer : ILogInitializer
{
private readonly LoggingLevelSwitch _levelSwitch = new LoggingLevelSwitch();
private readonly IPreferencesService _preferencesService;
public LogInitializer(IPreferencesService preferencesService)
{
_preferencesService = preferencesService;
RefreshLoggingLevel();
}
public void RefreshLoggingLevel()
{
_levelSwitch.MinimumLevel = _preferencesService.IsLoggingEnabled ? Serilog.Events.LogEventLevel.Debug : Serilog.Events.LogEventLevel.Fatal;
}
public void SetupLogger(string logFolderPath)
{
string logFilePath = Path.Combine(logFolderPath, Domain.Constants.WinoLogFileName);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(_levelSwitch)
.WriteTo.File(logFilePath)
.WriteTo.Debug()
.Enrich.FromLogContext()
.Enrich.WithExceptionDetails()
.CreateLogger();
}
}
}

View File

@@ -0,0 +1,946 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MimeKit;
using MoreLinq;
using Serilog;
using SqlKata;
using Wino.Domain;
using Wino.Domain.Models.Comparers;
using Wino.Domain;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Messaging.Server;
using Wino.Services.Extensions;
namespace Wino.Services.Services
{
public class MailService : BaseDatabaseService, IMailService
{
private const int ItemLoadCount = 100;
private readonly IFolderService _folderService;
private readonly IContactService _contactService;
private readonly IAccountService _accountService;
private readonly ISignatureService _signatureService;
private readonly IThreadingStrategyProvider _threadingStrategyProvider;
private readonly IMimeFileService _mimeFileService;
private readonly IHtmlPreviewer _htmlPreviewer;
private readonly ILogger _logger = Log.ForContext<MailService>();
public MailService(IDatabaseService databaseService,
IFolderService folderService,
IContactService contactService,
IAccountService accountService,
ISignatureService signatureService,
IThreadingStrategyProvider threadingStrategyProvider,
IMimeFileService mimeFileService,
IHtmlPreviewer htmlPreviewer) : base(databaseService)
{
_folderService = folderService;
_contactService = contactService;
_accountService = accountService;
_signatureService = signatureService;
_threadingStrategyProvider = threadingStrategyProvider;
_mimeFileService = mimeFileService;
_htmlPreviewer = htmlPreviewer;
}
public async Task<MailCopy> CreateDraftAsync(MailAccount composerAccount,
MimeMessage createdDraftMimeMessage,
MimeMessage replyingMimeMessage = null,
IMailItem replyingMailItem = null)
{
bool isImapAccount = composerAccount.ServerInformation != null;
string fromName;
fromName = composerAccount.SenderName;
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 async Task<List<MailCopy>> GetMailsByFolderIdAsync(Guid folderId)
{
var mails = await Connection.QueryAsync<MailCopy>("SELECT * FROM MailCopy WHERE FolderId = ?", folderId);
foreach (var mail in mails)
{
await LoadAssignedPropertiesAsync(mail).ConfigureAwait(false);
}
return mails;
}
public async Task<List<MailCopy>> GetUnreadMailsByFolderIdAsync(Guid folderId)
{
var unreadMails = await Connection.QueryAsync<MailCopy>("SELECT * FROM MailCopy WHERE FolderId = ? AND IsRead = 0", folderId);
foreach (var mail in unreadMails)
{
await LoadAssignedPropertiesAsync(mail).ConfigureAwait(false);
}
return unreadMails;
}
private string BuildMailFetchQuery(MailListInitializationOptions options)
{
// If the search query is there, we should ignore some properties and trim it.
//if (!string.IsNullOrEmpty(options.SearchQuery))
//{
// options.IsFocusedOnly = null;
// filterType = FilterOptionType.All;
// searchQuery = searchQuery.Trim();
//}
// SQLite PCL doesn't support joins.
// We make the query using SqlKatka and execute it directly on SQLite-PCL.
var query = new Query("MailCopy")
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
.WhereIn("MailCopy.FolderId", options.Folders.Select(a => a.Id))
.Take(ItemLoadCount)
.SelectRaw("MailCopy.*");
if (options.SortingOptionType == SortingOptionType.ReceiveDate)
query.OrderByDesc("CreationDate");
else if (options.SortingOptionType == SortingOptionType.Sender)
query.OrderBy("FromName");
// Conditional where.
switch (options.FilterType)
{
case FilterOptionType.Unread:
query.Where("MailCopy.IsRead", false);
break;
case FilterOptionType.Flagged:
query.Where("MailCopy.IsFlagged", true);
break;
case FilterOptionType.Files:
query.Where("MailCopy.HasAttachments", true);
break;
}
if (options.IsFocusedOnly != null)
query.Where("MailCopy.IsFocused", options.IsFocusedOnly.Value);
if (!string.IsNullOrEmpty(options.SearchQuery))
query.Where(a =>
a.OrWhereContains("MailCopy.PreviewText", options.SearchQuery)
.OrWhereContains("MailCopy.Subject", options.SearchQuery)
.OrWhereContains("MailCopy.FromName", options.SearchQuery)
.OrWhereContains("MailCopy.FromAddress", options.SearchQuery));
if (options.ExistingUniqueIds?.Any() ?? false)
{
query.WhereNotIn("MailCopy.UniqueId", options.ExistingUniqueIds);
}
//if (options.Skip > 0)
//{
// query.Skip(options.Skip);
//}
return query.GetRawQuery();
}
public async Task<List<IMailItem>> FetchMailsAsync(MailListInitializationOptions options)
{
var query = BuildMailFetchQuery(options);
var mails = await Connection.QueryAsync<MailCopy>(query);
Dictionary<Guid, MailItemFolder> folderCache = [];
Dictionary<Guid, MailAccount> accountCache = [];
// Populate Folder Assignment for each single mail, to be able later group by "MailAccountId".
// This is needed to execute threading strategy by account type.
// Avoid DBs calls as possible, storing info in a dictionary.
foreach (var mail in mails)
{
await LoadAssignedPropertiesWithCacheAsync(mail, folderCache, accountCache).ConfigureAwait(false);
}
// Remove items that has no assigned account or folder.
mails.RemoveAll(a => a.AssignedAccount == null || a.AssignedFolder == null);
if (!options.CreateThreads)
{
// Threading is disabled. Just return everything as it is.
mails.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer());
return new List<IMailItem>(mails);
}
// Populate threaded items.
var threadedItems = new List<IMailItem>();
// Each account items must be threaded separately.
foreach (var group in mails.GroupBy(a => a.AssignedAccount.Id))
{
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]);
// Populate threaded items with folder and account assignments.
// Almost everything already should be in cache from initial population.
foreach (var mail in accountThreadedItems)
{
await LoadAssignedPropertiesWithCacheAsync(mail, folderCache, accountCache).ConfigureAwait(false);
}
if (accountThreadedItems != null)
{
threadedItems.AddRange(accountThreadedItems);
}
}
threadedItems.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer());
return threadedItems;
// Recursive function to populate folder and account assignments for each mail item.
async Task LoadAssignedPropertiesWithCacheAsync(IMailItem mail, Dictionary<Guid, MailItemFolder> folderCache, Dictionary<Guid, MailAccount> accountCache)
{
if (mail is ThreadMailItem threadMailItem)
{
foreach (var childMail in threadMailItem.ThreadItems)
{
await LoadAssignedPropertiesWithCacheAsync(childMail, folderCache, accountCache).ConfigureAwait(false);
}
}
if (mail is MailCopy mailCopy)
{
MailAccount accountAssignment = null;
var isFolderCached = folderCache.TryGetValue(mailCopy.FolderId, out MailItemFolder folderAssignment);
accountAssignment = null;
if (!isFolderCached)
{
folderAssignment = await _folderService.GetFolderAsync(mailCopy.FolderId).ConfigureAwait(false);
_ = folderCache.TryAdd(mailCopy.FolderId, folderAssignment);
}
if (folderAssignment != null)
{
var isAccountCached = accountCache.TryGetValue(folderAssignment.MailAccountId, out accountAssignment);
if (!isAccountCached)
{
accountAssignment = await _accountService.GetAccountAsync(folderAssignment.MailAccountId).ConfigureAwait(false);
_ = accountCache.TryAdd(folderAssignment.MailAccountId, accountAssignment);
}
}
mailCopy.AssignedFolder = folderAssignment;
mailCopy.AssignedAccount = accountAssignment;
}
}
}
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);
// If there are no more copies exists of the same mail, delete the MIME file as well.
var isMailExists = await IsMailExistsAsync(mailCopy.Id).ConfigureAwait(false);
if (!isMailExists)
{
await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false);
}
ReportUIChange(new MailRemovedMessage(mailCopy));
}
#endregion
private async Task UpdateAllMailCopiesAsync(string mailCopyId, Func<MailCopy, bool> action)
{
var mailCopies = await GetMailItemsAsync(mailCopyId);
if (mailCopies == null || !mailCopies.Any())
{
_logger.Warning("Updating mail copies failed because there are no copies available with Id {MailCopyId}", mailCopyId);
return;
}
_logger.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;
message.From.Add(new MailboxAddress(account.SenderName, account.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);
builder.HtmlBody = CreateHtmlForReferencingMessage(referenceMessage);
}
if (reason == DraftCreationReason.Forward)
{
builder.HtmlBody = CreateHtmlForReferencingMessage(referenceMessage);
}
// Append signatures if needed.
if (account.Preferences.IsSignatureEnabled)
{
var signatureId = reason == DraftCreationReason.Empty ?
account.Preferences.SignatureIdForNewMessages :
account.Preferences.SignatureIdForFollowingMessages;
if (signatureId != null)
{
var signature = await _signatureService.GetSignatureAsync(signatureId.Value);
if (string.IsNullOrWhiteSpace(builder.HtmlBody))
{
builder.HtmlBody = $"<br><br><br>{signature.HtmlBody}";
}
else
{
builder.HtmlBody = $"<br><br><br>{signature.HtmlBody}" + builder.HtmlBody;
}
}
}
// 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 = _htmlPreviewer.GetHtmlPreview(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();
}
static 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;
// Generates html representation of To/Cc/From/Time and so on from referenced message.
string CreateHtmlForReferencingMessage(MimeMessage referenceMessage)
{
var htmlMimeInfo = string.Empty;
// Separation Line
htmlMimeInfo += "<br><br><hr style='display:inline-block;width:100%' tabindex='-1'>";
var visitor = _mimeFileService.CreateHTMLPreviewVisitor(referenceMessage, string.Empty);
visitor.Visit(referenceMessage);
htmlMimeInfo += $"""
<div id="divRplyFwdMsg" dir="ltr">
<font face="Calibri, sans-serif" style="font-size: 11pt;" color="#000000">
<b>From:</b> {ParticipantsToHtml(referenceMessage.From)}<br>
<b>Sent:</b> {referenceMessage.Date.ToLocalTime()}<br>
<b>To:</b> {ParticipantsToHtml(referenceMessage.To)}<br>
{(referenceMessage.Cc.Count > 0 ? $"<b>Cc:</b> {ParticipantsToHtml(referenceMessage.Cc)}<br>" : string.Empty)}
<b>Subject:</b> {referenceMessage.Subject}
</font>
<div>&nbsp;</div>
{visitor.HtmlBody}
</div>
""";
return htmlMimeInfo;
}
static string ParticipantsToHtml(InternetAddressList internetAddresses) =>
string.Join("; ", internetAddresses.Mailboxes
.Select(x => $"{x.Name ?? Translator.UnknownSender} &lt;<a href=\"mailto:{x.Address ?? Translator.UnknownAddress}\">{x.Address ?? Translator.UnknownAddress}</a>&gt;"));
}
public async Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
{
var query = new Query("MailCopy")
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
.Where("MailCopy.UniqueId", localDraftCopyUniqueId)
.Where("MailItemFolder.MailAccountId", accountId)
.SelectRaw("MailCopy.*")
.GetRawQuery();
var localDraftCopy = await Connection.FindWithQueryAsync<MailCopy>(query);
if (localDraftCopy == null)
{
_logger.Warning("Draft mapping failed because local draft copy with unique id {LocalDraftCopyUniqueId} does not exist.", localDraftCopyUniqueId);
return false;
}
var oldLocalDraftId = localDraftCopy.Id;
await LoadAssignedPropertiesAsync(localDraftCopy).ConfigureAwait(false);
bool isIdChanging = localDraftCopy.Id != newMailCopyId;
localDraftCopy.Id = newMailCopyId;
localDraftCopy.DraftId = newDraftId;
localDraftCopy.ThreadId = newThreadId;
await UpdateMailAsync(localDraftCopy).ConfigureAwait(false);
ReportUIChange(new DraftMapped(oldLocalDraftId, newDraftId));
return true;
}
public Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId)
{
return UpdateAllMailCopiesAsync(mailCopyId, (item) =>
{
if (item.ThreadId != newThreadId || item.DraftId != newDraftId)
{
var oldDraftId = item.DraftId;
item.DraftId = newDraftId;
item.ThreadId = newThreadId;
ReportUIChange(new DraftMapped(oldDraftId, newDraftId));
return true;
}
return false;
});
}
public Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds)
{
var rawQuery = new Query("MailCopy")
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
.WhereIn("MailCopy.Id", downloadedMailCopyIds)
.Where("MailCopy.IsRead", false)
.Where("MailItemFolder.MailAccountId", accountId)
.Where("MailItemFolder.SpecialFolderType", SpecialFolderType.Inbox)
.SelectRaw("MailCopy.*")
.GetRawQuery();
return Connection.QueryAsync<MailCopy>(rawQuery);
}
public Task<MailAccount> GetMailAccountByUniqueIdAsync(Guid uniqueMailId)
{
var query = new Query("MailCopy")
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
.Join("MailAccount", "MailItemFolder.MailAccountId", "MailAccount.Id")
.Where("MailCopy.UniqueId", uniqueMailId)
.SelectRaw("MailAccount.*")
.GetRawQuery();
return Connection.FindWithQueryAsync<MailAccount>(query);
}
public Task<bool> IsMailExistsAsync(string mailCopyId)
=> Connection.ExecuteScalarAsync<bool>("SELECT EXISTS(SELECT 1 FROM MailCopy WHERE Id = ?)", mailCopyId);
public Task<MailCopy> GetReplyParentAsync(IMailItem replyItem, Guid accountId, Guid threadingFolderId, Guid sentFolderId, Guid draftFolderId)
{
if (string.IsNullOrEmpty(replyItem?.MessageId)) return Task.FromResult<MailCopy>(null);
var query = new Query("MailCopy")
.Distinct()
.Take(1)
.Join("MailItemFolder", "MailItemFolder.Id", "MailCopy.FolderId")
.Where("MailItemFolder.MailAccountId", accountId)
.WhereIn("MailItemFolder.Id", new List<Guid> { threadingFolderId, sentFolderId, draftFolderId })
.Where("MailCopy.MessageId", replyItem.InReplyTo)
.WhereNot("MailCopy.Id", replyItem.Id)
.Select("MailCopy.*");
return Connection.FindWithQueryAsync<MailCopy>(query.GetRawQuery());
}
public Task<MailCopy> GetInReplyToReplyAsync(IMailItem originalItem, Guid accountId, Guid threadingFolderId, Guid sentFolderId, Guid draftFolderId)
{
if (string.IsNullOrEmpty(originalItem?.MessageId)) return Task.FromResult<MailCopy>(null);
var query = new Query("MailCopy")
.Distinct()
.Take(1)
.Join("MailItemFolder", "MailItemFolder.Id", "MailCopy.FolderId")
.WhereNot("MailCopy.Id", originalItem.Id)
.Where("MailItemFolder.MailAccountId", accountId)
.Where("MailCopy.InReplyTo", originalItem.MessageId)
.WhereIn("MailItemFolder.Id", new List<Guid> { threadingFolderId, sentFolderId, draftFolderId })
.Select("MailCopy.*");
var raq = query.GetRawQuery();
return Connection.FindWithQueryAsync<MailCopy>(query.GetRawQuery());
}
}
}

View File

@@ -0,0 +1,184 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MimeKit;
using Serilog;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Models.Mime;
using Wino.Domain.Models.Reader;
using Wino.Services.Extensions;
namespace Wino.Services.Services
{
public class MimeFileService : IMimeFileService
{
private readonly INativeAppService _nativeAppService;
private ILogger _logger = Log.ForContext<MimeFileService>();
public MimeFileService(INativeAppService nativeAppService)
{
_nativeAppService = nativeAppService;
}
public async Task<MimeMessageInformation> GetMimeMessageInformationAsync(Guid fileId, Guid accountId, CancellationToken cancellationToken = default)
{
var resourcePath = await GetMimeResourcePathAsync(accountId, fileId).ConfigureAwait(false);
var mimeFilePath = GetEMLPath(resourcePath);
var loadedMimeMessage = await MimeMessage.LoadAsync(mimeFilePath, cancellationToken).ConfigureAwait(false);
return new MimeMessageInformation(loadedMimeMessage, resourcePath);
}
public async Task<MimeMessageInformation> GetMimeMessageInformationAsync(byte[] fileBytes, string emlDirectoryPath, CancellationToken cancellationToken = default)
{
var memoryStream = new MemoryStream(fileBytes);
var loadedMimeMessage = await MimeMessage.LoadAsync(memoryStream, cancellationToken).ConfigureAwait(false);
return new MimeMessageInformation(loadedMimeMessage, emlDirectoryPath);
}
public async Task<bool> SaveMimeMessageAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId)
{
try
{
var resourcePath = await GetMimeResourcePathAsync(accountId, fileId).ConfigureAwait(false);
var completeFilePath = GetEMLPath(resourcePath);
var fileStream = File.Create(completeFilePath);
using (fileStream)
{
await mimeMessage.WriteToAsync(fileStream).ConfigureAwait(false);
}
return true;
}
catch (Exception ex)
{
_logger.Error(ex, "Could not save mime file for FileId: {FileId}", fileId);
}
return false;
}
private string GetEMLPath(string resourcePath) => $"{resourcePath}\\mail.eml";
public async Task<string> GetMimeResourcePathAsync(Guid accountId, Guid fileId)
{
var mimeFolderPath = await _nativeAppService.GetMimeMessageStoragePath().ConfigureAwait(false);
var mimeDirectory = Path.Combine(mimeFolderPath, accountId.ToString(), fileId.ToString());
if (!Directory.Exists(mimeDirectory))
Directory.CreateDirectory(mimeDirectory);
return mimeDirectory;
}
public async Task<bool> IsMimeExistAsync(Guid accountId, Guid fileId)
{
var resourcePath = await GetMimeResourcePathAsync(accountId, fileId);
var completeFilePath = GetEMLPath(resourcePath);
return File.Exists(completeFilePath);
}
public HtmlPreviewVisitor CreateHTMLPreviewVisitor(MimeMessage message, string mimeLocalPath)
{
var visitor = new HtmlPreviewVisitor(mimeLocalPath);
message.Accept(visitor);
// TODO: Match cid with attachments if any.
return visitor;
}
public async Task<bool> DeleteMimeMessageAsync(Guid accountId, Guid fileId)
{
var resourcePath = await GetMimeResourcePathAsync(accountId, fileId);
var completeFilePath = GetEMLPath(resourcePath);
if (File.Exists(completeFilePath))
{
try
{
File.Delete(completeFilePath);
_logger.Information("Mime file deleted for {FileId}", fileId);
return true;
}
catch (Exception ex)
{
_logger.Error(ex, "Could not delete mime file for {FileId}", fileId);
}
return false;
}
return true;
}
public MailRenderModel GetMailRenderModel(MimeMessage message, string mimeLocalPath, MailRenderingOptions options = null)
{
var visitor = CreateHTMLPreviewVisitor(message, mimeLocalPath);
string finalRenderHtml = visitor.HtmlBody;
// Check whether we need to purify the generated HTML from visitor.
// No need to create HtmlDocument if not required.
if (options != null && options.IsPurifyingNeeded())
{
var document = new HtmlAgilityPack.HtmlDocument();
document.LoadHtml(visitor.HtmlBody);
// Clear <img> src attribute.
if (!options.LoadImages)
document.ClearImages();
if (!options.LoadStyles)
document.ClearStyles();
// Update final HTML.
finalRenderHtml = document.DocumentNode.OuterHtml;
}
var renderingModel = new MailRenderModel(finalRenderHtml, options);
// Create attachments.
foreach (var attachment in visitor.Attachments)
{
if (attachment.IsAttachment && attachment is MimePart attachmentPart)
{
renderingModel.Attachments.Add(attachmentPart);
}
}
if (message.Headers.Contains(HeaderId.ListUnsubscribe))
{
var unsubscribeLinks = message.Headers[HeaderId.ListUnsubscribe]
.Normalize()
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim([' ', '<', '>']));
// Only two types of unsubscribe links are possible.
// So each has it's own property to simplify the usage.
renderingModel.UnsubscribeInfo = new UnsubscribeInfo()
{
HttpLink = unsubscribeLinks.FirstOrDefault(x => x.StartsWith("http", StringComparison.OrdinalIgnoreCase)),
MailToLink = unsubscribeLinks.FirstOrDefault(x => x.StartsWith("mailto", StringComparison.OrdinalIgnoreCase)),
IsOneClick = message.Headers.Contains(HeaderId.ListUnsubscribePost)
};
}
return renderingModel;
}
}
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Linq;
using Wino.Domain.Models.Accounts;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
namespace Wino.Services.Services
{
/// <summary>
/// Service that is returning available provider details.
/// </summary>
public class ProviderService : IProviderService
{
public IProviderDetail GetProviderDetail(MailProviderType type)
{
var details = GetProviderDetails();
return details.FirstOrDefault(a => a.Type == type);
}
public List<IProviderDetail> GetProviderDetails()
{
var providerList = new List<IProviderDetail>();
var providers = new MailProviderType[]
{
MailProviderType.Outlook,
MailProviderType.Gmail,
MailProviderType.IMAP4
};
foreach (var type in providers)
{
providerList.Add(new ProviderDetail(type));
}
return providerList;
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wino.Domain.Entities;
using Wino.Domain.Interfaces;
namespace Wino.Services.Services
{
public class SignatureService(IDatabaseService databaseService) : BaseDatabaseService(databaseService), ISignatureService
{
public async Task<AccountSignature> GetSignatureAsync(Guid signatureId)
{
return await Connection.Table<AccountSignature>().FirstAsync(s => s.Id == signatureId);
}
public async Task<List<AccountSignature>> GetSignaturesAsync(Guid accountId)
{
return await Connection.Table<AccountSignature>().Where(s => s.MailAccountId == accountId).ToListAsync();
}
public async Task<AccountSignature> CreateSignatureAsync(AccountSignature signature)
{
await Connection.InsertAsync(signature);
return signature;
}
public async Task<AccountSignature> CreateDefaultSignatureAsync(Guid accountId)
{
var defaultSignature = new AccountSignature()
{
Id = Guid.NewGuid(),
MailAccountId = accountId,
// TODO: Should be translated?
Name = "Wino Default Signature",
HtmlBody = @"<p>Sent from <a href=""https://github.com/bkaankose/Wino-Mail/"">Wino Mail</a> for Windows</p>"
};
await Connection.InsertAsync(defaultSignature);
return defaultSignature;
}
public async Task<AccountSignature> UpdateSignatureAsync(AccountSignature signature)
{
await Connection.UpdateAsync(signature);
return signature;
}
public async Task<AccountSignature> DeleteSignatureAsync(AccountSignature signature)
{
await Connection.DeleteAsync(signature);
return signature;
}
}
}

View File

@@ -0,0 +1,31 @@
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
namespace Wino.Services.Services
{
public class ThreadingStrategyProvider : IThreadingStrategyProvider
{
private readonly IOutlookThreadingStrategy _outlookThreadingStrategy;
private readonly IGmailThreadingStrategy _gmailThreadingStrategy;
private readonly IImapThreadStrategy _imapThreadStrategy;
public ThreadingStrategyProvider(IOutlookThreadingStrategy outlookThreadingStrategy,
IGmailThreadingStrategy gmailThreadingStrategy,
IImapThreadStrategy imapThreadStrategy)
{
_outlookThreadingStrategy = outlookThreadingStrategy;
_gmailThreadingStrategy = gmailThreadingStrategy;
_imapThreadStrategy = imapThreadStrategy;
}
public IThreadingStrategy GetStrategy(MailProviderType mailProviderType)
{
return mailProviderType switch
{
MailProviderType.Outlook or MailProviderType.Office365 => _outlookThreadingStrategy,
MailProviderType.Gmail => _gmailThreadingStrategy,
_ => _imapThreadStrategy,
};
}
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Threading.Tasks;
using Wino.Domain.Entities;
using Wino.Domain.Interfaces;
namespace Wino.Services.Services
{
public class TokenService : BaseDatabaseService, ITokenService
{
public TokenService(IDatabaseService databaseService) : base(databaseService) { }
public Task<TokenInformation> GetTokenInformationAsync(Guid accountId)
=> Connection.Table<TokenInformation>().FirstOrDefaultAsync(a => a.AccountId == accountId);
public async Task SaveTokenInformationAsync(Guid accountId, TokenInformation tokenInformation)
{
// Delete all tokens for this account.
await Connection.Table<TokenInformation>().DeleteAsync(a => a.AccountId == accountId);
// Save new token info to the account.
tokenInformation.AccountId = accountId;
await Connection.InsertOrReplaceAsync(tokenInformation);
}
}
}

View File

@@ -0,0 +1,87 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Domain;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Translations;
using Wino.Messaging.Client.Shell;
namespace Wino.Services.Services
{
public class TranslationService : ITranslationService
{
private ILogger _logger = Log.ForContext<TranslationService>();
private readonly IPreferencesService _preferencesService;
private bool isInitialized = false;
public TranslationService(IPreferencesService preferencesService)
{
_preferencesService = preferencesService;
}
// Initialize default language with ignoring current language check.
public Task InitializeAsync() => InitializeLanguageAsync(_preferencesService.CurrentLanguage, ignoreCurrentLanguageCheck: true);
public async Task InitializeLanguageAsync(AppLanguage language, bool ignoreCurrentLanguageCheck = false)
{
if (!ignoreCurrentLanguageCheck && _preferencesService.CurrentLanguage == language)
{
_logger.Warning("Changing language is ignored because current language and requested language are same.");
return;
}
if (ignoreCurrentLanguageCheck && isInitialized) return;
var currentDictionary = Translator.Resources;
using var resourceStream = currentDictionary.GetLanguageStream(language);
var stremValue = await new StreamReader(resourceStream).ReadToEndAsync().ConfigureAwait(false);
var translationLookups = JsonSerializer.Deserialize<Dictionary<string, string>>(stremValue);
// Insert new translation key-value pairs.
// Overwrite existing values for the same keys.
foreach (var pair in translationLookups)
{
// Replace existing value.
if (currentDictionary.ContainsKey(pair.Key))
{
currentDictionary[pair.Key] = pair.Value;
}
else
{
currentDictionary.Add(pair.Key, pair.Value);
}
}
_preferencesService.CurrentLanguage = language;
isInitialized = true;
WeakReferenceMessenger.Default.Send(new LanguageChanged());
}
public List<AppLanguageModel> GetAvailableLanguages()
{
return
[
new AppLanguageModel(AppLanguage.Chinese, "Chinese"),
new AppLanguageModel(AppLanguage.Czech, "Czech"),
new AppLanguageModel(AppLanguage.Deutsch, "Deutsch"),
new AppLanguageModel(AppLanguage.English, "English"),
new AppLanguageModel(AppLanguage.French, "French"),
new AppLanguageModel(AppLanguage.Greek, "Greek"),
new AppLanguageModel(AppLanguage.Indonesian, "Indonesian"),
new AppLanguageModel(AppLanguage.Polish, "Polski"),
new AppLanguageModel(AppLanguage.PortugeseBrazil, "Portugese-Brazil"),
new AppLanguageModel(AppLanguage.Russian, "Russian"),
new AppLanguageModel(AppLanguage.Spanish, "Spanish")
];
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Serilog;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Reader;
namespace Wino.Services.Services
{
public class UnsubscriptionService : IUnsubscriptionService
{
public async Task<bool> OneClickUnsubscribeAsync(UnsubscribeInfo info)
{
try
{
using var httpClient = new HttpClient();
var unsubscribeRequest = new HttpRequestMessage(HttpMethod.Post, info.HttpLink)
{
Content = new StringContent("List-Unsubscribe=One-Click", Encoding.UTF8, "application/x-www-form-urlencoded")
};
var result = await httpClient.SendAsync(unsubscribeRequest).ConfigureAwait(false);
return result.IsSuccessStatusCode;
}
catch (Exception ex)
{
Log.Error("Failed to unsubscribe from {HttpLink} - {Message}", info.HttpLink, ex.Message);
}
return false;
}
}
}

View File

@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Domain;
using Wino.Domain.Models.Synchronization;
using Wino.Domain.Enums;
using Wino.Domain.Exceptions;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.Folders;
using Wino.Domain.Models.MailItem;
using Wino.Messaging.Client.Synchronization;
using Wino.Services.Requests;
namespace Wino.Services.Services
{
public class WinoRequestDelegator : IWinoRequestDelegator
{
private readonly IWinoRequestProcessor _winoRequestProcessor;
private readonly IWinoServerConnectionManager _winoServerConnectionManager;
private readonly IFolderService _folderService;
private readonly IDialogService _dialogService;
private readonly ILogger _logger = Log.ForContext<WinoRequestDelegator>();
public WinoRequestDelegator(IWinoRequestProcessor winoRequestProcessor,
IWinoServerConnectionManager winoServerConnectionManager,
IFolderService folderService,
IDialogService dialogService)
{
_winoRequestProcessor = winoRequestProcessor;
_winoServerConnectionManager = winoServerConnectionManager;
_folderService = folderService;
_dialogService = dialogService;
}
public async Task QueueAsync(MailOperationPreperationRequest request)
{
var requests = new List<IRequest>();
try
{
requests = await _winoRequestProcessor.PrepareRequestsAsync(request);
}
catch (UnavailableSpecialFolderException unavailableSpecialFolderException)
{
_dialogService.InfoBarMessage(Translator.Info_MissingFolderTitle,
string.Format(Translator.Info_MissingFolderMessage, unavailableSpecialFolderException.SpecialFolderType),
InfoBarMessageType.Warning,
Translator.SettingConfigureSpecialFolders_Button,
() =>
{
_dialogService.HandleSystemFolderConfigurationDialogAsync(unavailableSpecialFolderException.AccountId, _folderService);
});
}
catch (InvalidMoveTargetException)
{
_dialogService.InfoBarMessage(Translator.Info_InvalidMoveTargetTitle, Translator.Info_InvalidMoveTargetMessage, InfoBarMessageType.Warning);
}
catch (NotImplementedException)
{
_dialogService.ShowNotSupportedMessage();
}
catch (Exception ex)
{
Log.Error(ex, "Request creation failed.");
_dialogService.InfoBarMessage(Translator.Info_RequestCreationFailedTitle, ex.Message, InfoBarMessageType.Error);
}
if (requests == null || !requests.Any()) return;
var accountIds = requests.GroupBy(a => a.Item.AssignedAccount.Id);
// Queue requests for each account and start synchronization.
foreach (var accountId in accountIds)
{
foreach (var accountRequest in accountId)
{
QueueRequest(accountRequest, accountId.Key);
}
QueueSynchronization(accountId.Key);
}
}
public async Task QueueAsync(FolderOperationPreperationRequest folderRequest)
{
if (folderRequest == null || folderRequest.Folder == null) return;
IRequestBase request = null;
var accountId = folderRequest.Folder.MailAccountId;
try
{
request = await _winoRequestProcessor.PrepareFolderRequestAsync(folderRequest);
}
catch (NotImplementedException)
{
_dialogService.ShowNotSupportedMessage();
}
catch (Exception ex)
{
Log.Error(ex, "Folder operation execution failed.");
}
if (request == null) return;
QueueRequest(request, accountId);
QueueSynchronization(accountId);
}
public Task QueueAsync(DraftPreperationRequest draftPreperationRequest)
{
var request = new CreateDraftRequest(draftPreperationRequest);
QueueRequest(request, draftPreperationRequest.Account.Id);
QueueSynchronization(draftPreperationRequest.Account.Id);
return Task.CompletedTask;
}
public Task QueueAsync(SendDraftPreparationRequest sendDraftPreperationRequest)
{
var request = new SendDraftRequest(sendDraftPreperationRequest);
QueueRequest(request, sendDraftPreperationRequest.MailItem.AssignedAccount.Id);
QueueSynchronization(sendDraftPreperationRequest.MailItem.AssignedAccount.Id);
return Task.CompletedTask;
}
private void QueueRequest(IRequestBase request, Guid accountId)
=> _winoServerConnectionManager.QueueRequest(request, accountId);
private void QueueSynchronization(Guid accountId)
{
var options = new SynchronizationOptions()
{
AccountId = accountId,
Type = SynchronizationType.ExecuteRequests
};
WeakReferenceMessenger.Default.Send(new NewSynchronizationRequested(options));
}
}
}

View File

@@ -0,0 +1,56 @@
using Microsoft.Extensions.DependencyInjection;
using Serilog.Core;
using Wino.Domain.Interfaces;
using Wino.Services.Authenticators;
using Wino.Services.Processors;
using Wino.Services.Services;
using Wino.Services.Threading;
namespace Wino.Services
{
public static class ServicesContainerSetup
{
public static void RegisterServices(this IServiceCollection services)
{
var loggerLevelSwitcher = new LoggingLevelSwitch();
services.AddSingleton(loggerLevelSwitcher);
services.AddSingleton<ILogInitializer, LogInitializer>();
services.AddSingleton<IApplicationConfiguration, ApplicationConfiguration>();
services.AddSingleton<ITranslationService, TranslationService>();
services.AddSingleton<IDatabaseService, DatabaseService>();
services.AddSingleton<IThreadingStrategyProvider, ThreadingStrategyProvider>();
services.AddSingleton<IMimeFileService, MimeFileService>();
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<IWinoRequestDelegator, WinoRequestDelegator>();
services.AddTransient<IWinoRequestProcessor, WinoRequestProcessor>();
services.AddTransient<IAutoDiscoveryService, AutoDiscoveryService>();
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
services.AddTransient<IFontService, FontService>();
services.AddTransient<IUnsubscriptionService, UnsubscriptionService>();
services.AddTransient<IHtmlPreviewer, HtmlPreviewer>();
services.AddTransient<IOutlookAuthenticator, OutlookAuthenticator>();
services.AddTransient<IGmailAuthenticator, GmailAuthenticator>();
services.AddTransient<IAuthenticationProvider, AuthenticationProvider>();
services.AddTransient<IGmailChangeProcessor, GmailChangeProcessor>();
services.AddTransient<IImapChangeProcessor, ImapChangeProcessor>();
services.AddTransient<IOutlookChangeProcessor, OutlookChangeProcessor>();
services.AddTransient<IOutlookThreadingStrategy, OutlookThreadingStrategy>();
services.AddTransient<IGmailThreadingStrategy, GmailThreadingStrategy>();
services.AddTransient<IImapThreadStrategy, ImapThreadStrategy>();
}
}
}

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Wino.Domain.Models.MailItem;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
namespace Wino.Services.Threading
{
public class APIThreadingStrategy : IThreadingStrategy
{
private readonly IDatabaseService _databaseService;
private readonly IFolderService _folderService;
public APIThreadingStrategy(IDatabaseService databaseService, IFolderService folderService)
{
_databaseService = databaseService;
_folderService = folderService;
}
public virtual bool ShouldThreadWithItem(IMailItem originalItem, IMailItem targetItem)
{
return originalItem.ThreadId != null && originalItem.ThreadId == targetItem.ThreadId;
}
///<inheritdoc/>
public async Task<List<IMailItem>> ThreadItemsAsync(List<MailCopy> items)
{
var assignedAccount = items[0].AssignedAccount;
var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(assignedAccount.Id, SpecialFolderType.Sent);
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(assignedAccount.Id, SpecialFolderType.Draft);
if (sentFolder == null || draftFolder == null) return default;
// True: Non threaded items.
// False: Potentially threaded items.
var nonThreadedOrThreadedMails = items
.Distinct()
.GroupBy(x => string.IsNullOrEmpty(x.ThreadId))
.ToDictionary(x => x.Key, x => x);
_ = nonThreadedOrThreadedMails.TryGetValue(true, out var nonThreadedMails);
var isThreadedItems = nonThreadedOrThreadedMails.TryGetValue(false, out var potentiallyThreadedMails);
List<IMailItem> resultList = nonThreadedMails is null ? [] : [.. nonThreadedMails];
if (isThreadedItems)
{
var threadItems = (await GetThreadItemsAsync(potentiallyThreadedMails.Select(x => (x.ThreadId, x.AssignedFolder)).ToList(), assignedAccount.Id, sentFolder.Id, draftFolder.Id))
.GroupBy(x => x.ThreadId);
foreach (var threadItem in threadItems)
{
if (threadItem.Count() == 1)
{
resultList.Add(threadItem.First());
continue;
}
var thread = new ThreadMailItem();
foreach (var childThreadItem in threadItem)
{
thread.AddThreadItem(childThreadItem);
}
resultList.Add(thread);
}
}
return resultList;
}
private async Task<List<MailCopy>> GetThreadItemsAsync(List<(string threadId, MailItemFolder threadingFolder)> potentialThread,
Guid accountId,
Guid sentFolderId,
Guid draftFolderId)
{
// Only items from the folder that we are threading for, sent and draft folder items must be included.
// This is important because deleted items or item assignments that belongs to different folder is
// affecting the thread creation here.
// If the threading is done from Sent or Draft folder, include everything...
// TODO: Convert to SQLKata query.
var query = @$"SELECT DISTINCT MC.* FROM MailCopy MC
INNER JOIN MailItemFolder MF on MF.Id = MC.FolderId
WHERE MF.MailAccountId == '{accountId}' AND
({string.Join(" OR ", potentialThread.Select(x => ConditionForItem(x, sentFolderId, draftFolderId)))})";
return await _databaseService.Connection.QueryAsync<MailCopy>(query);
static string ConditionForItem((string threadId, MailItemFolder threadingFolder) potentialThread, Guid sentFolderId, Guid draftFolderId)
{
if (potentialThread.threadingFolder.SpecialFolderType == SpecialFolderType.Draft || potentialThread.threadingFolder.SpecialFolderType == SpecialFolderType.Sent)
return $"(MC.ThreadId = '{potentialThread.threadId}')";
return $"(MC.ThreadId = '{potentialThread.threadId}' AND MC.FolderId IN ('{potentialThread.threadingFolder.Id}','{sentFolderId}','{draftFolderId}'))";
}
}
}
}

View File

@@ -0,0 +1,9 @@
using Wino.Domain.Interfaces;
namespace Wino.Services.Threading
{
public class GmailThreadingStrategy : APIThreadingStrategy, IGmailThreadingStrategy
{
public GmailThreadingStrategy(IDatabaseService databaseService, IFolderService folderService) : base(databaseService, folderService) { }
}
}

View File

@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using SqlKata;
using Wino.Domain.Entities;
using Wino.Domain.Enums;
using Wino.Domain.Interfaces;
using Wino.Domain.Models.MailItem;
using Wino.Services.Extensions;
namespace Wino.Services.Threading
{
public class ImapThreadStrategy : IThreadingStrategy, IImapThreadStrategy
{
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, SpecialFolderType.Sent);
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, SpecialFolderType.Draft);
// Threading is not possible. Return items as it is.
if (sentFolder == null || draftFolder == null) return new List<IMailItem>(items);
foreach (var replyItem in items)
{
if (mailLookupTable[replyItem.Id])
continue;
mailLookupTable[replyItem.Id] = true;
var threadItem = new ThreadMailItem();
threadItem.AddThreadItem(replyItem);
var replyToChild = await GetReplyParentAsync(replyItem, accountId, replyItem.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
// Build up
while (replyToChild != null)
{
replyToChild.AssignedAccount = account;
if (replyToChild.FolderId == draftFolder.Id)
replyToChild.AssignedFolder = draftFolder;
if (replyToChild.FolderId == sentFolder.Id)
replyToChild.AssignedFolder = sentFolder;
if (replyToChild.FolderId == replyItem.AssignedFolder.Id)
replyToChild.AssignedFolder = replyItem.AssignedFolder;
threadItem.AddThreadItem(replyToChild);
if (mailLookupTable.ContainsKey(replyToChild.Id))
mailLookupTable[replyToChild.Id] = true;
replyToChild = await GetReplyParentAsync(replyToChild, accountId, replyToChild.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
}
// Build down
var replyToParent = await GetInReplyToReplyAsync(replyItem, accountId, replyItem.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
while (replyToParent != null)
{
replyToParent.AssignedAccount = account;
if (replyToParent.FolderId == draftFolder.Id)
replyToParent.AssignedFolder = draftFolder;
if (replyToParent.FolderId == sentFolder.Id)
replyToParent.AssignedFolder = sentFolder;
if (replyToParent.FolderId == replyItem.AssignedFolder.Id)
replyToParent.AssignedFolder = replyItem.AssignedFolder;
threadItem.AddThreadItem(replyToParent);
if (mailLookupTable.ContainsKey(replyToParent.Id))
mailLookupTable[replyToParent.Id] = true;
replyToParent = await GetInReplyToReplyAsync(replyToParent, accountId, replyToParent.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
}
// It's a thread item.
if (threadItem.ThreadItems.Count > 1 && !threads.Exists(a => a.Id == threadItem.Id))
{
threads.Add(threadItem);
}
else
{
// False alert. This is not a thread item.
mailLookupTable[replyItem.Id] = false;
// TODO: Here potentially check other algorithms for threading like References.
}
}
// At this points all mails in the list belong to single items.
// Merge with threads.
// Last sorting will be done later on in MailService.
// Remove single mails that are included in thread.
items.RemoveAll(a => mailLookupTable.ContainsKey(a.Id) && mailLookupTable[a.Id]);
var finalList = new List<IMailItem>(items);
finalList.AddRange(threads);
return finalList;
}
public bool ShouldThreadWithItem(IMailItem originalItem, IMailItem targetItem)
{
bool isChild = originalItem.InReplyTo != null && originalItem.InReplyTo == targetItem.MessageId;
bool isParent = originalItem.MessageId != null && originalItem.MessageId == targetItem.InReplyTo;
return isChild || isParent;
}
}
}

View File

@@ -0,0 +1,13 @@
using Wino.Domain.Interfaces;
namespace Wino.Services.Threading
{
// Outlook and Gmail is using the same threading strategy.
// Outlook: ConversationId -> it's set as ThreadId
// Gmail: ThreadId
public class OutlookThreadingStrategy : APIThreadingStrategy, IOutlookThreadingStrategy
{
public OutlookThreadingStrategy(IDatabaseService databaseService, IFolderService folderService) : base(databaseService, folderService) { }
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Wino.Core.Domain\Wino.Domain.csproj" />
<ProjectReference Include="..\Wino.Messaging\Wino.Messaging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="SqlKata" Version="2.4.0" />
<PackageReference Include="morelinq" Version="4.3.0" />
<PackageReference Include="CommunityToolkit.Diagnostics" Version="8.2.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.61.3" />
</ItemGroup>
</Project>