Initial commit.

This commit is contained in:
Burak Kaan Köse
2024-04-18 01:44:37 +02:00
parent 524ea4c0e1
commit 12d3814626
671 changed files with 77295 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Services;
namespace Wino.Core.Authenticators
{
public abstract class BaseAuthenticator
{
public abstract MailProviderType ProviderType { get; }
protected ITokenService TokenService { get; }
protected BaseAuthenticator(ITokenService tokenService)
{
TokenService = tokenService;
}
internal Task SaveTokenInternalAsync(MailAccount account, TokenInformation tokenInformation)
=> TokenService.SaveTokenInformationAsync(account.Id, tokenInformation);
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Services;
namespace Wino.Core.Authenticators
{
public class CustomAuthenticator : BaseAuthenticator, IAuthenticator
{
public CustomAuthenticator(ITokenService tokenService) : base(tokenService) { }
public override MailProviderType ProviderType => MailProviderType.IMAP4;
public string ClientId => throw new NotImplementedException(); // Not needed.
public event EventHandler<string> InteractiveAuthenticationRequired;
public void CancelAuthorization() { }
public void ContinueAuthorization(Uri authorizationResponseUri) { }
public Task<TokenInformation> GenerateTokenAsync(MailAccount account, bool saveToken)
{
throw new NotImplementedException();
}
public Task<TokenInformation> GetTokenAsync(MailAccount account)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,219 @@
using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using Nito.AsyncEx;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Authentication;
using Wino.Core.Domain.Models.Authorization;
using Wino.Core.Services;
using Xamarin.Essentials;
namespace Wino.Core.Authenticators
{
public class GmailAuthenticator : BaseAuthenticator, IAuthenticator
{
public string ClientId { get; } = "973025879644-s7b4ur9p3rlgop6a22u7iuptdc0brnrn.apps.googleusercontent.com";
private const string TokenEndpoint = "https://www.googleapis.com/oauth2/v4/token";
private const string RefreshTokenEndpoint = "https://oauth2.googleapis.com/token";
private const string UserInfoEndpoint = "https://gmail.googleapis.com/gmail/v1/users/me/profile";
public override MailProviderType ProviderType => MailProviderType.Gmail;
private TaskCompletionSource<Uri> _authorizationCompletionSource = null;
private CancellationTokenSource _authorizationCancellationTokenSource = null;
private readonly INativeAppService _nativeAppService;
public event EventHandler<string> InteractiveAuthenticationRequired;
public GmailAuthenticator(ITokenService tokenService, INativeAppService nativeAppService) : base(tokenService)
{
_nativeAppService = nativeAppService;
}
/// <summary>
/// Performs tokenization code exchange and retrieves the actual Access - Refresh tokens from Google
/// after redirect uri returns from browser.
/// </summary>
/// <param name="tokenizationRequest">Tokenization request.</param>
/// <exception cref="GoogleAuthenticationException">In case of network or parsing related error.</exception>
private async Task<TokenInformation> PerformCodeExchangeAsync(GoogleTokenizationRequest tokenizationRequest)
{
var uri = tokenizationRequest.BuildRequest();
var content = new StringContent(uri, Encoding.UTF8, "application/x-www-form-urlencoded");
var handler = new HttpClientHandler()
{
AllowAutoRedirect = true
};
var client = new HttpClient(handler);
var response = await client.PostAsync(TokenEndpoint, content);
string responseString = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new GoogleAuthenticationException(Translator.Exception_GoogleAuthorizationCodeExchangeFailed);
var parsed = JObject.Parse(responseString);
if (parsed.ContainsKey("error"))
throw new GoogleAuthenticationException(parsed["error"]["message"].Value<string>());
var accessToken = parsed["access_token"].Value<string>();
var refreshToken = parsed["refresh_token"].Value<string>();
var expiresIn = parsed["expires_in"].Value<long>();
var expirationDate = DateTime.UtcNow.AddSeconds(expiresIn);
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
// Get basic user info for UserName.
var userinfoResponse = await client.GetAsync(UserInfoEndpoint);
string userinfoResponseContent = await userinfoResponse.Content.ReadAsStringAsync();
var parsedUserInfo = JObject.Parse(userinfoResponseContent);
if (parsedUserInfo.ContainsKey("error"))
throw new GoogleAuthenticationException(parsedUserInfo["error"]["message"].Value<string>());
var username = parsedUserInfo["emailAddress"].Value<string>();
return new TokenInformation()
{
Id = Guid.NewGuid(),
Address = username,
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresAt = expirationDate
};
}
public void ContinueAuthorization(Uri authorizationResponseUri) => _authorizationCompletionSource?.TrySetResult(authorizationResponseUri);
public async Task<TokenInformation> GetTokenAsync(MailAccount account)
{
var cachedToken = await TokenService.GetTokenInformationAsync(account.Id)
?? throw new AuthenticationAttentionException(account);
if (cachedToken.IsExpired)
{
// Refresh token with new exchanges.
// No need to check Username for account.
var refreshedTokenInfoBase = await RefreshTokenAsync(cachedToken.RefreshToken);
cachedToken.RefreshTokens(refreshedTokenInfoBase);
// Save new token and return.
await SaveTokenInternalAsync(account, cachedToken);
}
return cachedToken;
}
public async Task<TokenInformation> GenerateTokenAsync(MailAccount account, bool saveToken)
{
var authRequest = _nativeAppService.GetGoogleAuthorizationRequest();
_authorizationCompletionSource = new TaskCompletionSource<Uri>();
_authorizationCancellationTokenSource = new CancellationTokenSource();
var authorizationUri = authRequest.BuildRequest(ClientId);
await Browser.OpenAsync(authorizationUri, BrowserLaunchMode.SystemPreferred);
Uri responseRedirectUri = null;
try
{
responseRedirectUri = await _authorizationCompletionSource.Task.WaitAsync(_authorizationCancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
throw new AuthenticationException(Translator.Exception_AuthenticationCanceled);
}
finally
{
_authorizationCancellationTokenSource.Dispose();
_authorizationCancellationTokenSource = null;
_authorizationCompletionSource = null;
}
authRequest.ValidateAuthorizationCode(responseRedirectUri);
// Start tokenization.
var tokenizationRequest = new GoogleTokenizationRequest(authRequest);
var tokenInformation = await PerformCodeExchangeAsync(tokenizationRequest);
if (saveToken)
{
await SaveTokenInternalAsync(account, tokenInformation);
}
return tokenInformation;
}
/// <summary>
/// Internally exchanges refresh token with a new access token and returns new TokenInformation.
/// </summary>
/// <param name="refresh_token">Token to be used in refreshing.</param>
/// <returns>New TokenInformationBase that has new tokens and expiration date without a username. This token is not saved to database after returned.</returns>
private async Task<TokenInformationBase> RefreshTokenAsync(string refresh_token)
{
// TODO: This doesn't work.
var refreshUri = string.Format("client_id={0}&refresh_token={1}&grant_type=refresh_token", ClientId, refresh_token);
//Uri.EscapeDataString(refreshUri);
var content = new StringContent(refreshUri, Encoding.UTF8, "application/x-www-form-urlencoded");
var client = new HttpClient();
var response = await client.PostAsync(RefreshTokenEndpoint, content);
string responseString = await response.Content.ReadAsStringAsync();
var parsed = JObject.Parse(responseString);
// TODO: Error parsing is incorrect.
if (parsed.ContainsKey("error"))
throw new GoogleAuthenticationException(parsed["error_description"].Value<string>());
var accessToken = parsed["access_token"].Value<string>();
string activeRefreshToken = refresh_token;
// Refresh token might not be returned.
// In this case older refresh token is still available for new refreshes.
// Only change if provided.
if (parsed.ContainsKey("refresh_token"))
{
activeRefreshToken = parsed["refresh_token"].Value<string>();
}
var expiresIn = parsed["expires_in"].Value<long>();
var expirationDate = DateTime.UtcNow.AddSeconds(expiresIn);
return new TokenInformationBase()
{
AccessToken = accessToken,
ExpiresAt = expirationDate,
RefreshToken = activeRefreshToken
};
}
public void CancelAuthorization() => _authorizationCancellationTokenSource?.Cancel();
}
}

View File

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

View File

@@ -0,0 +1,115 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Identity.Client;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Extensions;
using Wino.Core.Services;
namespace Wino.Core.Authenticators
{
public class OutlookAuthenticator : BaseAuthenticator, IAuthenticator
{
// Outlook
private const string Authority = "https://login.microsoftonline.com/common";
public string ClientId { get; } = "b19c2035-d740-49ff-b297-de6ec561b208";
private readonly string[] MailScope = new string[] { "email", "mail.readwrite", "offline_access", "mail.send" };
public override MailProviderType ProviderType => MailProviderType.Outlook;
private readonly IPublicClientApplication _publicClientApplication;
public OutlookAuthenticator(ITokenService tokenService, INativeAppService nativeAppService) : base(tokenService)
{
var authenticationRedirectUri = nativeAppService.GetWebAuthenticationBrokerUri();
_publicClientApplication = PublicClientApplicationBuilder.Create(ClientId)
.WithAuthority(Authority)
.WithRedirectUri(authenticationRedirectUri)
.Build();
}
#pragma warning disable S1133 // Deprecated code should be removed
[Obsolete("Not used for OutlookAuthenticator.")]
#pragma warning restore S1133 // Deprecated code should be removed
public void ContinueAuthorization(Uri authorizationResponseUri) { }
#pragma warning disable S1133 // Deprecated code should be removed
[Obsolete("Not used for OutlookAuthenticator.")]
#pragma warning restore S1133 // Deprecated code should be removed
public void CancelAuthorization() { }
public async Task<TokenInformation> GetTokenAsync(MailAccount account)
{
var cachedToken = await TokenService.GetTokenInformationAsync(account.Id)
?? throw new AuthenticationAttentionException(account);
// We have token but it's expired.
// Silently refresh the token and save new token.
if (cachedToken.IsExpired)
{
var cachedOutlookAccount = (await _publicClientApplication.GetAccountsAsync()).FirstOrDefault(a => a.Username == account.Address);
// Again, not expected at all...
// Force interactive login at this point.
if (cachedOutlookAccount == null)
{
// What if interactive login info is for different account?
return await GenerateTokenAsync(account, true);
}
else
{
// Silently refresh token from cache.
AuthenticationResult authResult = await _publicClientApplication.AcquireTokenSilent(MailScope, cachedOutlookAccount).ExecuteAsync();
// Save refreshed token and return
var refreshedTokenInformation = authResult.CreateTokenInformation();
await TokenService.SaveTokenInformationAsync(account.Id, refreshedTokenInformation);
return refreshedTokenInformation;
}
}
else
return cachedToken;
}
public async Task<TokenInformation> GenerateTokenAsync(MailAccount account, bool saveToken)
{
try
{
var authResult = await _publicClientApplication
.AcquireTokenInteractive(MailScope)
.ExecuteAsync();
var tokenInformation = authResult.CreateTokenInformation();
if (saveToken)
{
await SaveTokenInternalAsync(account, tokenInformation);
}
return tokenInformation;
}
catch (MsalClientException msalClientException)
{
if (msalClientException.ErrorCode == "authentication_canceled" || msalClientException.ErrorCode == "access_denied")
throw new AccountSetupCanceledException();
throw;
}
throw new AuthenticationException(Translator.Exception_UnknowErrorDuringAuthentication, new Exception(Translator.Exception_TokenGenerationFailed));
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Services;
namespace Wino.Core.Authenticators
{
public class YahooAuthenticator : BaseAuthenticator, IAuthenticator
{
public YahooAuthenticator(ITokenService tokenService) : base(tokenService) { }
public override MailProviderType ProviderType => MailProviderType.Yahoo;
public string ClientId => throw new NotImplementedException();
public event EventHandler<string> InteractiveAuthenticationRequired;
public void CancelAuthorization()
{
throw new NotImplementedException();
}
public void ContinueAuthorization(Uri authorizationResponseUri)
{
throw new NotImplementedException();
}
public Task<TokenInformation> GenerateTokenAsync(MailAccount account, bool saveToken)
{
throw new NotImplementedException();
}
public Task<TokenInformation> GetTokenAsync(MailAccount account)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.Extensions.DependencyInjection;
using Serilog.Core;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration.Processors;
using Wino.Core.Integration.Threading;
using Wino.Core.Services;
namespace Wino.Core
{
public static class CoreContainerSetup
{
public static void RegisterCoreServices(this IServiceCollection services)
{
var loggerLevelSwitcher = new LoggingLevelSwitch();
services.AddSingleton(loggerLevelSwitcher);
services.AddSingleton<ILogInitializer, LogInitializer>();
services.AddSingleton<ITranslationService, TranslationService>();
services.AddSingleton<IDatabaseService, DatabaseService>();
services.AddSingleton<IWinoSynchronizerFactory, WinoSynchronizerFactory>();
services.AddSingleton<IThreadingStrategyProvider, ThreadingStrategyProvider>();
services.AddSingleton<IMimeFileService, MimeFileService>();
services.AddTransient<IDefaultChangeProcessor, DefaultChangeProcessor>();
services.AddTransient<ITokenService, TokenService>();
services.AddTransient<IProviderService, ProviderService>();
services.AddTransient<IFolderService, FolderService>();
services.AddTransient<IMailService, MailService>();
services.AddTransient<IAccountService, AccountService>();
services.AddTransient<IContactService, ContactService>();
services.AddTransient<ISignatureService, SignatureService>();
services.AddTransient<IWinoRequestProcessor, WinoRequestProcessor>();
services.AddTransient<IWinoRequestDelegator, WinoRequestDelegator>();
services.AddTransient<IImapTestService, ImapTestService>();
services.AddTransient<IAuthenticationProvider, AuthenticationProvider>();
services.AddTransient<IAutoDiscoveryService, AutoDiscoveryService>();
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
services.AddTransient<IFontService, FontService>();
services.AddTransient<OutlookThreadingStrategy>();
services.AddTransient<GmailThreadingStrategy>();
services.AddTransient<ImapThreadStrategy>();
}
}
}

View File

@@ -0,0 +1,54 @@
using System.Linq;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.MenuItems;
namespace Wino.Core.Extensions
{
public static class FolderTreeExtensions
{
public static AccountMenuItem GetAccountMenuTree(this AccountFolderTree accountTree, IMenuItem parentMenuItem = null)
{
var accountMenuItem = new AccountMenuItem(accountTree.Account, parentMenuItem);
foreach (var structure in accountTree.Folders)
{
var tree = GetMenuItemByFolderRecursive(structure, accountMenuItem, null);
accountMenuItem.SubMenuItems.Add(tree);
}
// Create flat folder hierarchy for ease of access.
accountMenuItem.FlattenedFolderHierarchy = ListExtensions
.FlattenBy(accountMenuItem.SubMenuItems, a => a.SubMenuItems)
.Where(a => a is FolderMenuItem)
.Cast<FolderMenuItem>()
.ToList();
return accountMenuItem;
}
private static MenuItemBase<IMailItemFolder, FolderMenuItem> GetMenuItemByFolderRecursive(IMailItemFolder structure, AccountMenuItem parentAccountMenuItem, IMenuItem parentFolderItem)
{
MenuItemBase<IMailItemFolder, FolderMenuItem> parentMenuItem = new FolderMenuItem(structure, parentAccountMenuItem.Parameter, parentAccountMenuItem);
var childStructures = structure.ChildFolders;
foreach (var childFolder in childStructures)
{
if (childFolder == null) continue;
// Folder menu item.
var subChildrenFolderTree = GetMenuItemByFolderRecursive(childFolder, parentAccountMenuItem, parentMenuItem);
if (subChildrenFolderTree is FolderMenuItem folderItem)
{
parentMenuItem.SubMenuItems.Add(folderItem);
}
}
return parentMenuItem;
}
}
}

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Web;
using Google.Apis.Gmail.v1.Data;
using MimeKit;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Extensions
{
public static class GoogleIntegratorExtensions
{
public const string INBOX_LABEL_ID = "INBOX";
public const string UNREAD_LABEL_ID = "UNREAD";
public const string IMPORTANT_LABEL_ID = "IMPORTANT";
public const string STARRED_LABEL_ID = "STARRED";
public const string DRAFT_LABEL_ID = "DRAFT";
public const string SENT_LABEL_ID = "SENT";
private const string SYSTEM_FOLDER_IDENTIFIER = "system";
private const string FOLDER_HIDE_IDENTIFIER = "labelHide";
private static Dictionary<string, SpecialFolderType> KnownFolderDictioanry = new Dictionary<string, SpecialFolderType>()
{
{ INBOX_LABEL_ID, SpecialFolderType.Inbox },
{ "CHAT", SpecialFolderType.Chat },
{ IMPORTANT_LABEL_ID, SpecialFolderType.Important },
{ "TRASH", SpecialFolderType.Deleted },
{ DRAFT_LABEL_ID, SpecialFolderType.Draft },
{ SENT_LABEL_ID, SpecialFolderType.Sent },
{ "SPAM", SpecialFolderType.Junk },
{ STARRED_LABEL_ID, SpecialFolderType.Starred },
{ UNREAD_LABEL_ID, SpecialFolderType.Unread },
{ "FORUMS", SpecialFolderType.Forums },
{ "UPDATES", SpecialFolderType.Updates },
{ "PROMOTIONS", SpecialFolderType.Promotions },
{ "SOCIAL", SpecialFolderType.Social},
{ "PERSONAL", SpecialFolderType.Personal},
};
public static MailItemFolder GetLocalFolder(this Label label, Guid accountId)
{
var unchangedFolderName = label.Name;
if (label.Name.StartsWith("CATEGORY_"))
label.Name = label.Name.Replace("CATEGORY_", "");
bool isSpecialFolder = KnownFolderDictioanry.ContainsKey(label.Name);
bool isAllCapital = label.Name.All(a => char.IsUpper(a));
var specialFolderType = isSpecialFolder ? KnownFolderDictioanry[label.Name] : SpecialFolderType.Other;
return new MailItemFolder()
{
TextColorHex = label.Color?.TextColor,
BackgroundColorHex = label.Color?.BackgroundColor,
FolderName = isAllCapital ? char.ToUpper(label.Name[0]) + label.Name.Substring(1).ToLower() : label.Name, // Capitilize only first letter.
RemoteFolderId = label.Id,
Id = Guid.NewGuid(),
MailAccountId = accountId,
IsSynchronizationEnabled = true,
SpecialFolderType = specialFolderType,
IsSystemFolder = label.Type == SYSTEM_FOLDER_IDENTIFIER,
IsSticky = isSpecialFolder && specialFolderType != SpecialFolderType.Category && !unchangedFolderName.StartsWith("CATEGORY"),
IsHidden = label.LabelListVisibility == FOLDER_HIDE_IDENTIFIER,
// By default, all special folders update unread count in the UI except Trash.
ShowUnreadCount = specialFolderType != SpecialFolderType.Deleted || specialFolderType != SpecialFolderType.Other
};
}
public static bool GetIsDraft(this Message message)
=> message?.LabelIds?.Any(a => a == DRAFT_LABEL_ID) ?? false;
public static bool GetIsUnread(this Message message)
=> message?.LabelIds?.Any(a => a == UNREAD_LABEL_ID) ?? false;
public static bool GetIsFocused(this Message message)
=> message?.LabelIds?.Any(a => a == IMPORTANT_LABEL_ID) ?? false;
public static bool GetIsFlagged(this Message message)
=> message?.LabelIds?.Any(a => a == STARRED_LABEL_ID) ?? false;
/// <summary>
/// Returns MailCopy out of native Gmail message and converted MimeMessage of that native messaage.
/// </summary>
/// <param name="gmailMessage">Gmail Message</param>
/// <param name="mimeMessage">MimeMessage representation of that native message.</param>
/// <returns>MailCopy object that is ready to be inserted to database.</returns>
public static MailCopy AsMailCopy(this Message gmailMessage, MimeMessage mimeMessage)
{
bool isUnread = gmailMessage.GetIsUnread();
bool isFocused = gmailMessage.GetIsFocused();
bool isFlagged = gmailMessage.GetIsFlagged();
bool isDraft = gmailMessage.GetIsDraft();
return new MailCopy()
{
CreationDate = mimeMessage.Date.UtcDateTime,
Subject = HttpUtility.HtmlDecode(mimeMessage.Subject),
FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage),
FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage),
PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet),
ThreadId = gmailMessage.ThreadId,
Importance = (MailImportance)mimeMessage.Importance,
Id = gmailMessage.Id,
IsDraft = isDraft,
HasAttachments = mimeMessage.Attachments.Any(),
IsRead = !isUnread,
IsFlagged = isFlagged,
IsFocused = isFocused,
InReplyTo = mimeMessage.InReplyTo,
MessageId = mimeMessage.MessageId,
References = mimeMessage.References.GetReferences(),
FileId = Guid.NewGuid()
};
}
public static Tuple<MailCopy, MimeMessage, IEnumerable<string>> GetMailDetails(this Message message)
{
MimeMessage mimeMessage = message.GetGmailMimeMessage();
if (mimeMessage == null)
{
// This should never happen.
Debugger.Break();
return default;
}
bool isUnread = message.GetIsUnread();
bool isFocused = message.GetIsFocused();
bool isFlagged = message.GetIsFlagged();
bool isDraft = message.GetIsDraft();
var mailCopy = new MailCopy()
{
CreationDate = mimeMessage.Date.UtcDateTime,
Subject = HttpUtility.HtmlDecode(mimeMessage.Subject),
FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage),
FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage),
PreviewText = HttpUtility.HtmlDecode(message.Snippet),
ThreadId = message.ThreadId,
Importance = (MailImportance)mimeMessage.Importance,
Id = message.Id,
IsDraft = isDraft,
HasAttachments = mimeMessage.Attachments.Any(),
IsRead = !isUnread,
IsFlagged = isFlagged,
IsFocused = isFocused,
InReplyTo = mimeMessage.InReplyTo,
MessageId = mimeMessage.MessageId,
References = mimeMessage.References.GetReferences()
};
return new Tuple<MailCopy, MimeMessage, IEnumerable<string>>(mailCopy, mimeMessage, message.LabelIds);
}
}
}

View File

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

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Extensions
{
public static class ListExtensions
{
public static IEnumerable<T> FlattenBy<T>(this IEnumerable<T> nodes, Func<T, IEnumerable<T>> selector)
{
if (nodes.Any() == false)
return nodes;
var descendants = nodes
.SelectMany(selector)
.FlattenBy(selector);
return nodes.Concat(descendants);
}
public static IEnumerable<IBatchChangeRequest> CreateBatch(this IEnumerable<IGrouping<MailSynchronizerOperation, IRequestBase>> items)
{
IBatchChangeRequest batch = null;
foreach (var group in items)
{
var key = group.Key;
}
yield return batch;
}
public static void AddSorted<T>(this List<T> @this, T item) where T : IComparable<T>
{
if (@this.Count == 0)
{
@this.Add(item);
return;
}
if (@this[@this.Count - 1].CompareTo(item) <= 0)
{
@this.Add(item);
return;
}
if (@this[0].CompareTo(item) >= 0)
{
@this.Insert(0, item);
return;
}
int index = @this.BinarySearch(item);
if (index < 0)
index = ~index;
@this.Insert(index, item);
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Wino.Core.Extensions
{
public static class LongExtensions
{
// Returns the human-readable file size for an arbitrary, 64-bit file size
// The default format is "0.### XB", e.g. "4.2 KB" or "1.434 GB"
public static string GetBytesReadable(this long i)
{
// Get absolute value
long absolute_i = (i < 0 ? -i : i);
// Determine the suffix and readable value
string suffix;
double readable;
if (absolute_i >= 0x1000000000000000) // Exabyte
{
suffix = "EB";
readable = (i >> 50);
}
else if (absolute_i >= 0x4000000000000) // Petabyte
{
suffix = "PB";
readable = (i >> 40);
}
else if (absolute_i >= 0x10000000000) // Terabyte
{
suffix = "TB";
readable = (i >> 30);
}
else if (absolute_i >= 0x40000000) // Gigabyte
{
suffix = "GB";
readable = (i >> 20);
}
else if (absolute_i >= 0x100000) // Megabyte
{
suffix = "MB";
readable = (i >> 10);
}
else if (absolute_i >= 0x400) // Kilobyte
{
suffix = "KB";
readable = i;
}
else
{
return i.ToString("0 B"); // Byte
}
// Divide by 1024 to get fractional value
readable = (readable / 1024);
// Return formatted number with suffix
return readable.ToString("0.# ") + suffix;
}
}
}

View File

@@ -0,0 +1,187 @@
using System;
using System.Linq;
using MailKit;
using MimeKit;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Extensions
{
public static class MailkitClientExtensions
{
public static char MailCopyUidSeparator = '_';
public static uint ResolveUid(string mailCopyId)
{
var splitted = mailCopyId.Split(MailCopyUidSeparator);
if (splitted.Length > 1 && uint.TryParse(splitted[1], out uint parsedUint)) return parsedUint;
throw new ArgumentOutOfRangeException(nameof(mailCopyId), mailCopyId, "Invalid mailCopyId format.");
}
public static string CreateUid(Guid folderId, uint messageUid)
=> $"{folderId}{MailCopyUidSeparator}{messageUid}";
public static MailImportance GetImportance(this MimeMessage messageSummary)
{
if (messageSummary.Headers != null && messageSummary.Headers.Contains(HeaderId.Importance))
{
var rawImportance = messageSummary.Headers[HeaderId.Importance];
return rawImportance switch
{
"Low" => MailImportance.Low,
"High" => MailImportance.High,
_ => MailImportance.Normal,
};
}
return MailImportance.Normal;
}
public static bool GetIsRead(this MessageFlags? flags)
=> flags.GetValueOrDefault().HasFlag(MessageFlags.Seen);
public static bool GetIsFlagged(this MessageFlags? flags)
=> flags.GetValueOrDefault().HasFlag(MessageFlags.Flagged);
public static string GetThreadId(this IMessageSummary messageSummary)
{
// First check whether we have the default values.
if (!string.IsNullOrEmpty(messageSummary.ThreadId))
return messageSummary.ThreadId;
if (messageSummary.GMailThreadId != null)
return messageSummary.GMailThreadId.ToString();
return default;
}
public static string GetMessageId(this MimeMessage mimeMessage)
=> mimeMessage.MessageId;
public static string GetReferences(this MessageIdList messageIdList)
=> string.Join(";", messageIdList);
public static string GetInReplyTo(this MimeMessage mimeMessage)
{
if (mimeMessage.Headers.Contains(HeaderId.InReplyTo))
{
// Normalize if <> brackets are there.
var inReplyTo = mimeMessage.Headers[HeaderId.InReplyTo];
if (inReplyTo.StartsWith("<") && inReplyTo.EndsWith(">"))
return inReplyTo.Substring(1, inReplyTo.Length - 2);
return inReplyTo;
}
return string.Empty;
}
private static string GetPreviewText(this MimeMessage message)
{
if (string.IsNullOrEmpty(message.HtmlBody))
return message.TextBody;
else
return HtmlAgilityPackExtensions.GetPreviewText(message.HtmlBody);
}
public static MailCopy GetMailDetails(this IMessageSummary messageSummary, MailItemFolder folder, MimeMessage mime)
{
// MessageSummary will only have UniqueId, Flags, ThreadId.
// Other properties are extracted directly from the MimeMessage.
// IMAP doesn't have unique id for mails.
// All mails are mapped to specific folders with incremental Id.
// Uid 1 may belong to different messages in different folders, but can never be
// same for different messages in same folders.
// Here we create arbitrary Id that maps the Id of the message with Folder UniqueId.
// When folder becomes invalid, we'll clear out these MailCopies as well.
var messageUid = CreateUid(folder.Id, messageSummary.UniqueId.Id);
var previewText = mime.GetPreviewText();
var copy = new MailCopy()
{
Id = messageUid,
CreationDate = mime.Date.UtcDateTime,
ThreadId = messageSummary.GetThreadId(),
MessageId = mime.GetMessageId(),
Subject = mime.Subject,
IsRead = messageSummary.Flags.GetIsRead(),
IsFlagged = messageSummary.Flags.GetIsFlagged(),
PreviewText = previewText,
FromAddress = GetActualSenderAddress(mime),
FromName = GetActualSenderName(mime),
IsFocused = false,
Importance = mime.GetImportance(),
References = mime.References?.GetReferences(),
InReplyTo = mime.GetInReplyTo(),
HasAttachments = mime.Attachments.Any(),
FileId = Guid.NewGuid()
};
return copy;
}
// TODO: Name and Address parsing should be handled better.
// At some point Wino needs better contact management.
public static string GetActualSenderName(MimeMessage message)
{
if (message == null)
return string.Empty;
// From MimeKit
// The "From" header specifies the author(s) of the message.
// If more than one MimeKit.MailboxAddress is added to the list of "From" addresses,
// the MimeKit.MimeMessage.Sender should be set to the single MimeKit.MailboxAddress
// of the personal actually sending the message.
// Also handle: https://stackoverflow.com/questions/46474030/mailkit-from-address
if (message.Sender != null)
return string.IsNullOrEmpty(message.Sender.Name) ? message.Sender.Address : message.Sender.Name;
else if (message.From?.Mailboxes != null)
{
var firstAvailableName = message.From.Mailboxes.FirstOrDefault(a => !string.IsNullOrEmpty(a.Name))?.Name;
if (string.IsNullOrEmpty(firstAvailableName))
{
var firstAvailableAddress = message.From.Mailboxes.FirstOrDefault(a => !string.IsNullOrEmpty(a.Address))?.Address;
if (!string.IsNullOrEmpty(firstAvailableAddress))
{
return firstAvailableAddress;
}
}
return firstAvailableName;
}
// No sender, no from, I don't know what to do.
return Translator.UnknownSender;
}
// TODO: This is wrong.
public static string GetActualSenderAddress(MimeMessage mime)
{
if (mime == null)
return string.Empty;
bool hasSingleFromMailbox = mime.From.Mailboxes.Count() == 1;
if (hasSingleFromMailbox)
return mime.From.Mailboxes.First().GetAddress(idnEncode: true);
else if (mime.Sender != null)
return mime.Sender.GetAddress(idnEncode: true);
else
return Translator.UnknownSender;
}
}
}

View File

@@ -0,0 +1,22 @@
using System;
using MailKit;
using Wino.Core.Domain.Entities;
namespace Wino.Core.Extensions
{
public static class MailkitExtensions
{
public static MailItemFolder GetLocalFolder(this IMailFolder mailkitMailFolder)
{
return new MailItemFolder()
{
Id = Guid.NewGuid(),
FolderName = mailkitMailFolder.Name,
RemoteFolderId = mailkitMailFolder.FullName,
ParentRemoteFolderId = mailkitMailFolder.ParentFolder?.FullName,
SpecialFolderType = Domain.Enums.SpecialFolderType.Other,
IsSynchronizationEnabled = true
};
}
}
}

View File

@@ -0,0 +1,52 @@
using System.IO;
using System.Text;
using Google.Apis.Gmail.v1.Data;
using MimeKit;
using MimeKit.IO;
using MimeKit.IO.Filters;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
namespace Wino.Core.Extensions
{
public static class MimeExtensions
{
/// <summary>
/// Returns MimeKit.MimeMessage instance for this GMail Message's Raw content.
/// </summary>
/// <param name="message">GMail message.</param>
public static MimeMessage GetGmailMimeMessage(this Message message)
{
if (message == null || message.Raw == null)
return null;
// Gmail raw is not base64 but base64Safe. We need to remove this HTML things.
var base64Encoded = message.Raw.Replace(",", "=").Replace("-", "+").Replace("_", "/");
byte[] bytes = Encoding.ASCII.GetBytes(base64Encoded);
var stream = new MemoryStream(bytes);
// This method will dispose outer stream.
using (stream)
{
using var filtered = new FilteredStream(stream);
filtered.Add(DecoderFilter.Create(ContentEncoding.Base64));
return MimeMessage.Load(filtered);
}
}
public static AddressInformation ToAddressInformation(this MailboxAddress address)
{
if (address == null)
return new AddressInformation() { Name = Translator.UnknownSender, Address = Translator.UnknownAddress };
if (string.IsNullOrEmpty(address.Name))
address.Name = address.Address;
return new AddressInformation() { Name = address.Name, Address = address.Address };
}
}
}

View File

@@ -0,0 +1,65 @@
using System;
using Microsoft.Graph.Models;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Extensions
{
public static class OutlookIntegratorExtensions
{
public static MailItemFolder GetLocalFolder(this MailFolder nativeFolder, Guid accountId)
{
return new MailItemFolder()
{
Id = Guid.NewGuid(),
FolderName = nativeFolder.DisplayName,
RemoteFolderId = nativeFolder.Id,
ParentRemoteFolderId = nativeFolder.ParentFolderId,
IsSynchronizationEnabled = true,
MailAccountId = accountId,
IsHidden = nativeFolder.IsHidden.GetValueOrDefault()
};
}
public static bool GetIsDraft(this Message message)
=> message != null && message.IsDraft.GetValueOrDefault();
public static bool GetIsRead(this Message message)
=> message != null && message.IsRead.GetValueOrDefault();
public static bool GetIsFocused(this Message message)
=> message?.InferenceClassification != null && message.InferenceClassification.Value == InferenceClassificationType.Focused;
public static bool GetIsFlagged(this Message message)
=> message?.Flag?.FlagStatus != null && message.Flag.FlagStatus == FollowupFlagStatus.Flagged;
public static MailCopy AsMailCopy(this Message outlookMessage)
{
bool isDraft = GetIsDraft(outlookMessage);
var mailCopy = new MailCopy()
{
MessageId = outlookMessage.InternetMessageId,
IsFlagged = GetIsFlagged(outlookMessage),
IsFocused = GetIsFocused(outlookMessage),
Importance = !outlookMessage.Importance.HasValue ? MailImportance.Normal : (MailImportance)outlookMessage.Importance.Value,
IsRead = GetIsRead(outlookMessage),
IsDraft = isDraft,
CreationDate = outlookMessage.ReceivedDateTime.GetValueOrDefault().DateTime,
HasAttachments = outlookMessage.HasAttachments.GetValueOrDefault(),
PreviewText = outlookMessage.BodyPreview,
Id = outlookMessage.Id,
ThreadId = outlookMessage.ConversationId,
FromName = outlookMessage.From?.EmailAddress?.Name,
FromAddress = outlookMessage.From?.EmailAddress?.Address,
Subject = outlookMessage.Subject,
FileId = Guid.NewGuid()
};
if (mailCopy.IsDraft)
mailCopy.DraftId = mailCopy.ThreadId;
return mailCopy;
}
}
}

View File

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

View File

@@ -0,0 +1,22 @@
using System;
namespace Wino.Core.Extensions
{
public static class StringExtensions
{
public static bool Contains(this string source, string toCheck, StringComparison comp)
{
return source?.IndexOf(toCheck, comp) >= 0;
}
public static string ReplaceFirst(this string text, string search, string replace)
{
int pos = text.IndexOf(search);
if (pos < 0)
{
return text;
}
return text.Substring(0, pos) + replace + text.Substring(pos + search.Length);
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using Microsoft.Identity.Client;
using Wino.Core.Domain.Entities;
namespace Wino.Core.Extensions
{
public static class TokenizationExtensions
{
public static TokenInformation CreateTokenInformation(this AuthenticationResult clientBuilderResult)
{
var expirationDate = clientBuilderResult.ExpiresOn.UtcDateTime;
var accesToken = clientBuilderResult.AccessToken;
var userName = clientBuilderResult.Account.Username;
// MSAL does not expose refresh token for security reasons.
// This token info will be created without refresh token.
// but OutlookIntegrator will ask for publicApplication to refresh it
// in case of expiration.
var tokenInfo = new TokenInformation()
{
ExpiresAt = expirationDate,
AccessToken = accesToken,
Address = userName,
Id = Guid.NewGuid(),
};
return tokenInfo;
}
}
}

View File

@@ -0,0 +1,10 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "<Pending>", Scope = "member", Target = "~P:Wino.Core.Models.IMailDisplayInformation.asd")]
[assembly: SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions", Justification = "<Pending>", Scope = "member", Target = "~M:Wino.Core.Services.WinoRequestProcessor.PrepareRequestsAsync(Wino.Core.Domain.Enums.MailOperation,System.Collections.Generic.IEnumerable{System.String})~System.Threading.Tasks.Task{System.Collections.Generic.List{Wino.Core.Abstractions.Interfaces.Data.IWinoChangeRequest}}")]
[assembly: SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions", Justification = "<Pending>", Scope = "member", Target = "~M:Wino.Core.Services.SynchronizationWorker.QueueAsync(System.Collections.Generic.IEnumerable{Wino.Core.Abstractions.Interfaces.Data.IWinoChangeRequest})")]

View File

@@ -0,0 +1,29 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Google.Apis.Http;
using Wino.Core.Domain.Entities;
namespace Wino.Core.Http
{
internal class GmailClientMessageHandler : ConfigurableMessageHandler
{
public Func<Task<TokenInformation>> TokenRetrieveDelegate { get; }
public GmailClientMessageHandler(Func<Task<TokenInformation>> tokenRetrieveDelegate) : base(new HttpClientHandler())
{
TokenRetrieveDelegate = tokenRetrieveDelegate;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var tokenizationTask = TokenRetrieveDelegate.Invoke();
var tokenInformation = await tokenizationTask;
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenInformation.AccessToken);
return await base.SendAsync(request, cancellationToken);
}
}
}

View File

@@ -0,0 +1,19 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Wino.Core.Http
{
/// <summary>
/// Adds additional Prefer header for immutable id support in the Graph service client.
/// </summary>
public class MicrosoftImmutableIdHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.TryAddWithoutValidation("Prefer", "IdType=\"ImmutableId\"");
return base.SendAsync(request, cancellationToken);
}
}
}

View File

@@ -0,0 +1,36 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
namespace Wino.Core.Http
{
/// <summary>
/// We need to generate HttpRequestMessage for batch requests, and sometimes we need to
/// serialize content as json. However, some of the fields like 'ODataType' must be ignored
/// in order PATCH requests to succeed. Therefore Microsoft account synchronizer uses
/// special JsonSerializerSettings for ignoring some of the properties.
/// </summary>
public class MicrosoftJsonContractResolver : DefaultContractResolver
{
private readonly HashSet<string> ignoreProps = new HashSet<string>()
{
"ODataType"
};
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
JsonProperty property = base.CreateProperty(member, memberSerialization);
if (ignoreProps.Contains(property.PropertyName))
{
property.ShouldSerialize = _ => false;
}
return property;
}
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Kiota.Abstractions.Authentication;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Http
{
public class MicrosoftTokenProvider : IAccessTokenProvider
{
private readonly MailAccount _account;
private readonly IAuthenticator _authenticator;
public MicrosoftTokenProvider(MailAccount account, IAuthenticator authenticator)
{
_account = account;
_authenticator = authenticator;
}
public AllowedHostsValidator AllowedHostsValidator { get; }
public async Task<string> GetAuthorizationTokenAsync(Uri uri,
Dictionary<string, object> additionalAuthenticationContext = null,
CancellationToken cancellationToken = default)
{
var token = await _authenticator.GetTokenAsync(_account).ConfigureAwait(false);
return token?.AccessToken;
}
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MailKit.Net.Imap;
using MoreLinq;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
using Wino.Core.Requests.Bundles;
namespace Wino.Core.Integration
{
public abstract class BaseMailIntegrator<TNativeRequestType>
{
/// <summary>
/// How many items per single HTTP call can be modified.
/// </summary>
public abstract uint BatchModificationSize { get; }
/// <summary>
/// How many items must be downloaded per folder when the folder is first synchronized.
/// </summary>
public abstract uint InitialMessageDownloadCountPerFolder { get; }
/// <summary>
/// Creates a batched HttpBundle without a response for a collection of MailItem.
/// </summary>
/// <param name="batchChangeRequest">Generated batch request.</param>
/// <param name="action">An action to get the native request from the MailItem.</param>
/// <returns>Collection of http bundle that contains batch and native request.</returns>
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateBatchedHttpBundleFromGroup(
IBatchChangeRequest batchChangeRequest,
Func<IEnumerable<IRequest>, TNativeRequestType> action)
{
if (batchChangeRequest.Items == null) yield break;
var groupedItems = batchChangeRequest.Items.Batch((int)BatchModificationSize);
foreach (var group in groupedItems)
yield return new HttpRequestBundle<TNativeRequestType>(action(group), batchChangeRequest);
}
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateBatchedHttpBundle(
IBatchChangeRequest batchChangeRequest,
Func<IRequest, TNativeRequestType> action)
{
if (batchChangeRequest.Items == null) yield break;
var groupedItems = batchChangeRequest.Items.Batch((int)BatchModificationSize);
foreach (var group in groupedItems)
foreach (var item in group)
yield return new HttpRequestBundle<TNativeRequestType>(action(item), item);
yield break;
}
/// <summary>
/// Creates a single HttpBundle without a response for a collection of MailItem.
/// </summary>
/// <param name="batchChangeRequest">Batch request</param>
/// <param name="action">An action to get the native request from the MailItem</param>
/// <returns>Collection of http bundle that contains batch and native request.</returns>
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateHttpBundle(
IBatchChangeRequest batchChangeRequest,
Func<IRequest, TNativeRequestType> action)
{
if (batchChangeRequest.Items == null) yield break;
foreach (var item in batchChangeRequest.Items)
yield return new HttpRequestBundle<TNativeRequestType>(action(item), batchChangeRequest);
}
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateHttpBundle<TResponseType>(
IBatchChangeRequest batchChangeRequest,
Func<IRequest, TNativeRequestType> action)
{
if (batchChangeRequest.Items == null) yield break;
foreach (var item in batchChangeRequest.Items)
yield return new HttpRequestBundle<TNativeRequestType, TResponseType>(action(item), item);
}
/// <summary>
/// Creates HttpBundle with TResponse of expected response type from the http call for each of the items in the batch.
/// </summary>
/// <typeparam name="TResponse">Expected http response type after the call.</typeparam>
/// <param name="batchChangeRequest">Generated batch request.</param>
/// <param name="action">An action to get the native request from the MailItem.</param>
/// <returns>Collection of http bundle that contains batch and native request.</returns>
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateHttpBundleWithResponse<TResponse>(
IBatchChangeRequest batchChangeRequest,
Func<IRequest, TNativeRequestType> action)
{
if (batchChangeRequest.Items == null) yield break;
foreach (var item in batchChangeRequest.Items)
yield return new HttpRequestBundle<TNativeRequestType, TResponse>(action(item), batchChangeRequest);
}
/// <summary>
/// Creates a batched HttpBundle with TResponse of expected response type from the http call for each of the items in the batch.
/// Func will be executed for each item separately in the batch request.
/// </summary>
/// <typeparam name="TResponse">Expected http response type after the call.</typeparam>
/// <param name="batchChangeRequest">Generated batch request.</param>
/// <param name="action">An action to get the native request from the MailItem.</param>
/// <returns>Collection of http bundle that contains batch and native request.</returns>
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateBatchedHttpBundle<TResponse>(
IBatchChangeRequest batchChangeRequest,
Func<IRequest, TNativeRequestType> action)
{
if (batchChangeRequest.Items == null) yield break;
var groupedItems = batchChangeRequest.Items.Batch((int)BatchModificationSize);
foreach (var group in groupedItems)
foreach (var item in group)
yield return new HttpRequestBundle<TNativeRequestType, TResponse>(action(item), item);
yield break;
}
public IEnumerable<IRequestBundle<ImapRequest>> CreateTaskBundle(Func<ImapClient, Task> value, IRequestBase request)
{
var imapreq = new ImapRequest(value, request);
return [new ImapRequestBundle(imapreq, request)];
}
}
}

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using MailKit.Net.Imap;
using MailKit.Net.Proxy;
using MailKit.Security;
using Serilog;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
namespace Wino.Core.Integration
{
/// <summary>
/// Provides a pooling mechanism for ImapClient.
/// Makes sure that we don't have too many connections to the server.
/// Rents a connected & authenticated client from the pool all the time.
/// TODO: Keeps the clients alive by sending NOOP command periodically.
/// TODO: Listens to the Inbox folder for new messages.
/// </summary>
/// <param name="customServerInformation">Connection/Authentication info to be used to configure ImapClient.</param>
public class ImapClientPool
{
// Hardcoded implementation details for ID extension if the server supports.
// Some providers like Chinese 126 require Id to be sent before authentication.
// We don't expose any customer data here. Therefore it's safe for now.
// Later on maybe we can make it configurable and leave it to the user with passing
// real implementation details.
private readonly ImapImplementation _implementation = new ImapImplementation()
{
Version = "1.0",
OS = "Windows",
Vendor = "Wino"
};
private const int MaxPoolSize = 5;
private readonly ConcurrentBag<ImapClient> _clients = [];
private readonly SemaphoreSlim _semaphore = new(MaxPoolSize);
private readonly CustomServerInformation _customServerInformation;
private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
public ImapClientPool(CustomServerInformation customServerInformation)
{
_customServerInformation = customServerInformation;
}
private async Task EnsureConnectivityAsync(ImapClient client, bool isCreatedNew)
{
try
{
await EnsureConnectedAsync(client);
if (isCreatedNew && client.IsConnected)
{
// Activate supported pre-auth capabilities.
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
await client.CompressAsync();
// Identify if the server supports ID extension.
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
await client.IdentifyAsync(_implementation);
}
await EnsureAuthenticatedAsync(client);
if (isCreatedNew && client.IsAuthenticated)
{
// Activate post-auth capabilities.
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
await client.EnableQuickResyncAsync();
}
}
catch (Exception ex)
{
throw new ImapClientPoolException(ex);
}
finally
{
// Release it even if it fails.
_semaphore.Release();
}
}
public async Task<ImapClient> GetClientAsync()
{
await _semaphore.WaitAsync();
if (_clients.TryTake(out ImapClient item))
{
await EnsureConnectivityAsync(item, false);
return item;
}
var client = CreateNewClient();
await EnsureConnectivityAsync(client, true);
return client;
}
public void Release(ImapClient item)
{
if (item != null)
{
_clients.Add(item);
_semaphore.Release();
}
}
public ImapClient CreateNewClient()
{
var client = new ImapClient();
HttpProxyClient proxyClient = null;
// Add proxy client if exists.
if (!string.IsNullOrEmpty(_customServerInformation.ProxyServer))
{
proxyClient = new HttpProxyClient(_customServerInformation.ProxyServer, int.Parse(_customServerInformation.ProxyServerPort));
}
client.ProxyClient = proxyClient;
_logger.Debug("Created new ImapClient. Current clients: {Count}", _clients.Count);
return client;
}
private SecureSocketOptions GetSocketOptions(ImapConnectionSecurity connectionSecurity)
=> connectionSecurity switch
{
ImapConnectionSecurity.Auto => SecureSocketOptions.Auto,
ImapConnectionSecurity.None => SecureSocketOptions.None,
ImapConnectionSecurity.StartTls => SecureSocketOptions.StartTlsWhenAvailable,
ImapConnectionSecurity.SslTls => SecureSocketOptions.SslOnConnect,
_ => SecureSocketOptions.None
};
public async Task EnsureConnectedAsync(ImapClient client)
{
if (client.IsConnected) return;
await client.ConnectAsync(_customServerInformation.IncomingServer,
int.Parse(_customServerInformation.IncomingServerPort),
GetSocketOptions(_customServerInformation.IncomingServerSocketOption));
}
public async Task EnsureAuthenticatedAsync(ImapClient client)
{
if (client.IsAuthenticated) return;
switch (_customServerInformation.IncomingAuthenticationMethod)
{
case ImapAuthenticationMethod.Auto:
break;
case ImapAuthenticationMethod.None:
break;
case ImapAuthenticationMethod.NormalPassword:
break;
case ImapAuthenticationMethod.EncryptedPassword:
break;
case ImapAuthenticationMethod.Ntlm:
break;
case ImapAuthenticationMethod.CramMd5:
break;
case ImapAuthenticationMethod.DigestMd5:
break;
default:
break;
}
await client.AuthenticateAsync(_customServerInformation.IncomingServerUsername, _customServerInformation.IncomingServerPassword);
}
}
}

View File

@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MimeKit;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Services;
namespace Wino.Core.Integration.Processors
{
/// <summary>
/// Database change processor that handles common operations for all synchronizers.
/// When a synchronizer detects a change, it should call the appropriate method in this class to reflect the change in the database.
/// Different synchronizers might need additional implementations.
/// <see cref="IGmailChangeProcessor"/> and <see cref="IOutlookChangeProcessor"/>
/// None of the synchronizers can directly change anything in the database.
/// </summary>
public interface IDefaultChangeProcessor
{
Task<string> UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string deltaSynchronizationIdentifier);
Task<string> UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string deltaSynchronizationIdentifier);
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead);
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
Task<bool> CreateMailAsync(Guid AccountId, NewMailItemPackage package);
Task DeleteMailAsync(Guid accountId, string mailId);
Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId);
Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId);
Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds);
Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId);
// For gmail.
Task UpdateFolderStructureAsync(Guid accountId, List<MailItemFolder> allFolders);
Task DeleteFolderAsync(Guid accountId, string remoteFolderId);
Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options);
Task InsertFolderAsync(MailItemFolder folder);
Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId);
}
public interface IGmailChangeProcessor : IDefaultChangeProcessor
{
}
public interface IOutlookChangeProcessor : IDefaultChangeProcessor
{
}
public class DefaultChangeProcessor(IDatabaseService databaseService,
IFolderService folderService,
IMailService mailService,
IAccountService accountService,
IMimeFileService mimeFileService) : BaseDatabaseService(databaseService), IDefaultChangeProcessor
{
private readonly IFolderService _folderService = folderService;
private readonly IMailService _mailService = mailService;
private readonly IAccountService _accountService = accountService;
private readonly IMimeFileService _mimeFileService = mimeFileService;
public Task<string> UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string synchronizationDeltaIdentifier)
=> _accountService.UpdateSynchronizationIdentifierAsync(accountId, synchronizationDeltaIdentifier);
public Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged)
=> _mailService.ChangeFlagStatusAsync(mailCopyId, isFlagged);
public Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead)
=> _mailService.ChangeReadStatusAsync(mailCopyId, isRead);
public Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
=> _mailService.DeleteAssignmentAsync(accountId, mailCopyId, remoteFolderId);
public Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
=> _mailService.CreateAssignmentAsync(accountId, mailCopyId, remoteFolderId);
public Task DeleteMailAsync(Guid accountId, string mailId)
=> _mailService.DeleteMailAsync(accountId, mailId);
public Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
=> _mailService.CreateMailAsync(accountId, package);
// Folder methods
public Task UpdateFolderStructureAsync(Guid accountId, List<MailItemFolder> allFolders)
=> _folderService.BulkUpdateFolderStructureAsync(accountId, allFolders);
public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
=> _mailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId);
public Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId)
=> _mailService.MapLocalDraftAsync(mailCopyId, newDraftId, newThreadId);
public Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options)
=> _folderService.GetSynchronizationFoldersAsync(options);
public Task<string> UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string deltaSynchronizationIdentifier)
=> _folderService.UpdateFolderDeltaSynchronizationIdentifierAsync(folderId, deltaSynchronizationIdentifier);
public Task DeleteFolderAsync(Guid accountId, string remoteFolderId)
=> _folderService.DeleteFolderAsync(accountId, remoteFolderId);
public Task InsertFolderAsync(MailItemFolder folder)
=> _folderService.InsertFolderAsync(folder);
public Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds)
=> _mailService.GetDownloadedUnreadMailsAsync(accountId, downloadedMailCopyIds);
public Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
=> _folderService.GetKnownUidsForFolderAsync(folderId);
public Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId)
=> _mimeFileService.SaveMimeMessageAsync(fileId, mimeMessage, accountId);
}
}

View File

@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Services;
namespace Wino.Core.Integration.Threading
{
public class APIThreadingStrategy : IThreadingStrategy
{
private readonly IDatabaseService _databaseService;
private readonly IFolderService _folderService;
public APIThreadingStrategy(IDatabaseService databaseService, IFolderService folderService)
{
_databaseService = databaseService;
_folderService = folderService;
}
public virtual bool ShouldThreadWithItem(IMailItem originalItem, IMailItem targetItem)
{
return originalItem.ThreadId != null && originalItem.ThreadId == targetItem.ThreadId;
}
public async Task<List<IMailItem>> ThreadItemsAsync(List<MailCopy> items)
{
var accountId = items.First().AssignedAccount.Id;
var threads = new List<ThreadMailItem>();
var assignedAccount = items.First().AssignedAccount;
// TODO: Can be optimized by moving to the caller.
var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, Domain.Enums.SpecialFolderType.Sent);
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, Domain.Enums.SpecialFolderType.Draft);
if (sentFolder == null || draftFolder == null) return default;
// Child -> Parent approach.
var potentialThreadItems = items.Distinct().Where(a => !string.IsNullOrEmpty(a.ThreadId));
var mailLookupTable = new Dictionary<string, bool>();
// Fill up the mail lookup table to prevent double thread creation.
foreach (var mail in items)
if (!mailLookupTable.ContainsKey(mail.Id))
mailLookupTable.Add(mail.Id, false);
foreach (var potentialItem in potentialThreadItems)
{
if (mailLookupTable[potentialItem.Id])
continue;
mailLookupTable[potentialItem.Id] = true;
var allThreadItems = await GetThreadItemsAsync(potentialItem.ThreadId, accountId, potentialItem.AssignedFolder, sentFolder.Id, draftFolder.Id);
if (allThreadItems.Count == 1)
{
// It's a single item.
// Mark as not-processed as thread.
mailLookupTable[potentialItem.Id] = false;
}
else
{
// Thread item. Mark all items as true in dict.
var threadItem = new ThreadMailItem();
foreach (var childThreadItem in allThreadItems)
{
if (mailLookupTable.ContainsKey(childThreadItem.Id))
mailLookupTable[childThreadItem.Id] = true;
childThreadItem.AssignedAccount = assignedAccount;
childThreadItem.AssignedFolder = await _folderService.GetFolderAsync(childThreadItem.FolderId);
threadItem.AddThreadItem(childThreadItem);
}
// Multiple mail copy ids from different folders are thing for Gmail.
if (threadItem.ThreadItems.Count == 1)
mailLookupTable[potentialItem.Id] = false;
else
threads.Add(threadItem);
}
}
// At this points all mails in the list belong to single items.
// Merge with threads.
// Last sorting will be done later on in MailService.
// Remove single mails that are included in thread.
items.RemoveAll(a => mailLookupTable.ContainsKey(a.Id) && mailLookupTable[a.Id]);
var finalList = new List<IMailItem>(items);
finalList.AddRange(threads);
return finalList;
}
private async Task<List<MailCopy>> GetThreadItemsAsync(string threadId,
Guid accountId,
MailItemFolder threadingFolder,
Guid sentFolderId,
Guid draftFolderId)
{
// Only items from the folder that we are threading for, sent and draft folder items must be included.
// This is important because deleted items or item assignments that belongs to different folder is
// affecting the thread creation here.
// If the threading is done from Sent or Draft folder, include everything...
// TODO: Convert to SQLKata query.
string query = string.Empty;
if (threadingFolder.SpecialFolderType == SpecialFolderType.Draft || threadingFolder.SpecialFolderType == SpecialFolderType.Sent)
{
query = @$"SELECT DISTINCT MC.* FROM MailCopy MC
INNER JOIN MailItemFolder MF on MF.Id = MC.FolderId
WHERE MF.MailAccountId == '{accountId}' AND MC.ThreadId = '{threadId}'";
}
else
{
query = @$"SELECT DISTINCT MC.* FROM MailCopy MC
INNER JOIN MailItemFolder MF on MF.Id = MC.FolderId
WHERE MF.MailAccountId == '{accountId}' AND MC.FolderId IN ('{threadingFolder.Id}','{sentFolderId}','{draftFolderId}')
AND MC.ThreadId = '{threadId}'";
}
return await _databaseService.Connection.QueryAsync<MailCopy>(query);
}
}
}

View File

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

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using SqlKata;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Extensions;
using Wino.Core.Services;
namespace Wino.Core.Integration.Threading
{
public class ImapThreadStrategy : IThreadingStrategy
{
private readonly IDatabaseService _databaseService;
private readonly IFolderService _folderService;
public ImapThreadStrategy(IDatabaseService databaseService, IFolderService folderService)
{
_databaseService = databaseService;
_folderService = folderService;
}
private Task<MailCopy> GetReplyParentAsync(IMailItem replyItem, Guid accountId, Guid threadingFolderId, Guid sentFolderId, Guid draftFolderId)
{
if (string.IsNullOrEmpty(replyItem?.MessageId)) return Task.FromResult<MailCopy>(null);
var query = new Query("MailCopy")
.Distinct()
.Take(1)
.Join("MailItemFolder", "MailItemFolder.Id", "MailCopy.FolderId")
.Where("MailItemFolder.MailAccountId", accountId)
.WhereIn("MailItemFolder.Id", new List<Guid> { threadingFolderId, sentFolderId, draftFolderId })
.Where("MailCopy.MessageId", replyItem.InReplyTo)
.WhereNot("MailCopy.Id", replyItem.Id)
.Select("MailCopy.*");
return _databaseService.Connection.FindWithQueryAsync<MailCopy>(query.GetRawQuery());
}
private Task<MailCopy> GetInReplyToReplyAsync(IMailItem originalItem, Guid accountId, Guid threadingFolderId, Guid sentFolderId, Guid draftFolderId)
{
if (string.IsNullOrEmpty(originalItem?.MessageId)) return Task.FromResult<MailCopy>(null);
var query = new Query("MailCopy")
.Distinct()
.Take(1)
.Join("MailItemFolder", "MailItemFolder.Id", "MailCopy.FolderId")
.WhereNot("MailCopy.Id", originalItem.Id)
.Where("MailItemFolder.MailAccountId", accountId)
.Where("MailCopy.InReplyTo", originalItem.MessageId)
.WhereIn("MailItemFolder.Id", new List<Guid> { threadingFolderId, sentFolderId, draftFolderId })
.Select("MailCopy.*");
var raq = query.GetRawQuery();
return _databaseService.Connection.FindWithQueryAsync<MailCopy>(query.GetRawQuery());
}
public async Task<List<IMailItem>> ThreadItemsAsync(List<MailCopy> items)
{
var threads = new List<ThreadMailItem>();
var account = items.First().AssignedAccount;
var accountId = account.Id;
// Child -> Parent approach.
var mailLookupTable = new Dictionary<string, bool>();
// Fill up the mail lookup table to prevent double thread creation.
foreach (var mail in items)
if (!mailLookupTable.ContainsKey(mail.Id))
mailLookupTable.Add(mail.Id, false);
var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, Domain.Enums.SpecialFolderType.Sent);
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, Domain.Enums.SpecialFolderType.Draft);
if (sentFolder == null || draftFolder == null) return default;
foreach (var replyItem in items)
{
if (mailLookupTable[replyItem.Id])
continue;
mailLookupTable[replyItem.Id] = true;
var threadItem = new ThreadMailItem();
threadItem.AddThreadItem(replyItem);
var replyToChild = await GetReplyParentAsync(replyItem, accountId, replyItem.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
// Build up
while (replyToChild != null)
{
replyToChild.AssignedAccount = account;
if (replyToChild.FolderId == draftFolder.Id)
replyToChild.AssignedFolder = draftFolder;
if (replyToChild.FolderId == sentFolder.Id)
replyToChild.AssignedFolder = sentFolder;
if (replyToChild.FolderId == replyItem.AssignedFolder.Id)
replyToChild.AssignedFolder = replyItem.AssignedFolder;
threadItem.AddThreadItem(replyToChild);
if (mailLookupTable.ContainsKey(replyToChild.Id))
mailLookupTable[replyToChild.Id] = true;
replyToChild = await GetReplyParentAsync(replyToChild, accountId, replyToChild.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
}
// Build down
var replyToParent = await GetInReplyToReplyAsync(replyItem, accountId, replyItem.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
while (replyToParent != null)
{
replyToParent.AssignedAccount = account;
if (replyToParent.FolderId == draftFolder.Id)
replyToParent.AssignedFolder = draftFolder;
if (replyToParent.FolderId == sentFolder.Id)
replyToParent.AssignedFolder = sentFolder;
if (replyToParent.FolderId == replyItem.AssignedFolder.Id)
replyToParent.AssignedFolder = replyItem.AssignedFolder;
threadItem.AddThreadItem(replyToParent);
if (mailLookupTable.ContainsKey(replyToParent.Id))
mailLookupTable[replyToParent.Id] = true;
replyToParent = await GetInReplyToReplyAsync(replyToParent, accountId, replyToParent.AssignedFolder.Id, sentFolder.Id, draftFolder.Id);
}
// It's a thread item.
if (threadItem.ThreadItems.Count > 1 && !threads.Exists(a => a.Id == threadItem.Id))
{
threads.Add(threadItem);
}
else
{
// False alert. This is not a thread item.
mailLookupTable[replyItem.Id] = false;
// TODO: Here potentially check other algorithms for threading like References.
}
}
// At this points all mails in the list belong to single items.
// Merge with threads.
// Last sorting will be done later on in MailService.
// Remove single mails that are included in thread.
items.RemoveAll(a => mailLookupTable.ContainsKey(a.Id) && mailLookupTable[a.Id]);
var finalList = new List<IMailItem>(items);
finalList.AddRange(threads);
return finalList;
}
public bool ShouldThreadWithItem(IMailItem originalItem, IMailItem targetItem)
{
bool isChild = originalItem.InReplyTo != null && originalItem.InReplyTo == targetItem.MessageId;
bool isParent = originalItem.MessageId != null && originalItem.MessageId == targetItem.InReplyTo;
return isChild || isParent;
}
}
}

View File

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

View File

@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
namespace Wino.Core.MenuItems
{
public partial class AccountMenuItem : MenuItemBase<MailAccount, MenuItemBase<IMailItemFolder, FolderMenuItem>>, IAccountMenuItem
{
public List<FolderMenuItem> FlattenedFolderHierarchy { get; set; }
[ObservableProperty]
private int unreadItemCount;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible))]
private double synchronizationProgress;
public bool IsAttentionRequired => AttentionReason != AccountAttentionReason.None;
public bool IsSynchronizationProgressVisible => SynchronizationProgress > 0 && SynchronizationProgress < 100;
public Guid AccountId => Parameter.Id;
private AccountAttentionReason attentionReason;
public AccountAttentionReason AttentionReason
{
get => attentionReason;
set
{
if (SetProperty(ref attentionReason, value))
{
OnPropertyChanged(nameof(IsAttentionRequired));
UpdateFixAccountIssueMenuItem();
}
}
}
public string AccountName
{
get => Parameter.Name;
set => SetProperty(Parameter.Name, value, Parameter, (u, n) => u.Name = n);
}
public IEnumerable<MailAccount> HoldingAccounts => new List<MailAccount> { Parameter };
public AccountMenuItem(MailAccount account, IMenuItem parent = null) : base(account, account.Id, parent)
{
UpdateAccount(account);
}
public void UpdateAccount(MailAccount account)
{
Parameter = account;
AccountName = account.Name;
AttentionReason = account.AttentionReason;
}
private void UpdateFixAccountIssueMenuItem()
{
if (AttentionReason != AccountAttentionReason.None && !SubMenuItems.Any(a => a is FixAccountIssuesMenuItem))
{
// Add fix issue item if not exists.
SubMenuItems.Insert(0, new FixAccountIssuesMenuItem(Parameter, this));
}
else
{
// Remove existing if issue is resolved.
var fixAccountIssueItem = SubMenuItems.FirstOrDefault(a => a is FixAccountIssuesMenuItem);
if (fixAccountIssueItem != null)
{
SubMenuItems.Remove(fixAccountIssueItem);
}
}
}
public int GetUnreadItemCountByFolderType(SpecialFolderType specialFolderType)
=> FlattenedFolderHierarchy?.Where(a => a.SpecialFolderType == specialFolderType).Sum(a => a.UnreadItemCount) ?? 0;
}
}

View File

@@ -0,0 +1,16 @@
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
namespace Wino.Core.MenuItems
{
public class FixAccountIssuesMenuItem : MenuItemBase<IMailItemFolder, FolderMenuItem>
{
public MailAccount Account { get; }
public FixAccountIssuesMenuItem(MailAccount account, IMenuItem parentAccountMenuItem) : base(null, null, parentAccountMenuItem)
{
Account = account;
}
}
}

View File

@@ -0,0 +1,75 @@
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
namespace Wino.Core.MenuItems
{
public partial class FolderMenuItem : MenuItemBase<IMailItemFolder, FolderMenuItem>, IFolderMenuItem
{
[ObservableProperty]
private int unreadItemCount;
public bool HasTextColor => !string.IsNullOrEmpty(Parameter.TextColorHex);
public bool IsMoveTarget => HandlingFolders.All(a => a.IsMoveTarget);
public SpecialFolderType SpecialFolderType => Parameter.SpecialFolderType;
public bool IsSticky => Parameter.IsSticky;
public bool IsSystemFolder => Parameter.IsSystemFolder;
/// <summary>
/// Display name of the folder. More and Category folders have localized display names.
/// </summary>
public string FolderName
{
get
{
if (Parameter.SpecialFolderType == SpecialFolderType.More)
return Translator.MoreFolderNameOverride;
else if (Parameter.SpecialFolderType == SpecialFolderType.Category)
return Translator.CategoriesFolderNameOverride;
else
return Parameter.FolderName;
}
set => SetProperty(Parameter.FolderName, value, Parameter, (u, n) => u.FolderName = n);
}
public bool IsSynchronizationEnabled
{
get => Parameter.IsSynchronizationEnabled;
set => SetProperty(Parameter.IsSynchronizationEnabled, value, Parameter, (u, n) => u.IsSynchronizationEnabled = n);
}
public IEnumerable<IMailItemFolder> HandlingFolders => new List<IMailItemFolder>() { Parameter };
public MailAccount ParentAccount { get; }
public string AssignedAccountName => ParentAccount?.Name;
public bool ShowUnreadCount => Parameter.ShowUnreadCount;
public FolderMenuItem(IMailItemFolder folderStructure, MailAccount parentAccount, IMenuItem parentMenuItem) : base(folderStructure, folderStructure.Id, parentMenuItem)
{
ParentAccount = parentAccount;
}
public void UpdateFolder(IMailItemFolder folder)
{
Parameter = folder;
OnPropertyChanged(nameof(IsSynchronizationEnabled));
OnPropertyChanged(nameof(ShowUnreadCount));
OnPropertyChanged(nameof(HasTextColor));
OnPropertyChanged(nameof(IsSystemFolder));
OnPropertyChanged(nameof(SpecialFolderType));
OnPropertyChanged(nameof(IsSticky));
OnPropertyChanged(nameof(FolderName));
}
public override string ToString() => FolderName;
}
}

View File

@@ -0,0 +1,4 @@
namespace Wino.Core.MenuItems
{
public class ManageAccountsMenuItem : MenuItemBase { }
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.MenuItems
{
public partial class MenuItemBase : ObservableObject, IMenuItem
{
[ObservableProperty]
private bool _isExpanded;
[ObservableProperty]
private bool _isSelected;
public IMenuItem ParentMenuItem { get; }
public Guid? EntityId { get; }
public MenuItemBase(Guid? entityId = null, IMenuItem parentMenuItem = null)
{
EntityId = entityId;
ParentMenuItem = parentMenuItem;
}
public void Expand()
{
// Recursively expand all parent menu items if parent exists, starting from parent.
if (ParentMenuItem != null)
{
IMenuItem parentMenuItem = ParentMenuItem;
while (parentMenuItem != null)
{
parentMenuItem.IsExpanded = true;
parentMenuItem = parentMenuItem.ParentMenuItem;
}
}
// Finally expand itself.
IsExpanded = true;
}
}
public partial class MenuItemBase<T> : MenuItemBase
{
[ObservableProperty]
private T _parameter;
public MenuItemBase(T parameter, Guid? entityId, IMenuItem parentMenuItem = null) : base(entityId, parentMenuItem) => Parameter = parameter;
}
public partial class MenuItemBase<TValue, TCollection> : MenuItemBase<TValue>
{
[ObservableProperty]
private bool _isChildSelected;
protected MenuItemBase(TValue parameter, Guid? entityId, IMenuItem parentMenuItem = null) : base(parameter, entityId, parentMenuItem) { }
public ObservableCollection<TCollection> SubMenuItems { get; set; } = [];
}
}

View File

@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.MenuItems
{
public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
{
public IEnumerable<IBaseFolderMenuItem> GetFolderItems(Guid folderId)
{
var rootItems = this.OfType<AccountMenuItem>()
.SelectMany(a => a.FlattenedFolderHierarchy)
.Where(a => a.Parameter?.Id == folderId)
.Cast<IBaseFolderMenuItem>();
// Accounts that are merged can't exist in the root items.
// Therefore if the folder is found in root items, return it without searching inside merged accounts.
if (rootItems.Any()) return rootItems;
var mergedItems = this.OfType<MergedAccountMenuItem>()
.SelectMany(a => a.SubMenuItems.OfType<MergedAccountFolderMenuItem>()
.Where(a => a.Parameter.Any(b => b.Id == folderId)))
.Cast<IBaseFolderMenuItem>();
// Folder is found in the MergedInbox shared folders.
if (mergedItems.Any()) return mergedItems;
// Folder is not in any of the above. Looks inside the individual accounts in merged inbox account menu item.
var mergedAccountItems = this.OfType<MergedAccountMenuItem>()
.SelectMany(a => a.SubMenuItems.OfType<AccountMenuItem>()
.SelectMany(a => a.FlattenedFolderHierarchy)
.Where(a => a.Parameter?.Id == folderId))
.Cast<IBaseFolderMenuItem>();
return mergedAccountItems;
}
public IBaseFolderMenuItem GetFolderItem(Guid folderId) => GetFolderItems(folderId).FirstOrDefault();
public IAccountMenuItem GetAccountMenuItem(Guid accountId)
{
if (accountId == null) return null;
if (TryGetRootAccountMenuItem(accountId, out IAccountMenuItem rootAccountMenuItem)) return rootAccountMenuItem;
return null;
}
// Pattern: Look for root account menu item only. Don't search inside the merged account menu item.
public bool TryGetRootAccountMenuItem(Guid accountId, out IAccountMenuItem value)
{
value = this.OfType<IAccountMenuItem>().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
value ??= this.OfType<MergedAccountMenuItem>().FirstOrDefault(a => a.EntityId == accountId);
return value != null;
}
// Pattern: Look for root account menu item only and return the folder menu item inside the account menu item that has specific special folder type.
public bool TryGetRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
{
value = this.OfType<AccountMenuItem>()
.Where(a => a.HoldingAccounts.Any(b => b.Id == accountId))
.SelectMany(a => a.FlattenedFolderHierarchy)
.FirstOrDefault(a => a.Parameter?.SpecialFolderType == specialFolderType);
return value != null;
}
// Pattern: Look for special folder menu item inside the loaded folders for Windows Mail style menu items.
public bool TryGetWindowsStyleRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
{
value = this.OfType<IBaseFolderMenuItem>()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
return value != null;
}
// Pattern: Find the merged account menu item and return the special folder menu item that belongs to the merged account menu item.
// This will not look for the folders inside individual account menu items inside merged account menu item.
public bool TryGetMergedAccountSpecialFolderMenuItem(Guid mergedInboxId, SpecialFolderType specialFolderType, out IBaseFolderMenuItem value)
{
value = this.OfType<MergedAccountMenuItem>()
.Where(a => a.EntityId == mergedInboxId)
.SelectMany(a => a.SubMenuItems)
.OfType<MergedAccountFolderMenuItem>()
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
return value != null;
}
// Pattern: Find the child account menu item inside the merged account menu item, locate the special folder menu item inside the child account menu item.
public bool TryGetMergedAccountFolderMenuItemByAccountId(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
{
value = this.OfType<MergedAccountMenuItem>()
.SelectMany(a => a.SubMenuItems)
.OfType<AccountMenuItem>()
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))
?.FlattenedFolderHierarchy
.OfType<FolderMenuItem>()
.FirstOrDefault(a => a.Parameter?.SpecialFolderType == specialFolderType);
return value != null;
}
// Pattern: Find the common folder menu item with special folder type inside the merged account menu item for the given AccountId.
public bool TryGetMergedAccountRootFolderMenuItemByAccountId(Guid accountId, SpecialFolderType specialFolderType, out MergedAccountFolderMenuItem value)
{
value = this.OfType<MergedAccountMenuItem>()
.Where(a => a.HoldingAccounts.Any(b => b.Id == accountId))
.SelectMany(a => a.SubMenuItems)
.OfType<MergedAccountFolderMenuItem>()
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
return value != null;
}
/// <summary>
/// Skips the merged account menu item, but directly returns the Account menu item inside the merged account menu item.
/// </summary>
/// <param name="accountId">Account id to look for.</param>
/// <returns>Direct AccountMenuItem inside the Merged Account menu item if exists.</returns>
public AccountMenuItem GetSpecificAccountMenuItem(Guid accountId)
{
AccountMenuItem accountMenuItem = null;
accountMenuItem = this.OfType<AccountMenuItem>().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
// Look for the items inside the merged accounts if regular menu item is not found.
accountMenuItem ??= this.OfType<MergedAccountMenuItem>()
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))?.SubMenuItems
.OfType<AccountMenuItem>()
.FirstOrDefault();
return accountMenuItem;
}
public void ReplaceFolders(IEnumerable<IMenuItem> folders)
{
ClearFolderAreaMenuItems();
Items.Add(new SeperatorItem());
AddRange(folders);
}
public void AddAccountMenuItem(IAccountMenuItem accountMenuItem)
{
var lastAccount = Items.OfType<IAccountMenuItem>().LastOrDefault();
// Index 0 is always the New Mail button.
var insertIndex = lastAccount == null ? 1 : Items.IndexOf(lastAccount) + 1;
Insert(insertIndex, accountMenuItem);
}
private void ClearFolderAreaMenuItems()
{
var cloneItems = Items.ToList();
foreach (var item in cloneItems)
{
if (item is SeperatorItem || item is IBaseFolderMenuItem || item is MergedAccountMoreFolderMenuItem)
{
item.IsSelected = false;
item.IsExpanded = false;
Remove(item);
}
}
}
}
}

View File

@@ -0,0 +1,110 @@
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
namespace Wino.Core.MenuItems
{
/// <summary>
/// Menu item that holds a list of folders under the merged account menu item.
/// </summary>
public partial class MergedAccountFolderMenuItem : MenuItemBase<List<IMailItemFolder>>, IMergedAccountFolderMenuItem
{
public SpecialFolderType FolderType { get; }
public string FolderName { get; private set; }
// Any of the folders is enough to determine the synchronization enable/disable state.
public bool IsSynchronizationEnabled => HandlingFolders.Any(a => a.IsSynchronizationEnabled);
public bool IsMoveTarget => HandlingFolders.All(a => a.IsMoveTarget);
public IEnumerable<IMailItemFolder> HandlingFolders => Parameter;
// All folders in the list should have the same type.
public SpecialFolderType SpecialFolderType => HandlingFolders.First().SpecialFolderType;
public bool IsSticky => true;
public bool IsSystemFolder => true;
public string AssignedAccountName => MergedInbox?.Name;
public MergedInbox MergedInbox { get; set; }
public bool ShowUnreadCount => HandlingFolders?.Any(a => a.ShowUnreadCount) ?? false;
[ObservableProperty]
private int unreadItemCount;
// Merged account's shared folder menu item does not have an entity id.
// Navigations to specific folders are done by explicit folder id if needed.
public MergedAccountFolderMenuItem(List<IMailItemFolder> parameter, IMenuItem parentMenuItem, MergedInbox mergedInbox) : base(parameter, null, parentMenuItem)
{
Guard.IsNotNull(mergedInbox, nameof(mergedInbox));
Guard.IsNotNull(parameter, nameof(parameter));
Guard.HasSizeGreaterThan(parameter, 0, nameof(parameter));
MergedInbox = mergedInbox;
SetFolderName();
// All folders in the list should have the same type.
FolderType = parameter[0].SpecialFolderType;
}
private void SetFolderName()
{
// Folders that hold more than 1 folder belong to merged account.
// These folders will be displayed as their localized names based on the
// special type they have.
if (HandlingFolders.Count() > 1)
{
FolderName = GetSpecialFolderName(HandlingFolders.First());
}
else
{
// Folder only holds 1 Id, but it's displayed as merged account folder.
FolderName = HandlingFolders.First().FolderName;
}
}
private string GetSpecialFolderName(IMailItemFolder folder)
{
var specialType = folder.SpecialFolderType;
// We only handle 5 different types for combining folders.
// Rest of the types are not supported.
return specialType switch
{
SpecialFolderType.Inbox => Translator.MergedAccountCommonFolderInbox,
SpecialFolderType.Draft => Translator.MergedAccountCommonFolderDraft,
SpecialFolderType.Sent => Translator.MergedAccountCommonFolderSent,
SpecialFolderType.Deleted => Translator.MergedAccountCommonFolderTrash,
SpecialFolderType.Junk => Translator.MergedAccountCommonFolderJunk,
SpecialFolderType.Archive => Translator.MergedAccountCommonFolderArchive,
_ => folder.FolderName,
};
}
public void UpdateFolder(IMailItemFolder folder)
{
var existingFolder = Parameter.FirstOrDefault(a => a.Id == folder.Id);
if (existingFolder == null) return;
Parameter.Remove(existingFolder);
Parameter.Add(folder);
SetFolderName();
OnPropertyChanged(nameof(ShowUnreadCount));
OnPropertyChanged(nameof(IsSynchronizationEnabled));
}
}
}

View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.MenuItems
{
public partial class MergedAccountMenuItem : MenuItemBase<MergedInbox, IMenuItem>, IAccountMenuItem
{
public int MergedAccountCount => GetAccountMenuItems().Count();
public IEnumerable<MailAccount> HoldingAccounts => GetAccountMenuItems()?.SelectMany(a => a.HoldingAccounts);
[ObservableProperty]
private int unreadItemCount;
[ObservableProperty]
private double synchronizationProgress;
[ObservableProperty]
private string mergedAccountName;
public MergedAccountMenuItem(MergedInbox mergedInbox, IMenuItem parent) : base(mergedInbox, mergedInbox.Id, parent)
{
MergedAccountName = mergedInbox.Name;
}
public void RefreshFolderItemCount()
{
UnreadItemCount = GetAccountMenuItems().Select(a => a.GetUnreadItemCountByFolderType(SpecialFolderType.Inbox)).Sum();
var unreadUpdateFolders = SubMenuItems.OfType<IBaseFolderMenuItem>().Where(a => a.ShowUnreadCount);
foreach (var folder in unreadUpdateFolders)
{
folder.UnreadItemCount = GetAccountMenuItems().Select(a => a.GetUnreadItemCountByFolderType(folder.SpecialFolderType)).Sum();
}
}
// Accounts are always located in More folder of Merged Inbox menu item.
public IEnumerable<AccountMenuItem> GetAccountMenuItems()
{
var moreFolder = SubMenuItems.OfType<MergedAccountMoreFolderMenuItem>().FirstOrDefault();
if (moreFolder == null) return default;
return moreFolder.SubMenuItems.OfType<AccountMenuItem>();
}
public void UpdateAccount(MailAccount account)
=> GetAccountMenuItems().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == account.Id))?.UpdateAccount(account);
}
}

View File

@@ -0,0 +1,12 @@
using System;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.MenuItems
{
public class MergedAccountMoreFolderMenuItem : MenuItemBase<object, IMenuItem>
{
public MergedAccountMoreFolderMenuItem(object parameter, Guid? entityId, IMenuItem parentMenuItem = null) : base(parameter, entityId, parentMenuItem)
{
}
}
}

View File

@@ -0,0 +1,4 @@
namespace Wino.Core.MenuItems
{
public class NewMailMenuItem : MenuItemBase { }
}

View File

@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace Wino.Core.MenuItems
{
/// <summary>
/// Represents a dynamic data collection that provides notifications when items get added, removed, or when the whole list is refreshed.
/// </summary>
/// <typeparam name="T"></typeparam>
public class ObservableRangeCollection<T> : ObservableCollection<T>
{
/// <summary>
/// Initializes a new instance of the System.Collections.ObjectModel.ObservableCollection(Of T) class.
/// </summary>
public ObservableRangeCollection()
: base()
{
}
/// <summary>
/// Initializes a new instance of the System.Collections.ObjectModel.ObservableCollection(Of T) class that contains elements copied from the specified collection.
/// </summary>
/// <param name="collection">collection: The collection from which the elements are copied.</param>
/// <exception cref="System.ArgumentNullException">The collection parameter cannot be null.</exception>
public ObservableRangeCollection(IEnumerable<T> collection)
: base(collection)
{
}
/// <summary>
/// Adds the elements of the specified collection to the end of the ObservableCollection(Of T).
/// </summary>
public void AddRange(IEnumerable<T> collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Add)
{
if (notificationMode != NotifyCollectionChangedAction.Add && notificationMode != NotifyCollectionChangedAction.Reset)
throw new ArgumentException("Mode must be either Add or Reset for AddRange.", nameof(notificationMode));
if (collection == null)
throw new ArgumentNullException(nameof(collection));
CheckReentrancy();
var startIndex = Count;
var itemsAdded = AddArrangeCore(collection);
if (!itemsAdded)
return;
if (notificationMode == NotifyCollectionChangedAction.Reset)
{
RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset);
return;
}
var changedItems = collection is List<T> ? (List<T>)collection : new List<T>(collection);
RaiseChangeNotificationEvents(
action: NotifyCollectionChangedAction.Add,
changedItems: changedItems,
startingIndex: startIndex);
}
/// <summary>
/// Removes the first occurence of each item in the specified collection from ObservableCollection(Of T). NOTE: with notificationMode = Remove, removed items starting index is not set because items are not guaranteed to be consecutive.
/// </summary>
public void RemoveRange(IEnumerable<T> collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Reset)
{
if (notificationMode != NotifyCollectionChangedAction.Remove && notificationMode != NotifyCollectionChangedAction.Reset)
throw new ArgumentException("Mode must be either Remove or Reset for RemoveRange.", nameof(notificationMode));
if (collection == null)
throw new ArgumentNullException(nameof(collection));
CheckReentrancy();
if (notificationMode == NotifyCollectionChangedAction.Reset)
{
var raiseEvents = false;
foreach (var item in collection)
{
Items.Remove(item);
raiseEvents = true;
}
if (raiseEvents)
RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset);
return;
}
var changedItems = new List<T>(collection);
for (var i = 0; i < changedItems.Count; i++)
{
if (!Items.Remove(changedItems[i]))
{
changedItems.RemoveAt(i); //Can't use a foreach because changedItems is intended to be (carefully) modified
i--;
}
}
if (changedItems.Count == 0)
return;
RaiseChangeNotificationEvents(
action: NotifyCollectionChangedAction.Remove,
changedItems: changedItems);
}
/// <summary>
/// Clears the current collection and replaces it with the specified item.
/// </summary>
public void Replace(T item) => ReplaceRange(new T[] { item });
/// <summary>
/// Clears the current collection and replaces it with the specified collection.
/// </summary>
public void ReplaceRange(IEnumerable<T> collection)
{
if (collection == null)
throw new ArgumentNullException(nameof(collection));
CheckReentrancy();
var previouslyEmpty = Items.Count == 0;
Items.Clear();
AddArrangeCore(collection);
var currentlyEmpty = Items.Count == 0;
if (previouslyEmpty && currentlyEmpty)
return;
RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset);
}
private bool AddArrangeCore(IEnumerable<T> collection)
{
var itemAdded = false;
foreach (var item in collection)
{
Items.Add(item);
itemAdded = true;
}
return itemAdded;
}
private void RaiseChangeNotificationEvents(NotifyCollectionChangedAction action, List<T>? changedItems = null, int startingIndex = -1)
{
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
if (changedItems is null)
OnCollectionChanged(new NotifyCollectionChangedEventArgs(action));
else
OnCollectionChanged(new NotifyCollectionChangedEventArgs(action, changedItems: changedItems, startingIndex: startingIndex));
}
}
}

View File

@@ -0,0 +1,4 @@
namespace Wino.Core.MenuItems
{
public class RateMenuItem : MenuItemBase { }
}

View File

@@ -0,0 +1,4 @@
namespace Wino.Core.MenuItems
{
public class SeperatorItem : MenuItemBase { }
}

View File

@@ -0,0 +1,4 @@
namespace Wino.Core.MenuItems
{
public class SettingsItem : MenuItemBase { }
}

View File

@@ -0,0 +1,14 @@
using System;
using Wino.Core.Domain.Models.MailItem;
namespace Wino.Core.Messages.Accounts
{
/// <summary>
/// When menu item for the account is requested to be extended.
/// Additional properties are also supported to navigate to correct IMailItem.
/// </summary>
/// <param name="AutoSelectAccount">Account to extend menu item for.</param>
/// <param name="FolderId">Folder to select after expansion.</param>
/// <param name="NavigateMailItem">Mail item to select if possible in the expanded folder.</param>
public record AccountMenuItemExtended(Guid FolderId, IMailItem NavigateMailItem);
}

View File

@@ -0,0 +1,7 @@
namespace Wino.Core.Messages.Accounts
{
/// <summary>
/// When a full menu refresh for accounts menu is requested.
/// </summary>
public record AccountsMenuRefreshRequested(bool AutomaticallyNavigateFirstItem = true);
}

View File

@@ -0,0 +1,10 @@
using System;
namespace Wino.Core.Messages.Authorization
{
/// <summary>
/// When Google authentication makes a callback to the app via protocol activation to the app.
/// </summary>
/// <param name="AuthorizationResponseUri">Callback Uri that Google returned.</param>
public record ProtocolAuthorizationCallbackReceived(Uri AuthorizationResponseUri);
}

View File

@@ -0,0 +1,7 @@
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When rendered html is requested to cancel.
/// </summary>
public record CancelRenderingContentRequested;
}

View File

@@ -0,0 +1,7 @@
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When reset all mail selections requested.
/// </summary>
public record ClearMailSelectionsRequested;
}

View File

@@ -0,0 +1,10 @@
using Wino.Core.Domain.Models.Reader;
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When a new composing requested.
/// </summary>
/// <param name="RenderModel"></param>
public record CreateNewComposeMailRequested(MailRenderModel RenderModel);
}

View File

@@ -0,0 +1,8 @@
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When existing a new html is requested to be rendered due to mail selection or signature.
/// </summary>
/// <param name="HtmlBody">HTML to render in WebView2.</param>
public record HtmlRenderingRequested(string HtmlBody);
}

View File

@@ -0,0 +1,11 @@
using System;
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When IMAP setup dialog requestes back breadcrumb navigation.
/// </summary>
/// <param name="PageType">Type to go back.</param>
/// <param name="Parameter">Back parameters.</param>
public record ImapSetupBackNavigationRequested(Type PageType, object Parameter);
}

View File

@@ -0,0 +1,10 @@
using Wino.Core.Domain.Entities;
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When user asked to dismiss IMAP setup dialog.
/// </summary>
/// <param name="CompletedServerInformation"> Validated server information that is ready to be saved to database. </param>
public record ImapSetupDismissRequested(CustomServerInformation CompletedServerInformation = null);
}

View File

@@ -0,0 +1,11 @@
using System;
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When IMAP setup dialog breadcrumb navigation requested.
/// </summary>
/// <param name="PageType">Page type to navigate.</param>
/// <param name="Parameter">Navigation parameters.</param>
public record ImapSetupNavigationRequested(Type PageType, object Parameter);
}

View File

@@ -0,0 +1,11 @@
using System;
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When a IMailItem needs to be navigated (or selected)
/// </summary>
/// <param name="UniqueMailId">UniqueId of the mail to navigate.</param>
/// <param name="ScrollToItem">Whether navigated item should be scrolled to or not..</param>
public record MailItemNavigationRequested(Guid UniqueMailId, bool ScrollToItem = false);
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// Selects the given FolderMenuItem in the shell folders list.
/// </summary>
public class NavigateMailFolderEvent : NavigateMailFolderEventArgs
{
public NavigateMailFolderEvent(IBaseFolderMenuItem baseFolderMenuItem, TaskCompletionSource<bool> folderInitLoadAwaitTask = null)
: base(baseFolderMenuItem, folderInitLoadAwaitTask)
{
}
}
}

View File

@@ -0,0 +1,6 @@
using System;
namespace Wino.Core.Messages.Mails
{
public record RefreshUnreadCountsMessage(Guid AccountId);
}

View File

@@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When mail save as PDF requested.
/// </summary>
public record SaveAsPDFRequested(string FileSavePath);
}

View File

@@ -0,0 +1,8 @@
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When selected mail count is changed.
/// </summary>
/// <param name="SelectedItemCount">New selected mail count.</param>
public record SelectedMailItemsChanged(int SelectedItemCount);
}

View File

@@ -0,0 +1,7 @@
namespace Wino.Core.Messages.Navigation
{
/// <summary>
/// When back navigation is requested for breadcrumb pages.
/// </summary>
public record BackBreadcrumNavigationRequested { }
}

View File

@@ -0,0 +1,12 @@
using Wino.Core.Domain.Enums;
namespace Wino.Core.Messages.Navigation
{
/// <summary>
/// When Breadcrumb control navigation requested.
/// </summary>
/// <param name="PageTitle">Title to display for the page.</param>
/// <param name="PageType">Enum equilavent of the page to navigate.</param>
/// <param name="Parameter">Additional parameters to the page.</param>
public record BreadcrumbNavigationRequested(string PageTitle, WinoPage PageType, object Parameter = null);
}

View File

@@ -0,0 +1,7 @@
namespace Wino.Core.Messages.Navigation
{
/// <summary>
/// Navigates to settings page.
/// </summary>
public record NavigateSettingsRequested;
}

View File

@@ -0,0 +1,8 @@
namespace Wino.Core.Messages.Shell
{
/// <summary>
/// When the application theme changed.
/// </summary>
/// <param name="IsUnderlyingThemeDark"></param>
public record ApplicationThemeChanged(bool IsUnderlyingThemeDark);
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using Wino.Core.Domain.Entities;
namespace Wino.Core.Messages.Shell
{
/// <summary>
/// When
/// - There is no selection of any folder for any account
/// - Multiple accounts exists
/// - User clicked 'Create New Mail'
///
/// flyout must be presented to pick correct account.
/// This message will be picked up by UWP Shell.
/// </summary>
public record CreateNewMailWithMultipleAccountsRequested(IEnumerable<MailAccount> AllAccounts);
}

View File

@@ -0,0 +1,17 @@
using System;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Messages.Shell
{
/// <summary>
/// For displaying right sliding notification message in shell.
/// </summary>
/// <param name="Severity">Severity of notification.</param>
/// <param name="Title">Title of the message.</param>
/// <param name="Message">Message content.</param>
public record InfoBarMessageRequested(InfoBarMessageType Severity,
string Title,
string Message,
string ActionButtonTitle = "",
Action Action = null);
}

View File

@@ -0,0 +1,7 @@
namespace Wino.Core.Messages.Shell
{
/// <summary>
/// When application language is updated.
/// </summary>
public record LanguageChanged;
}

View File

@@ -0,0 +1,4 @@
namespace Wino.Core.Messages.Shell
{
public class MailtoProtocolMessageRequested { }
}

View File

@@ -0,0 +1,10 @@
using Wino.Core.Domain.Enums;
namespace Wino.Core.Messages.Shell
{
/// <summary>
/// When navigation pane mode is changed.
/// </summary>
/// <param name="NewMode">New navigation mode.</param>
public record NavigationPaneModeChanged(MenuPaneMode NewMode);
}

View File

@@ -0,0 +1,7 @@
namespace Wino.Core.Messages.Shell
{
/// <summary>
/// When reading mail state or reader pane narrowed state is changed.
/// </summary>
public record ShellStateUpdated;
}

View File

@@ -0,0 +1,7 @@
using System;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Messages.Synchronization
{
public record AccountSynchronizationCompleted(Guid AccountId, SynchronizationCompletedState Result, Guid? SynchronizationTrackingId);
}

View File

@@ -0,0 +1,12 @@
using Wino.Core.Domain.Enums;
using Wino.Core.Synchronizers;
namespace Wino.Core.Messages.Synchronization
{
/// <summary>
/// Emitted when synchronizer state is updated.
/// </summary>
/// <param name="synchronizer">Account Synchronizer</param>
/// <param name="newState">New state.</param>
public record AccountSynchronizerStateChanged(IBaseSynchronizer Synchronizer, AccountSynchronizerState NewState);
}

View File

@@ -0,0 +1,10 @@
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Messages.Synchronization
{
/// <summary>
/// Triggers a new synchronization if possible.
/// </summary>
/// <param name="Options">Options for synchronization.</param>
public record NewSynchronizationRequested(SynchronizationOptions Options);
}

View File

@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.IO;
using MimeKit;
using MimeKit.Text;
using MimeKit.Tnef;
namespace Wino.Core.Mime
{
/// <summary>
/// Visits a MimeMessage and generates HTML suitable to be rendered by a browser control.
/// </summary>
public class HtmlPreviewVisitor : MimeVisitor
{
List<MultipartRelated> stack = new List<MultipartRelated>();
List<MimeEntity> attachments = new List<MimeEntity>();
readonly string tempDir;
public string Body { get; set; }
/// <summary>
/// Creates a new HtmlPreviewVisitor.
/// </summary>
/// <param name="tempDirectory">A temporary directory used for storing image files.</param>
public HtmlPreviewVisitor(string tempDirectory)
{
tempDir = tempDirectory;
}
/// <summary>
/// The list of attachments that were in the MimeMessage.
/// </summary>
public IList<MimeEntity> Attachments
{
get { return attachments; }
}
/// <summary>
/// The HTML string that can be set on the BrowserControl.
/// </summary>
public string HtmlBody
{
get { return Body ?? string.Empty; }
}
protected override void VisitMultipartAlternative(MultipartAlternative alternative)
{
// walk the multipart/alternative children backwards from greatest level of faithfulness to the least faithful
for (int i = alternative.Count - 1; i >= 0 && Body == null; i--)
alternative[i].Accept(this);
}
protected override void VisitMultipartRelated(MultipartRelated related)
{
var root = related.Root;
// push this multipart/related onto our stack
stack.Add(related);
// visit the root document
root.Accept(this);
// pop this multipart/related off our stack
stack.RemoveAt(stack.Count - 1);
}
// look up the image based on the img src url within our multipart/related stack
bool TryGetImage(string url, out MimePart image)
{
UriKind kind;
int index;
Uri uri;
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
kind = UriKind.Absolute;
else if (Uri.IsWellFormedUriString(url, UriKind.Relative))
kind = UriKind.Relative;
else
kind = UriKind.RelativeOrAbsolute;
try
{
uri = new Uri(url, kind);
}
catch
{
image = null;
return false;
}
for (int i = stack.Count - 1; i >= 0; i--)
{
if ((index = stack[i].IndexOf(uri)) == -1)
continue;
image = stack[i][index] as MimePart;
return image != null;
}
image = null;
return false;
}
// Save the image to our temp directory and return a "file://" url suitable for
// the browser control to load.
// Note: if you'd rather embed the image data into the HTML, you can construct a
// "data:" url instead.
string SaveImage(MimePart image)
{
using (var memory = new MemoryStream())
{
image.Content.DecodeTo(memory);
var buffer = memory.GetBuffer();
var length = (int)memory.Length;
var base64 = Convert.ToBase64String(buffer, 0, length);
return string.Format("data:{0};base64,{1}", image.ContentType.MimeType, base64);
}
//string fileName = url
// .Replace(':', '_')
// .Replace('\\', '_')
// .Replace('/', '_');
//string path = Path.Combine(tempDir, fileName);
//if (!File.Exists(path))
//{
// using (var output = File.Create(path))
// image.Content.DecodeTo(output);
//}
//return "file://" + path.Replace('\\', '/');
}
// Replaces <img src=...> urls that refer to images embedded within the message with
// "file://" urls that the browser control will actually be able to load.
void HtmlTagCallback(HtmlTagContext ctx, HtmlWriter htmlWriter)
{
if (ctx.TagId == HtmlTagId.Image && !ctx.IsEndTag && stack.Count > 0)
{
ctx.WriteTag(htmlWriter, false);
// replace the src attribute with a file:// URL
foreach (var attribute in ctx.Attributes)
{
if (attribute.Id == HtmlAttributeId.Src)
{
MimePart image;
string url;
if (!TryGetImage(attribute.Value, out image))
{
htmlWriter.WriteAttribute(attribute);
continue;
}
url = SaveImage(image);
htmlWriter.WriteAttributeName(attribute.Name);
htmlWriter.WriteAttributeValue(url);
}
else
{
htmlWriter.WriteAttribute(attribute);
}
}
}
else if (ctx.TagId == HtmlTagId.Body && !ctx.IsEndTag)
{
ctx.WriteTag(htmlWriter, false);
// add and/or replace oncontextmenu="return false;"
foreach (var attribute in ctx.Attributes)
{
if (attribute.Name.ToLowerInvariant() == "oncontextmenu")
continue;
htmlWriter.WriteAttribute(attribute);
}
htmlWriter.WriteAttribute("oncontextmenu", "return false;");
}
else
{
// pass the tag through to the output
ctx.WriteTag(htmlWriter, true);
}
}
protected override void VisitTextPart(TextPart entity)
{
TextConverter converter;
if (Body != null)
{
// since we've already found the body, treat this as an attachment
attachments.Add(entity);
return;
}
if (entity.IsHtml)
{
converter = new HtmlToHtml
{
HtmlTagCallback = HtmlTagCallback
};
}
else if (entity.IsFlowed)
{
var flowed = new FlowedToHtml();
string delsp;
if (entity.ContentType.Parameters.TryGetValue("delsp", out delsp))
flowed.DeleteSpace = delsp.ToLowerInvariant() == "yes";
converter = flowed;
}
else
{
converter = new TextToHtml();
}
Body = converter.Convert(entity.Text);
}
protected override void VisitTnefPart(TnefPart entity)
{
// extract any attachments in the MS-TNEF part
attachments.AddRange(entity.ExtractAttachments());
}
protected override void VisitMessagePart(MessagePart entity)
{
// treat message/rfc822 parts as attachments
attachments.Add(entity);
}
protected override void VisitMimePart(MimePart entity)
{
// realistically, if we've gotten this far, then we can treat this as an attachment
// even if the IsAttachment property is false.
attachments.Add(entity);
}
}
}

View File

@@ -0,0 +1,9 @@
using MailKit;
namespace Wino.Core.Mime
{
/// <summary>
/// Encapsulates all required information to create a MimeMessage for IMAP synchronizer.
/// </summary>
public record ImapMessageCreationPackage(IMessageSummary MessageSummary, IMailFolder MailFolder);
}

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Requests;
namespace Wino.Core.Misc
{
/// <summary>
/// This is incomplete.
/// </summary>
internal class RequestComparer : IEqualityComparer<IRequestBase>
{
public bool Equals(IRequestBase x, IRequestBase y)
{
if (x is MoveRequest sourceMoveRequest && y is MoveRequest targetMoveRequest)
{
return sourceMoveRequest.FromFolder.Id == targetMoveRequest.FromFolder.Id && sourceMoveRequest.ToFolder.Id == targetMoveRequest.ToFolder.Id;
}
else if (x is ChangeFlagRequest sourceFlagRequest && y is ChangeFlagRequest targetFlagRequest)
{
return sourceFlagRequest.IsFlagged == targetFlagRequest.IsFlagged;
}
else if (x is MarkReadRequest sourceMarkReadRequest && y is MarkReadRequest targetMarkReadRequest)
{
return sourceMarkReadRequest.Item.IsRead == targetMarkReadRequest.Item.IsRead;
}
else if (x is DeleteRequest sourceDeleteRequest && y is DeleteRequest targetDeleteRequest)
{
return sourceDeleteRequest.MailItem.AssignedFolder.Id == targetDeleteRequest.MailItem.AssignedFolder.Id;
}
return true;
}
public int GetHashCode(IRequestBase obj) => obj.Operation.GetHashCode();
}
}

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.ComponentModel;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record AlwaysMoveToRequest(MailCopy Item, bool MoveToFocused) : RequestBase<BatchMoveRequest>(Item, MailSynchronizerOperation.AlwaysMoveTo)
{
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
=> new BatchAlwaysMoveToRequest(matchingItems, MoveToFocused);
public override void ApplyUIChanges()
{
}
public override void RevertUIChanges()
{
}
}
[EditorBrowsable(EditorBrowsableState.Never)]
public record BatchAlwaysMoveToRequest(IEnumerable<IRequest> Items, bool MoveToFocused) : BatchRequestBase(Items, MailSynchronizerOperation.AlwaysMoveTo)
{
public override void ApplyUIChanges()
{
}
public override void RevertUIChanges()
{
}
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Domain.Models.Requests
{
/// <summary>
/// Bundle that encapsulates batch request and native request without a response.
/// </summary>
/// <typeparam name="TRequest">Http type for each integrator. eg. ClientServiceRequest for Gmail and RequestInformation for Microsoft Graph.</typeparam>
/// <param name="NativeRequest">Native type to send via http.</param>
/// <param name="BatchRequest">Batch request that is generated by base synchronizer.</param>
public record HttpRequestBundle<TRequest>(TRequest NativeRequest, IRequestBase Request) : IRequestBundle<TRequest>
{
public string BundleId { get; set; } = string.Empty;
public override string ToString()
{
if (Request is IRequest singleRequest)
return $"Single {singleRequest.Operation}. No response.";
else if (Request is IBatchChangeRequest batchChangeRequest)
return $"Batch {batchChangeRequest.Operation} for {batchChangeRequest.Items.Count()} items. No response.";
else
return "Unknown http request bundle.";
}
}
/// <summary>
/// Bundle that encapsulates batch request and native request with response.
/// </summary>
/// <typeparam name="TRequest">Http type for each integrator. eg. ClientServiceRequest for Gmail and RequestInformation for Microsoft Graph.</typeparam>
/// <param name="NativeRequest">Native type to send via http.</param>
/// <param name="BatchRequest">Batch request that is generated by base synchronizer.</param>
public record HttpRequestBundle<TRequest, TResponse>(TRequest NativeRequest, IRequestBase Request) : HttpRequestBundle<TRequest>(NativeRequest, Request)
{
public async Task<TResponse> DeserializeBundleAsync(HttpResponseMessage httpResponse, CancellationToken cancellationToken = default)
{
var content = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
return JsonConvert.DeserializeObject<TResponse>(content) ?? throw new InvalidOperationException("Invalid Http Response Deserialization");
}
public override string ToString()
{
if (Request is IRequest singleRequest)
return $"Single {singleRequest.Operation}. Expecting '{typeof(TResponse).FullName}' type.";
else if (Request is IBatchChangeRequest batchChangeRequest)
return $"Batch {batchChangeRequest.Operation} for {batchChangeRequest.Items.Count()} items. Expecting '{typeof(TResponse).FullName}' type.";
else
return "Unknown http request bundle.";
}
}
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Threading.Tasks;
using MailKit.Net.Imap;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Requests.Bundles
{
//public abstract record TaskRequestBundleBase()
//{
// public abstract Task ExecuteAsync(ImapClient executorImapClient);
//}
//public record TaskRequestBundle(Func<ImapClient, Task> NativeRequest) : TaskRequestBundleBase
//{
// public override async Task ExecuteAsync(ImapClient executorImapClient) => await NativeRequest(executorImapClient).ConfigureAwait(false);
//}
public record ImapRequest(Func<ImapClient, Task> IntegratorTask, IRequestBase Request) { }
public record ImapRequestBundle(ImapRequest NativeRequest, IRequestBase Request) : IRequestBundle<ImapRequest>
{
public string BundleId { get; set; } = Guid.NewGuid().ToString();
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : RequestBase<BatchMoveRequest>(Item, MailSynchronizerOperation.ChangeFlag),
ICustomFolderSynchronizationRequest
{
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
=> new BatchChangeFlagRequest(matchingItems, IsFlagged);
public override void ApplyUIChanges()
{
Item.IsFlagged = IsFlagged;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
}
public override void RevertUIChanges()
{
Item.IsFlagged = !IsFlagged;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
}
}
[EditorBrowsable(EditorBrowsableState.Never)]
public record BatchChangeFlagRequest(IEnumerable<IRequest> Items, bool IsFlagged) : BatchRequestBase(Items, MailSynchronizerOperation.ChangeFlag)
{
public override void ApplyUIChanges()
{
Items.ForEach(item =>
{
item.Item.IsFlagged = IsFlagged;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item.Item));
});
}
public override void RevertUIChanges()
{
Items.ForEach(item =>
{
item.Item.IsFlagged = !IsFlagged;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item.Item));
});
}
}
}

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record CreateDraftRequest(DraftPreperationRequest DraftPreperationRequest)
: RequestBase<BatchCreateDraftRequest>(DraftPreperationRequest.CreatedLocalDraftCopy, MailSynchronizerOperation.CreateDraft),
ICustomFolderSynchronizationRequest
{
public List<Guid> SynchronizationFolderIds =>
[
DraftPreperationRequest.CreatedLocalDraftCopy.AssignedFolder.Id
];
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
=> new BatchCreateDraftRequest(matchingItems, DraftPreperationRequest);
public override void ApplyUIChanges()
{
// No need for it since Draft folder is automatically navigated and draft item is added + selected.
// We only need to revert changes in case of network fails to create the draft.
}
public override void RevertUIChanges()
{
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item));
}
}
[EditorBrowsable(EditorBrowsableState.Never)]
public record class BatchCreateDraftRequest(IEnumerable<IRequest> Items, DraftPreperationRequest DraftPreperationRequest)
: BatchRequestBase(Items, MailSynchronizerOperation.CreateDraft)
{
public override void ApplyUIChanges()
{
// No need for it since Draft folder is automatically navigated and draft item is added + selected.
// We only need to revert changes in case of network fails to create the draft.
}
public override void RevertUIChanges()
{
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item.Item)));
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
/// <summary>
/// Hard delete request. This request will delete the mail item from the server without moving it to the trash folder.
/// </summary>
/// <param name="MailItem">Item to delete permanently.</param>
public record DeleteRequest(MailCopy MailItem) : RequestBase<BatchDeleteRequest>(MailItem, MailSynchronizerOperation.Delete),
ICustomFolderSynchronizationRequest
{
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
=> new BatchDeleteRequest(matchingItems);
public override void ApplyUIChanges()
{
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item));
}
public override void RevertUIChanges()
{
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item));
}
}
[EditorBrowsable(EditorBrowsableState.Never)]
public record class BatchDeleteRequest(IEnumerable<IRequest> Items) : BatchRequestBase(Items, MailSynchronizerOperation.Delete)
{
public override void ApplyUIChanges()
{
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item.Item)));
}
public override void RevertUIChanges()
{
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailAddedMessage(item.Item)));
}
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record MarkReadRequest(MailCopy Item, bool IsRead) : RequestBase<BatchMarkReadRequest>(Item, MailSynchronizerOperation.MarkRead),
ICustomFolderSynchronizationRequest
{
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
=> new BatchMarkReadRequest(matchingItems, IsRead);
public override void ApplyUIChanges()
{
Item.IsRead = IsRead;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
}
public override void RevertUIChanges()
{
Item.IsRead = !IsRead;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
}
}
[EditorBrowsable(EditorBrowsableState.Never)]
public record BatchMarkReadRequest(IEnumerable<IRequest> Items, bool IsRead) : BatchRequestBase(Items, MailSynchronizerOperation.MarkRead)
{
public override void ApplyUIChanges()
{
Items.ForEach(item =>
{
item.Item.IsRead = IsRead;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item.Item));
});
}
public override void RevertUIChanges()
{
Items.ForEach(item =>
{
item.Item.IsRead = !IsRead;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item.Item));
});
}
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record MoveRequest(MailCopy Item, MailItemFolder FromFolder, MailItemFolder ToFolder)
: RequestBase<BatchMoveRequest>(Item, MailSynchronizerOperation.Move), ICustomFolderSynchronizationRequest
{
public List<Guid> SynchronizationFolderIds => new() { FromFolder.Id, ToFolder.Id };
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
=> new BatchMoveRequest(matchingItems, FromFolder, ToFolder);
public override void ApplyUIChanges()
{
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item));
}
public override void RevertUIChanges()
{
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item));
}
}
[EditorBrowsable(EditorBrowsableState.Never)]
public record BatchMoveRequest(IEnumerable<IRequest> Items, MailItemFolder FromFolder, MailItemFolder ToFolder) : BatchRequestBase(Items, MailSynchronizerOperation.Move)
{
public override void ApplyUIChanges()
{
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item.Item)));
}
public override void RevertUIChanges()
{
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailAddedMessage(item.Item)));
}
}
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.ComponentModel;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record MoveToFocusedRequest(MailCopy Item, bool MoveToFocused) : RequestBase<BatchMoveRequest>(Item, MailSynchronizerOperation.Move)
{
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
=> new BatchMoveToFocusedRequest(matchingItems, MoveToFocused);
public override void ApplyUIChanges() { }
public override void RevertUIChanges() { }
}
[EditorBrowsable(EditorBrowsableState.Never)]
public record BatchMoveToFocusedRequest(IEnumerable<IRequest> Items, bool MoveToFocused) : BatchRequestBase(Items, MailSynchronizerOperation.Move)
{
public override void ApplyUIChanges() { }
public override void RevertUIChanges() { }
}
}

View File

@@ -0,0 +1,19 @@
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record RenameFolderRequest(MailItemFolder Folder) : FolderRequestBase(Folder, MailSynchronizerOperation.RenameFolder)
{
public override void ApplyUIChanges()
{
}
public override void RevertUIChanges()
{
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record SendDraftRequest(SendDraftPreparationRequest Request) : RequestBase<BatchMarkReadRequest>(Request.MailItem, MailSynchronizerOperation.Send)
{
public override IBatchChangeRequest CreateBatch(IEnumerable<IRequest> matchingItems)
=> new BatchSendDraftRequestRequest(matchingItems, Request);
public override void ApplyUIChanges()
{
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item));
}
public override void RevertUIChanges()
{
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item));
}
}
[EditorBrowsable(EditorBrowsableState.Never)]
public record BatchSendDraftRequestRequest(IEnumerable<IRequest> Items, SendDraftPreparationRequest Request) : BatchRequestBase(Items, MailSynchronizerOperation.Send)
{
public override void ApplyUIChanges()
{
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item.Item)));
}
public override void RevertUIChanges()
{
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailAddedMessage(item.Item)));
}
}
}

View File

@@ -0,0 +1,25 @@
using System;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record MailAddedMessage(MailCopy AddedMail) : IUIMessage;
public record MailRemovedMessage(MailCopy RemovedMail) : IUIMessage;
public record MailUpdatedMessage(MailCopy UpdatedMail) : IUIMessage;
public record MailDownloadedMessage(MailCopy DownloadedMail) : IUIMessage;
public record FolderAddedMessage(MailItemFolder AddedFolder, MailAccount Account) : IUIMessage;
public record FolderRemovedMessage(MailItemFolder RemovedFolder, MailAccount Account) : IUIMessage;
public record FolderUpdatedMessage(MailItemFolder UpdatedFolder, MailAccount Account) : IUIMessage;
public record AccountCreatedMessage(MailAccount Account) : IUIMessage;
public record AccountRemovedMessage(MailAccount Account) : IUIMessage;
public record AccountUpdatedMessage(MailAccount Account) : IUIMessage;
public record DraftCreated(MailCopy DraftMail, MailAccount Account) : IUIMessage;
public record DraftFailed(MailCopy DraftMail, MailAccount Account) : IUIMessage;
public record DraftMapped(string LocalDraftCopyId, string RemoteDraftCopyId) : IUIMessage;
public record MergedInboxRenamed(Guid MergedInboxId, string NewName) : IUIMessage;
}

View File

@@ -0,0 +1,403 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using SqlKata;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Extensions;
using Wino.Core.Messages.Accounts;
using Wino.Core.Requests;
namespace Wino.Core.Services
{
public class AccountService : BaseDatabaseService, IAccountService
{
public IAuthenticator ExternalAuthenticationAuthenticator { get; set; }
private readonly IAuthenticationProvider _authenticationProvider;
private readonly ISignatureService _signatureService;
private readonly IPreferencesService _preferencesService;
private readonly ILogger _logger = Log.ForContext<AccountService>();
public AccountService(IDatabaseService databaseService,
IAuthenticationProvider authenticationProvider,
ISignatureService signatureService,
IPreferencesService preferencesService) : base(databaseService)
{
_authenticationProvider = authenticationProvider;
_signatureService = signatureService;
_preferencesService = preferencesService;
}
public async Task ClearAccountAttentionAsync(Guid accountId)
{
var account = await GetAccountAsync(accountId);
Guard.IsNotNull(account);
account.AttentionReason = AccountAttentionReason.None;
await UpdateAccountAsync(account);
}
public async Task UpdateMergedInboxAsync(Guid mergedInboxId, IEnumerable<Guid> linkedAccountIds)
{
// First, remove all accounts from merged inbox.
await Connection.ExecuteAsync("UPDATE MailAccount SET MergedInboxId = NULL WHERE MergedInboxId = ?", mergedInboxId);
// Then, add new accounts to merged inbox.
var query = new Query("MailAccount")
.WhereIn("Id", linkedAccountIds)
.AsUpdate(new
{
MergedInboxId = mergedInboxId
});
await Connection.ExecuteAsync(query.GetRawQuery());
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested());
}
public async Task UnlinkMergedInboxAsync(Guid mergedInboxId)
{
var mergedInbox = await Connection.Table<MergedInbox>().FirstOrDefaultAsync(a => a.Id == mergedInboxId).ConfigureAwait(false);
if (mergedInbox == null)
{
_logger.Warning("Could not find merged inbox with id {MergedInboxId}", mergedInboxId);
return;
}
var query = new Query("MailAccount")
.Where("MergedInboxId", mergedInboxId)
.AsUpdate(new
{
MergedInboxId = (Guid?)null
});
await Connection.ExecuteAsync(query.GetRawQuery()).ConfigureAwait(false);
await Connection.DeleteAsync(mergedInbox).ConfigureAwait(false);
// Change the startup entity id if it was the merged inbox.
// Take the first account as startup account.
if (_preferencesService.StartupEntityId == mergedInboxId)
{
var firstAccount = await Connection.Table<MailAccount>().FirstOrDefaultAsync();
if (firstAccount != null)
{
_preferencesService.StartupEntityId = firstAccount.Id;
}
else
{
_preferencesService.StartupEntityId = null;
}
}
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested());
}
public async Task CreateMergeAccountsAsync(MergedInbox mergedInbox, IEnumerable<MailAccount> accountsToMerge)
{
if (mergedInbox == null) return;
// 0. Give the merged inbox a new Guid.
mergedInbox.Id = Guid.NewGuid();
var accountFolderDictionary = new Dictionary<MailAccount, List<MailItemFolder>>();
// 1. Make all folders in the accounts unsticky. We will stick them based on common special folder types.
foreach (var account in accountsToMerge)
{
var accountFolderList = new List<MailItemFolder>();
var folders = await Connection.Table<MailItemFolder>().Where(a => a.MailAccountId == account.Id).ToListAsync();
foreach (var folder in folders)
{
accountFolderList.Add(folder);
folder.IsSticky = false;
await Connection.UpdateAsync(folder);
}
accountFolderDictionary.Add(account, accountFolderList);
}
// 2. Find the common special folders and stick them.
// Only following types will be considered as common special folder.
SpecialFolderType[] commonSpecialTypes =
[
SpecialFolderType.Inbox,
SpecialFolderType.Sent,
SpecialFolderType.Draft,
SpecialFolderType.Archive,
SpecialFolderType.Junk,
SpecialFolderType.Deleted
];
foreach (var type in commonSpecialTypes)
{
var isCommonType = accountFolderDictionary
.Select(a => a.Value)
.Where(a => a.Any(a => a.SpecialFolderType == type))
.Count() == accountsToMerge.Count();
if (isCommonType)
{
foreach (var account in accountsToMerge)
{
var folder = accountFolderDictionary[account].FirstOrDefault(a => a.SpecialFolderType == type);
if (folder != null)
{
folder.IsSticky = true;
await Connection.UpdateAsync(folder);
}
}
}
}
// 3. Insert merged inbox and assign accounts.
await Connection.InsertAsync(mergedInbox);
foreach (var account in accountsToMerge)
{
account.MergedInboxId = mergedInbox.Id;
await Connection.UpdateAsync(account);
}
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested());
}
public async Task RenameMergedAccountAsync(Guid mergedInboxId, string newName)
{
var query = new Query("MergedInbox")
.Where("Id", mergedInboxId)
.AsUpdate(new
{
Name = newName
});
await Connection.ExecuteAsync(query.GetRawQuery());
ReportUIChange(new MergedInboxRenamed(mergedInboxId, newName));
}
public async Task FixTokenIssuesAsync(Guid accountId)
{
var account = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id == accountId);
if (account == null) return;
var authenticator = _authenticationProvider.GetAuthenticator(account.ProviderType);
// This will re-generate token.
var token = await authenticator.GenerateTokenAsync(account, true);
Guard.IsNotNull(token);
}
private Task<MailAccountPreferences> GetAccountPreferencesAsync(Guid accountId)
=> Connection.Table<MailAccountPreferences>().FirstOrDefaultAsync(a => a.AccountId == accountId);
public async Task<List<MailAccount>> GetAccountsAsync()
{
var accounts = await Connection.Table<MailAccount>().ToListAsync();
foreach (var account in accounts)
{
// Load IMAP server configuration.
if (account.ProviderType == MailProviderType.IMAP4)
account.ServerInformation = await GetAccountCustomServerInformationAsync(account.Id);
// Load MergedInbox information.
if (account.MergedInboxId != null)
account.MergedInbox = await GetMergedInboxInformationAsync(account.MergedInboxId.Value);
account.Preferences = await GetAccountPreferencesAsync(account.Id);
}
return accounts;
}
private Task<MergedInbox> GetMergedInboxInformationAsync(Guid mergedInboxId)
=> Connection.Table<MergedInbox>().FirstOrDefaultAsync(a => a.Id == mergedInboxId);
public async Task DeleteAccountAsync(MailAccount account)
{
// TODO: Delete mime messages and attachments.
await Connection.ExecuteAsync("DELETE FROM MailCopy WHERE Id IN(SELECT Id FROM MailCopy WHERE FolderId IN (SELECT Id from MailItemFolder WHERE MailAccountId == ?))", account.Id);
await Connection.Table<TokenInformation>().Where(a => a.AccountId == account.Id).DeleteAsync();
await Connection.Table<MailItemFolder>().DeleteAsync(a => a.MailAccountId == account.Id);
if (account.SignatureId != null)
await Connection.Table<AccountSignature>().DeleteAsync(a => a.Id == account.SignatureId);
// Account belongs to a merged inbox.
// In case of there'll be a single account in the merged inbox, remove the merged inbox as well.
if (account.MergedInboxId != null)
{
var mergedInboxAccountCount = await Connection.Table<MailAccount>().Where(a => a.MergedInboxId == account.MergedInboxId.Value).CountAsync();
// There will be only one account in the merged inbox. Remove the link for the other account as well.
if (mergedInboxAccountCount == 2)
{
var query = new Query("MailAccount")
.Where("MergedInboxId", account.MergedInboxId.Value)
.AsUpdate(new
{
MergedInboxId = (Guid?)null
});
await Connection.ExecuteAsync(query.GetRawQuery()).ConfigureAwait(false);
}
}
if (account.ProviderType == MailProviderType.IMAP4)
await Connection.Table<CustomServerInformation>().DeleteAsync(a => a.AccountId == account.Id);
if (account.Preferences != null)
await Connection.DeleteAsync(account.Preferences);
await Connection.DeleteAsync(account);
// Clear out or set up a new startup entity id.
// Next account after the deleted one will be the startup account.
if (_preferencesService.StartupEntityId == account.Id || _preferencesService.StartupEntityId == account.MergedInboxId)
{
var firstNonStartupAccount = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id != account.Id);
if (firstNonStartupAccount != null)
{
_preferencesService.StartupEntityId = firstNonStartupAccount.Id;
}
else
{
_preferencesService.StartupEntityId = null;
}
}
ReportUIChange(new AccountRemovedMessage(account));
}
public async Task<MailAccount> GetAccountAsync(Guid accountId)
{
var account = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id == accountId);
if (account?.ProviderType == MailProviderType.IMAP4)
account.ServerInformation = await GetAccountCustomServerInformationAsync(account.Id);
account.Preferences = await GetAccountPreferencesAsync(account.Id);
return account;
}
public Task<CustomServerInformation> GetAccountCustomServerInformationAsync(Guid accountId)
=> Connection.Table<CustomServerInformation>().FirstOrDefaultAsync(a => a.AccountId == accountId);
public async Task UpdateAccountAsync(MailAccount account)
{
if (account.Preferences == null)
{
Debugger.Break();
}
await Connection.UpdateAsync(account.Preferences);
await Connection.UpdateAsync(account);
ReportUIChange(new AccountUpdatedMessage(account));
}
public async Task CreateAccountAsync(MailAccount account, TokenInformation tokenInformation, CustomServerInformation customServerInformation)
{
Guard.IsNotNull(account);
var accountCount = await Connection.Table<MailAccount>().CountAsync();
// If there are no accounts before this one, set it as startup account.
if (accountCount == 0)
{
_preferencesService.StartupEntityId = account.Id;
}
await Connection.InsertAsync(account);
var preferences = new MailAccountPreferences()
{
Id = Guid.NewGuid(),
AccountId = account.Id,
IsNotificationsEnabled = true,
ShouldAppendMessagesToSentFolder = false
};
account.Preferences = preferences;
// Outlook & Office 365 supports Focused inbox. Enabled by default.
bool isMicrosoftProvider = account.ProviderType == MailProviderType.Outlook || account.ProviderType == MailProviderType.Office365;
if (isMicrosoftProvider)
account.Preferences.IsFocusedInboxEnabled = true;
await Connection.InsertAsync(preferences);
// Create default signature.
var defaultSignature = await _signatureService.CreateDefaultSignatureAsync(account.Id);
account.SignatureId = defaultSignature.Id;
if (customServerInformation != null)
await Connection.InsertAsync(customServerInformation);
if (tokenInformation != null)
await Connection.InsertAsync(tokenInformation);
}
public async Task<string> UpdateSynchronizationIdentifierAsync(Guid accountId, string newIdentifier)
{
var account = await GetAccountAsync(accountId);
if (account == null)
{
_logger.Error("Could not find account with id {AccountId}", accountId);
return string.Empty;
}
var currentIdentifier = account.SynchronizationDeltaIdentifier;
bool shouldUpdateIdentifier = account.ProviderType == MailProviderType.Gmail ?
((string.IsNullOrEmpty(currentIdentifier) ? true : !string.IsNullOrEmpty(currentIdentifier)
&& ulong.TryParse(currentIdentifier, out ulong currentIdentifierValue)
&& ulong.TryParse(newIdentifier, out ulong newIdentifierValue)
&& newIdentifierValue > currentIdentifierValue)) : true;
if (shouldUpdateIdentifier)
{
_logger.Debug("Updating synchronization identifier for {Name}. From: {SynchronizationDeltaIdentifier} To: {NewIdentifier}", account.Name, account.SynchronizationDeltaIdentifier, newIdentifier);
account.SynchronizationDeltaIdentifier = newIdentifier;
await UpdateAccountAsync(account);
}
return account.SynchronizationDeltaIdentifier;
}
}
}

View File

@@ -0,0 +1,34 @@
using System;
using Wino.Core.Authenticators;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using IAuthenticationProvider = Wino.Core.Domain.Interfaces.IAuthenticationProvider;
namespace Wino.Core.Services
{
public class AuthenticationProvider : IAuthenticationProvider
{
private readonly INativeAppService _nativeAppService;
private readonly ITokenService _tokenService;
public AuthenticationProvider(INativeAppService nativeAppService, ITokenService tokenService)
{
_nativeAppService = nativeAppService;
_tokenService = tokenService;
}
public IAuthenticator GetAuthenticator(MailProviderType providerType)
{
return providerType switch
{
MailProviderType.Outlook => new OutlookAuthenticator(_tokenService, _nativeAppService),
MailProviderType.Office365 => new Office365Authenticator(_tokenService, _nativeAppService),
MailProviderType.Gmail => new GmailAuthenticator(_tokenService, _nativeAppService),
MailProviderType.Yahoo => new YahooAuthenticator(_tokenService),
MailProviderType.IMAP4 => new CustomAuthenticator(_tokenService),
_ => throw new ArgumentException(Translator.Exception_UnsupportedProvider),
};
}
}
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Serilog;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.AutoDiscovery;
namespace Wino.Core.Services
{
/// <summary>
/// We have 2 methods to do auto discovery.
/// 1. Use https://emailsettings.firetrust.com/settings?q={address} API
/// 2. TODO: Thunderbird auto discovery file.
/// </summary>
public class AutoDiscoveryService : IAutoDiscoveryService
{
private const string FiretrustURL = " https://emailsettings.firetrust.com/settings?q=";
// TODO: Try Thunderbird Auto Discovery as second approach.
public Task<AutoDiscoverySettings> GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings)
=> GetSettingsFromFiretrustAsync(autoDiscoveryMinimalSettings.Email);
private async Task<AutoDiscoverySettings> GetSettingsFromFiretrustAsync(string mailAddress)
{
using var client = new HttpClient();
var response = await client.GetAsync($"{FiretrustURL}{mailAddress}");
if (response.IsSuccessStatusCode)
return await DeserializeFiretrustResponse(response);
else
{
Log.Warning($"Firetrust AutoDiscovery failed. ({response.StatusCode})");
return null;
}
}
private async Task<AutoDiscoverySettings> DeserializeFiretrustResponse(HttpResponseMessage response)
{
try
{
var content = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<AutoDiscoverySettings>(content);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to deserialize Firetrust response.");
}
return null;
}
}
}

View File

@@ -0,0 +1,22 @@
using CommunityToolkit.Mvvm.Messaging;
using SQLite;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Services
{
public class BaseDatabaseService
{
protected IMessenger Messenger => WeakReferenceMessenger.Default;
protected SQLiteAsyncConnection Connection => _databaseService.Connection;
private readonly IDatabaseService _databaseService;
public BaseDatabaseService(IDatabaseService databaseService)
{
_databaseService = databaseService;
}
public void ReportUIChange<TMessage>(TMessage message) where TMessage : class, IUIMessage
=> Messenger.Send(message);
}
}

View File

@@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MimeKit;
using SqlKata;
using Wino.Core.Domain.Entities;
using Wino.Core.Extensions;
namespace Wino.Core.Services
{
public interface IContactService
{
Task<List<AddressInformation>> GetAddressInformationAsync(string queryText);
Task<AddressInformation> GetAddressInformationByAddressAsync(string address);
Task SaveAddressInformationAsync(MimeMessage message);
}
public class ContactService : BaseDatabaseService, IContactService
{
public ContactService(IDatabaseService databaseService) : base(databaseService) { }
public Task<List<AddressInformation>> GetAddressInformationAsync(string queryText)
{
if (queryText == null || queryText.Length < 2)
return Task.FromResult<List<AddressInformation>>(null);
var query = new Query(nameof(AddressInformation));
query.WhereContains("Address", queryText);
query.OrWhereContains("Name", queryText);
var rawLikeQuery = query.GetRawQuery();
return Connection.QueryAsync<AddressInformation>(rawLikeQuery);
}
public async Task<AddressInformation> GetAddressInformationByAddressAsync(string address)
{
return await Connection.Table<AddressInformation>().Where(a => a.Address == address).FirstOrDefaultAsync()
?? new AddressInformation() { Name = address, Address = address };
}
public async Task SaveAddressInformationAsync(MimeMessage message)
{
var recipients = message
.GetRecipients(true)
.Where(a => !string.IsNullOrEmpty(a.Name) && !string.IsNullOrEmpty(a.Address));
var addressInformations = recipients.Select(a => new AddressInformation() { Name = a.Name, Address = a.Address });
foreach (var info in addressInformations)
await Connection.InsertOrReplaceAsync(info).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,182 @@
using System.Collections.Generic;
using System.Linq;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Menus;
namespace Wino.Core.Services
{
public class ContextMenuItemService : IContextMenuItemService
{
public virtual IEnumerable<FolderOperationMenuItem> GetFolderContextMenuActions(IBaseFolderMenuItem folderInformation)
{
var list = new List<FolderOperationMenuItem>();
if (folderInformation.IsSticky)
list.Add(FolderOperationMenuItem.Create(FolderOperation.Unpin));
else
list.Add(FolderOperationMenuItem.Create(FolderOperation.Pin));
list.Add(FolderOperationMenuItem.Create(FolderOperation.Seperator));
// Following 4 items are disabled for system folders.
list.Add(FolderOperationMenuItem.Create(FolderOperation.Rename, !folderInformation.IsSystemFolder));
list.Add(FolderOperationMenuItem.Create(FolderOperation.Delete, !folderInformation.IsSystemFolder));
list.Add(FolderOperationMenuItem.Create(FolderOperation.CreateSubFolder, !folderInformation.IsSystemFolder));
list.Add(FolderOperationMenuItem.Create(FolderOperation.Seperator));
list.Add(FolderOperationMenuItem.Create(FolderOperation.Empty));
list.Add(FolderOperationMenuItem.Create(FolderOperation.MarkAllAsRead));
return list;
}
public virtual IEnumerable<MailOperationMenuItem> GetMailItemContextMenuActions(IEnumerable<IMailItem> selectedMailItems)
{
if (selectedMailItems == null)
return default;
var operationList = new List<MailOperationMenuItem>();
// Disable archive button for Archive folder itself.
bool isArchiveFolder = selectedMailItems.All(a => a.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive);
bool isDraftOrSent = selectedMailItems.All(a => a.AssignedFolder.SpecialFolderType == SpecialFolderType.Draft || a.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent);
bool isJunkFolder = selectedMailItems.All(a => a.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk);
bool isSingleItem = selectedMailItems.Count() == 1;
IMailItem singleItem = selectedMailItems.FirstOrDefault();
// Archive button.
if (isArchiveFolder)
operationList.Add(MailOperationMenuItem.Create(MailOperation.UnArchive));
else
operationList.Add(MailOperationMenuItem.Create(MailOperation.Archive));
// Delete button.
operationList.Add(MailOperationMenuItem.Create(MailOperation.SoftDelete));
// Move button.
operationList.Add(MailOperationMenuItem.Create(MailOperation.Move, !isDraftOrSent));
// Independent flag, read etc.
if (isSingleItem)
{
if (singleItem.IsFlagged)
operationList.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
else
operationList.Add(MailOperationMenuItem.Create(MailOperation.SetFlag));
if (singleItem.IsRead)
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread));
else
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead));
}
else
{
bool isAllFlagged = selectedMailItems.All(a => a.IsFlagged);
bool isAllRead = selectedMailItems.All(a => a.IsRead);
bool isAllUnread = selectedMailItems.All(a => !a.IsRead);
if (isAllRead)
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread));
else
{
if (!isAllUnread)
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread));
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead));
}
if (isAllFlagged)
operationList.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
else
{
operationList.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
operationList.Add(MailOperationMenuItem.Create(MailOperation.SetFlag));
}
}
// Ignore
if (!isDraftOrSent)
operationList.Add(MailOperationMenuItem.Create(MailOperation.Ignore));
// Seperator
operationList.Add(MailOperationMenuItem.Create(MailOperation.Seperator));
// Junk folder
if (isJunkFolder)
operationList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsNotJunk));
else if (!isDraftOrSent)
operationList.Add(MailOperationMenuItem.Create(MailOperation.MoveToJunk));
// TODO: Focus folder support.
// Remove the separator if it's the last item remaining.
// It's creating unpleasent UI glitch.
if (operationList.LastOrDefault()?.Operation == MailOperation.Seperator)
operationList.RemoveAt(operationList.Count - 1);
return operationList;
}
public virtual IEnumerable<MailOperationMenuItem> GetMailItemRenderMenuActions(IMailItem mailItem, bool isDarkEditor)
{
var actionList = new List<MailOperationMenuItem>();
bool isArchiveFolder = mailItem.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive;
// Add light/dark editor theme switch.
if (isDarkEditor)
actionList.Add(MailOperationMenuItem.Create(MailOperation.LightEditor));
else
actionList.Add(MailOperationMenuItem.Create(MailOperation.DarkEditor));
actionList.Add(MailOperationMenuItem.Create(MailOperation.Seperator));
// You can't do these to draft items.
if (!mailItem.IsDraft)
{
// Reply
actionList.Add(MailOperationMenuItem.Create(MailOperation.Reply));
// Reply All
actionList.Add(MailOperationMenuItem.Create(MailOperation.ReplyAll));
// Forward
actionList.Add(MailOperationMenuItem.Create(MailOperation.Forward));
}
// Archive - Unarchive
if (isArchiveFolder)
actionList.Add(MailOperationMenuItem.Create(MailOperation.UnArchive));
else
actionList.Add(MailOperationMenuItem.Create(MailOperation.Archive));
// Delete
actionList.Add(MailOperationMenuItem.Create(MailOperation.SoftDelete));
// Flag - Clear Flag
if (mailItem.IsFlagged)
actionList.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
else
actionList.Add(MailOperationMenuItem.Create(MailOperation.SetFlag));
// Secondary items.
// Read - Unread
if (mailItem.IsRead)
actionList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread, true, false));
else
actionList.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false));
return actionList;
}
}
}

View File

@@ -0,0 +1,69 @@
using System;
using System.IO;
using System.Threading.Tasks;
using SQLite;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Services
{
public interface IDatabaseService : IInitializeAsync
{
SQLiteAsyncConnection Connection { get; }
}
public class DatabaseService : IDatabaseService
{
private string DatabaseName => "Wino.db";
private bool _isInitialized = false;
private readonly IAppInitializerService _appInitializerService;
public SQLiteAsyncConnection Connection { get; private set; }
public DatabaseService(IAppInitializerService appInitializerService)
{
_appInitializerService = appInitializerService;
}
public async Task InitializeAsync()
{
if (_isInitialized)
return;
var applicationData = _appInitializerService.GetApplicationDataFolder();
var databaseFileName = Path.Combine(applicationData, DatabaseName);
Connection = new SQLiteAsyncConnection(databaseFileName)
{
// Enable for debugging sqlite.
Trace = true,
Tracer = new Action<string>((t) =>
{
// Debug.WriteLine(t);
// Log.Debug(t);
})
};
await CreateTablesAsync();
_isInitialized = true;
}
private async Task CreateTablesAsync()
{
await Connection.CreateTablesAsync(CreateFlags.None,
typeof(MailCopy),
typeof(MailItemFolder),
typeof(MailAccount),
typeof(TokenInformation),
typeof(AddressInformation),
typeof(CustomServerInformation),
typeof(AccountSignature),
typeof(MergedInbox),
typeof(MailAccountPreferences)
);
}
}
}

View File

@@ -0,0 +1,592 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MoreLinq;
using Serilog;
using SqlKata;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Extensions;
using Wino.Core.Requests;
namespace Wino.Core.Services
{
public class FolderService : BaseDatabaseService, IFolderService
{
private readonly IAccountService _accountService;
private readonly IMimeFileService _mimeFileService;
private readonly ILogger _logger = Log.ForContext<FolderService>();
private readonly SpecialFolderType[] gmailCategoryFolderTypes =
[
SpecialFolderType.Promotions,
SpecialFolderType.Social,
SpecialFolderType.Updates,
SpecialFolderType.Forums,
SpecialFolderType.Personal
];
public FolderService(IDatabaseService databaseService,
IAccountService accountService,
IMimeFileService mimeFileService) : base(databaseService)
{
_accountService = accountService;
_mimeFileService = mimeFileService;
}
public async Task ChangeStickyStatusAsync(Guid folderId, bool isSticky)
=> await Connection.ExecuteAsync("UPDATE MailItemFolder SET IsSticky = ? WHERE Id = ?", isSticky, folderId);
public async Task<int> GetFolderNotificationBadgeAsync(Guid folderId)
{
var folder = await GetFolderAsync(folderId);
if (folder == null || !folder.ShowUnreadCount) return default;
var account = await _accountService.GetAccountAsync(folder.MailAccountId);
if (account == null) return default;
var query = new Query("MailCopy")
.Where("FolderId", folderId)
.SelectRaw("count (DISTINCT Id)");
// If focused inbox is enabled, we need to check if this is the inbox folder.
if (account.Preferences.IsFocusedInboxEnabled.GetValueOrDefault() && folder.SpecialFolderType == SpecialFolderType.Inbox)
{
query.Where("IsFocused", 1);
}
// Draft and Junk folders are not counted as unread. They must return the item count instead.
if (folder.SpecialFolderType != SpecialFolderType.Draft || folder.SpecialFolderType != SpecialFolderType.Junk)
{
query.Where("IsRead", 0);
}
return await Connection.ExecuteScalarAsync<int>(query.GetRawQuery());
}
public async Task<AccountFolderTree> GetFolderStructureForAccountAsync(Guid accountId, bool includeHiddenFolders)
{
var account = await _accountService.GetAccountAsync(accountId);
if (account == null)
throw new ArgumentException(nameof(account));
var accountTree = new AccountFolderTree(account);
// Account folders.
var folderQuery = Connection.Table<MailItemFolder>().Where(a => a.MailAccountId == accountId);
if (!includeHiddenFolders)
folderQuery = folderQuery.Where(a => !a.IsHidden);
// Load child folders for each folder.
var allFolders = await folderQuery.OrderBy(a => a.SpecialFolderType).ToListAsync();
if (allFolders.Any())
{
// Get sticky folders. Category type is always sticky.
// Sticky folders don't have tree structure. So they can be added to the main tree.
var stickyFolders = allFolders.Where(a => a.IsSticky && a.SpecialFolderType != SpecialFolderType.Category);
foreach (var stickyFolder in stickyFolders)
{
var childStructure = await GetChildFolderItemsRecursiveAsync(stickyFolder.Id, accountId);
accountTree.Folders.Add(childStructure);
}
// Check whether we need special 'Categories' kind of folder.
var categoryExists = allFolders.Any(a => a.SpecialFolderType == SpecialFolderType.Category);
if (categoryExists)
{
var categoryFolder = allFolders.First(a => a.SpecialFolderType == SpecialFolderType.Category);
// Construct category items under pinned items.
var categoryFolders = allFolders.Where(a => gmailCategoryFolderTypes.Contains(a.SpecialFolderType));
foreach (var categoryFolderSubItem in categoryFolders)
{
categoryFolder.ChildFolders.Add(categoryFolderSubItem);
}
accountTree.Folders.Add(categoryFolder);
allFolders.Remove(categoryFolder);
}
// Move rest of the items into virtual More folder if any.
var nonStickyFolders = allFolders.Except(stickyFolders);
if (nonStickyFolders.Any())
{
var virtualMoreFolder = new MailItemFolder()
{
FolderName = Translator.More,
SpecialFolderType = SpecialFolderType.More
};
foreach (var unstickyItem in nonStickyFolders)
{
if (account.ProviderType == MailProviderType.Gmail)
{
// Gmail requires this check to not include child folders as
// separate folder without their parent for More folder...
if (!string.IsNullOrEmpty(unstickyItem.ParentRemoteFolderId))
continue;
}
else if (account.ProviderType == MailProviderType.Outlook || account.ProviderType == MailProviderType.Office365)
{
bool belongsToExistingParent = (await Connection
.Table<MailItemFolder>()
.Where(a => unstickyItem.ParentRemoteFolderId == a.RemoteFolderId)
.CountAsync()) > 0;
// No need to include this as unsticky.
if (belongsToExistingParent) continue;
}
var structure = await GetChildFolderItemsRecursiveAsync(unstickyItem.Id, accountId);
virtualMoreFolder.ChildFolders.Add(structure);
}
// Only add more if there are any.
if (virtualMoreFolder.ChildFolders.Count > 0)
accountTree.Folders.Add(virtualMoreFolder);
}
}
return accountTree;
}
private async Task<MailItemFolder> GetChildFolderItemsRecursiveAsync(Guid folderId, Guid accountId)
{
var folder = await Connection.Table<MailItemFolder>().Where(a => a.Id == folderId && a.MailAccountId == accountId).FirstOrDefaultAsync();
if (folder == null)
return null;
var childFolders = await Connection.Table<MailItemFolder>()
.Where(a => a.ParentRemoteFolderId == folder.RemoteFolderId && a.MailAccountId == folder.MailAccountId)
.ToListAsync();
foreach (var childFolder in childFolders)
{
var subChild = await GetChildFolderItemsRecursiveAsync(childFolder.Id, accountId);
folder.ChildFolders.Add(subChild);
}
return folder;
}
public async Task<MailItemFolder> GetSpecialFolderByAccountIdAsync(Guid accountId, SpecialFolderType type)
=> await Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.SpecialFolderType == type);
public async Task<MailItemFolder> GetFolderAsync(Guid folderId)
=> await Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.Id.Equals(folderId));
public Task<int> GetCurrentItemCountForFolder(Guid folderId)
=> Connection.Table<MailCopy>().Where(a => a.FolderId == folderId).CountAsync();
public Task<List<MailItemFolder>> GetFoldersAsync(Guid accountId)
=> Connection.Table<MailItemFolder>().Where(a => a.MailAccountId == accountId).ToListAsync();
public async Task UpdateCustomServerMailListAsync(Guid accountId, List<MailItemFolder> folders)
{
var account = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id == accountId);
if (account == null)
return;
// IMAP servers don't have unique identifier for folders all the time.
// We'll map them with parent-name relation.
var currentFolders = await GetFoldersAsync(accountId);
// These folders don't exist anymore. Remove them.
var localRemoveFolders = currentFolders.ExceptBy(folders, a => a.RemoteFolderId);
foreach (var currentFolder in currentFolders)
{
// Check if we have this folder locally.
var remotelyExistFolder = folders.FirstOrDefault(a => a.RemoteFolderId == currentFolder.RemoteFolderId
&& a.ParentRemoteFolderId == currentFolder.ParentRemoteFolderId);
if (remotelyExistFolder == null)
{
// This folder is removed.
// Remove everything for this folder.
}
}
foreach (var folder in folders)
{
var currentFolder = await Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.RemoteFolderId == folder.RemoteFolderId);
// Nothing is changed, it's still the same folder.
// Just update Id of the folder.
if (currentFolder != null)
folder.Id = currentFolder.Id;
await Connection.InsertOrReplaceAsync(folder);
}
}
public async Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
{
var folder = await GetFolderAsync(folderId);
if (folder == null) return default;
var mailCopyIds = await GetMailCopyIdsByFolderIdAsync(folderId);
// Make sure we don't include Ids that doesn't have uid separator.
// Local drafts might not have it for example.
return new List<uint>(mailCopyIds.Where(a => a.Contains(MailkitClientExtensions.MailCopyUidSeparator)).Select(a => MailkitClientExtensions.ResolveUid(a)));
}
public async Task<MailAccount> UpdateSystemFolderConfigurationAsync(Guid accountId, SystemFolderConfiguration configuration)
{
if (configuration == null)
throw new ArgumentNullException(nameof(configuration));
var account = await _accountService.GetAccountAsync(accountId);
if (account == null)
throw new ArgumentNullException(nameof(account));
// Update system folders for this account.
await Task.WhenAll(UpdateSystemFolderInternalAsync(configuration.SentFolder, SpecialFolderType.Sent),
UpdateSystemFolderInternalAsync(configuration.DraftFolder, SpecialFolderType.Draft),
UpdateSystemFolderInternalAsync(configuration.JunkFolder, SpecialFolderType.Junk),
UpdateSystemFolderInternalAsync(configuration.TrashFolder, SpecialFolderType.Deleted));
await _accountService.UpdateAccountAsync(account);
return account;
}
private Task UpdateSystemFolderInternalAsync(MailItemFolder folder, SpecialFolderType assignedSpecialFolderType)
{
if (folder == null) return Task.CompletedTask;
folder.IsSticky = true;
folder.IsSynchronizationEnabled = true;
folder.IsSystemFolder = true;
folder.SpecialFolderType = assignedSpecialFolderType;
return UpdateFolderAsync(folder);
}
public async Task ChangeFolderSynchronizationStateAsync(Guid folderId, bool isSynchronizationEnabled)
{
var localFolder = await Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.Id == folderId);
if (localFolder != null)
{
localFolder.IsSynchronizationEnabled = isSynchronizationEnabled;
await UpdateFolderAsync(localFolder).ConfigureAwait(false);
}
}
#region Repository Calls
public async Task InsertFolderAsync(MailItemFolder folder)
{
if (folder == null)
{
_logger.Warning("Folder is null. Cannot insert.");
return;
}
var account = await _accountService.GetAccountAsync(folder.MailAccountId);
if (account == null)
{
_logger.Warning("Account with id {MailAccountId} does not exist. Cannot insert folder.", folder.MailAccountId);
return;
}
var existingFolder = await GetFolderAsync(folder.Id).ConfigureAwait(false);
// IMAP servers don't have unique identifier for folders all the time.
// So we'll try to match them with remote folder id and account id relation.
// If we have a match, we'll update the folder instead of inserting.
existingFolder ??= await GetFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
if (existingFolder == null)
{
_logger.Debug("Inserting folder {Id} - {FolderName}", folder.Id, folder.FolderName, folder.MailAccountId);
await Connection.InsertAsync(folder).ConfigureAwait(false);
ReportUIChange(new FolderAddedMessage(folder, account));
}
else
{
_logger.Debug("Folder {Id} - {FolderName} already exists. Updating.", folder.Id, folder.FolderName);
await UpdateFolderAsync(folder).ConfigureAwait(false);
}
}
private async Task UpdateFolderAsync(MailItemFolder folder)
{
if (folder == null)
{
_logger.Warning("Folder is null. Cannot update.");
return;
}
var account = await _accountService.GetAccountAsync(folder.MailAccountId).ConfigureAwait(false);
if (account == null)
{
_logger.Warning("Account with id {MailAccountId} does not exist. Cannot update folder.", folder.MailAccountId);
return;
}
#if !DEBUG // Annoying
_logger.Debug("Updating folder {FolderName}", folder.Id, folder.FolderName);
#endif
await Connection.UpdateAsync(folder).ConfigureAwait(false);
ReportUIChange(new FolderUpdatedMessage(folder, account));
}
private async Task DeleteFolderAsync(MailItemFolder folder)
{
if (folder == null)
{
_logger.Warning("Folder is null. Cannot delete.");
return;
}
var account = await _accountService.GetAccountAsync(folder.MailAccountId).ConfigureAwait(false);
if (account == null)
{
_logger.Warning("Account with id {MailAccountId} does not exist. Cannot delete folder.", folder.MailAccountId);
return;
}
_logger.Debug("Deleting folder {FolderName}", folder.FolderName);
await Connection.DeleteAsync(folder).ConfigureAwait(false);
ReportUIChange(new FolderRemovedMessage(folder, account));
}
#endregion
private Task<List<string>> GetMailCopyIdsByFolderIdAsync(Guid folderId)
{
var query = new Query("MailCopy")
.Where("FolderId", folderId)
.Select("Id");
return Connection.QueryScalarsAsync<string>(query.GetRawQuery());
}
public async Task<List<MailFolderPairMetadata>> GetMailFolderPairMetadatasAsync(IEnumerable<string> mailCopyIds)
{
// Get all assignments for all items.
var query = new Query(nameof(MailCopy))
.Join(nameof(MailItemFolder), $"{nameof(MailCopy)}.FolderId", $"{nameof(MailItemFolder)}.Id")
.WhereIn($"{nameof(MailCopy)}.Id", mailCopyIds)
.SelectRaw($"{nameof(MailCopy)}.Id as MailCopyId, {nameof(MailItemFolder)}.Id as FolderId, {nameof(MailItemFolder)}.RemoteFolderId as RemoteFolderId")
.Distinct();
var rowQuery = query.GetRawQuery();
return await Connection.QueryAsync<MailFolderPairMetadata>(rowQuery);
}
public Task<List<MailFolderPairMetadata>> GetMailFolderPairMetadatasAsync(string mailCopyId)
=> GetMailFolderPairMetadatasAsync(new List<string>() { mailCopyId });
public async Task SetSpecialFolderAsync(Guid folderId, SpecialFolderType type)
=> await Connection.ExecuteAsync("UPDATE MailItemFolder SET SpecialFolderType = ? WHERE Id = ?", type, folderId);
public async Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options)
{
var folders = new List<MailItemFolder>();
if (options.Type == SynchronizationType.Inbox)
{
var inboxFolder = await GetSpecialFolderByAccountIdAsync(options.AccountId, SpecialFolderType.Inbox);
var sentFolder = await GetSpecialFolderByAccountIdAsync(options.AccountId, SpecialFolderType.Sent);
var draftFolder = await GetSpecialFolderByAccountIdAsync(options.AccountId, SpecialFolderType.Draft);
// For properly creating threads we need Sent and Draft to be synchronized as well.
if (sentFolder != null && sentFolder.IsSynchronizationEnabled)
{
folders.Add(sentFolder);
}
if (draftFolder != null && draftFolder.IsSynchronizationEnabled)
{
folders.Add(draftFolder);
}
// User might've disabled inbox synchronization somehow...
if (inboxFolder != null && inboxFolder.IsSynchronizationEnabled)
{
folders.Add(inboxFolder);
}
}
else if (options.Type == SynchronizationType.Full)
{
// Only get sync enabled folders.
var synchronizationFolders = await Connection.Table<MailItemFolder>()
.Where(a => a.MailAccountId == options.AccountId && a.IsSynchronizationEnabled)
.OrderBy(a => a.SpecialFolderType)
.ToListAsync();
folders.AddRange(synchronizationFolders);
}
else if (options.Type == SynchronizationType.Custom)
{
// Only get the specified and enabled folders.
var synchronizationFolders = await Connection.Table<MailItemFolder>()
.Where(a => a.MailAccountId == options.AccountId && a.IsSynchronizationEnabled && options.SynchronizationFolderIds.Contains(a.Id))
.ToListAsync();
folders.AddRange(synchronizationFolders);
}
return folders;
}
public Task<MailItemFolder> GetFolderAsync(Guid accountId, string remoteFolderId)
=> Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.RemoteFolderId == remoteFolderId);
// v2
public async Task BulkUpdateFolderStructureAsync(Guid accountId, List<MailItemFolder> allFolders)
{
var existingFolders = await GetFoldersAsync(accountId).ConfigureAwait(false);
var foldersToInsert = allFolders.ExceptBy(existingFolders, a => a.RemoteFolderId);
var foldersToDelete = existingFolders.ExceptBy(allFolders, a => a.RemoteFolderId);
var foldersToUpdate = allFolders.Except(foldersToInsert).Except(foldersToDelete);
_logger.Debug("Found {0} folders to insert, {1} folders to update and {2} folders to delete.",
foldersToInsert.Count(),
foldersToUpdate.Count(),
foldersToDelete.Count());
foreach (var folder in foldersToInsert)
{
await InsertFolderAsync(folder).ConfigureAwait(false);
}
foreach (var folder in foldersToUpdate)
{
await UpdateFolderAsync(folder).ConfigureAwait(false);
}
foreach (var folder in foldersToDelete)
{
await DeleteFolderAsync(folder).ConfigureAwait(false);
}
}
public async Task<string> UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string synchronizationIdentifier)
{
var folder = await GetFolderAsync(folderId).ConfigureAwait(false);
if (folder == null)
{
_logger.Warning("Folder with id {FolderId} does not exist.", folderId);
return string.Empty;
}
folder.DeltaToken = synchronizationIdentifier;
await UpdateFolderAsync(folder).ConfigureAwait(false);
return synchronizationIdentifier;
}
public async Task DeleteFolderAsync(Guid accountId, string remoteFolderId)
{
var folder = await GetFolderAsync(accountId, remoteFolderId);
if (folder == null)
{
_logger.Warning("Folder with id {RemoteFolderId} does not exist. Delete folder canceled.", remoteFolderId);
return;
}
await DeleteFolderAsync(folder).ConfigureAwait(false);
}
public async Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount)
{
var localFolder = await GetFolderAsync(folderId);
if (localFolder != null)
{
localFolder.ShowUnreadCount = showUnreadCount;
await UpdateFolderAsync(localFolder).ConfigureAwait(false);
}
}
// Inbox folder is always included for account menu item unread count.
public Task<List<MailItemFolder>> GetUnreadUpdateFoldersAsync(Guid accountId)
=> Connection.Table<MailItemFolder>().Where(a => a.MailAccountId == accountId && (a.ShowUnreadCount || a.SpecialFolderType == SpecialFolderType.Inbox)).ToListAsync();
public async Task TestAsync()
{
var account = new MailAccount()
{
Address = "test@test.com",
ProviderType = MailProviderType.Gmail,
Name = "Test Account",
Id = Guid.NewGuid()
};
await Connection.InsertAsync(account);
var pref = new MailAccountPreferences
{
Id = Guid.NewGuid(),
AccountId = account.Id
};
await Connection.InsertAsync(pref);
ReportUIChange(new AccountCreatedMessage(account));
}
public async Task<bool> IsInboxAvailableForAccountAsync(Guid accountId)
=> (await Connection.Table<MailItemFolder>()
.Where(a => a.SpecialFolderType == SpecialFolderType.Inbox && a.MailAccountId == accountId)
.CountAsync()) == 1;
}
}

View File

@@ -0,0 +1,50 @@
using System.Collections.Generic;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Reader;
namespace Wino.Core.Services
{
public class FontService : IFontService
{
private readonly IPreferencesService _preferencesService;
private ILogger _logger = Log.ForContext<FontService>();
private readonly List<ReaderFontModel> _availableFonts =
[
new ReaderFontModel(ReaderFont.Arial, "Arial"),
new ReaderFontModel(ReaderFont.Calibri, "Calibri"),
new ReaderFontModel(ReaderFont.TimesNewRoman, "Times New Roman"),
new ReaderFontModel(ReaderFont.TrebuchetMS, "Trebuchet MS"),
new ReaderFontModel(ReaderFont.Tahoma, "Tahoma"),
new ReaderFontModel(ReaderFont.Verdana, "Verdana"),
new ReaderFontModel(ReaderFont.Georgia, "Georgia"),
new ReaderFontModel(ReaderFont.CourierNew, "Courier New")
];
public FontService(IPreferencesService preferencesService)
{
_preferencesService = preferencesService;
}
public List<ReaderFontModel> GetReaderFonts() => _availableFonts;
public void ChangeReaderFont(ReaderFont font)
{
_preferencesService.ReaderFont = font;
_logger.Information("Default reader font is changed to {Font}", font);
}
public void ChangeReaderFontSize(int size)
{
_preferencesService.ReaderFontSize = size;
_logger.Information("Default reader font size is changed to {Size}", size);
}
public ReaderFontModel GetCurrentReaderFont() => _availableFonts.Find(f => f.Font == _preferencesService.ReaderFont);
public int GetCurrentReaderFontSize() => _preferencesService.ReaderFontSize;
}
}

View File

@@ -0,0 +1,53 @@
using System.IO;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Services
{
public class ImapTestService : IImapTestService
{
public const string ProtocolLogFileName = "ImapProtocolLog.log";
private readonly IPreferencesService _preferencesService;
private readonly IAppInitializerService _appInitializerService;
public ImapTestService(IPreferencesService preferencesService, IAppInitializerService appInitializerService)
{
_preferencesService = preferencesService;
_appInitializerService = appInitializerService;
}
public async Task TestImapConnectionAsync(CustomServerInformation serverInformation)
{
ImapClient client = null;
if (_preferencesService.IsMailkitProtocolLoggerEnabled)
{
// Create new file for protocol logger.
var localAppFolderPath = _appInitializerService.GetApplicationDataFolder();
var logFile = Path.Combine(localAppFolderPath, ProtocolLogFileName);
if (File.Exists(logFile))
File.Delete(logFile);
var stream = File.Create(logFile);
client = new ImapClient(new ProtocolLogger(stream));
}
else
client = new ImapClient();
using (client)
{
// todo: test connection
// await client.InitializeAsync(serverInformation);
await client.DisconnectAsync(true);
}
}
}
}

View File

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

View File

@@ -0,0 +1,833 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MimeKit;
using MimeKit.Text;
using MoreLinq;
using Serilog;
using SqlKata;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Comparers;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Extensions;
using Wino.Core.Requests;
namespace Wino.Core.Services
{
public class MailService : BaseDatabaseService, IMailService
{
private const int ItemLoadCount = 20;
private readonly IFolderService _folderService;
private readonly IContactService _contactService;
private readonly IAccountService _accountService;
private readonly ISignatureService _signatureService;
private readonly IThreadingStrategyProvider _threadingStrategyProvider;
private readonly IMimeFileService _mimeFileService;
private readonly ILogger _logger = Log.ForContext<MailService>();
public MailService(IDatabaseService databaseService,
IFolderService folderService,
IContactService contactService,
IAccountService accountService,
ISignatureService signatureService,
IThreadingStrategyProvider threadingStrategyProvider,
IMimeFileService mimeFileService) : base(databaseService)
{
_folderService = folderService;
_contactService = contactService;
_accountService = accountService;
_signatureService = signatureService;
_threadingStrategyProvider = threadingStrategyProvider;
_mimeFileService = mimeFileService;
}
public async Task<MailCopy> CreateDraftAsync(MailAccount composerAccount,
MimeMessage createdDraftMimeMessage,
MimeMessage replyingMimeMessage = null,
IMailItem replyingMailItem = null)
{
bool isImapAccount = composerAccount.ServerInformation != null;
string fromName;
if (isImapAccount)
fromName = composerAccount.ServerInformation.DisplayName;
else
{
var composerContact = await _contactService.GetAddressInformationByAddressAsync(composerAccount.Address);
fromName = composerContact?.Name ?? composerAccount.Address;
}
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(composerAccount.Id, SpecialFolderType.Draft);
// Get locally created unique id from the mime headers.
// This header will be used to map the local draft copy with the remote draft copy.
var mimeUniqueId = createdDraftMimeMessage.Headers[Constants.WinoLocalDraftHeader];
var copy = new MailCopy
{
UniqueId = Guid.Parse(mimeUniqueId),
Id = Guid.NewGuid().ToString(), // This will be replaced after network call with the remote draft id.
CreationDate = DateTime.UtcNow,
FromAddress = composerAccount.Address,
FromName = fromName,
HasAttachments = false,
Importance = MailImportance.Normal,
Subject = createdDraftMimeMessage.Subject,
PreviewText = createdDraftMimeMessage.TextBody,
IsRead = true,
IsDraft = true,
FolderId = draftFolder.Id,
DraftId = $"{Constants.LocalDraftStartPrefix}{Guid.NewGuid()}",
AssignedFolder = draftFolder,
AssignedAccount = composerAccount,
FileId = Guid.NewGuid()
};
// If replying, add In-Reply-To, ThreadId and References.
bool isReplying = replyingMimeMessage != null;
if (isReplying)
{
if (replyingMimeMessage.References != null)
copy.References = string.Join(",", replyingMimeMessage.References);
if (!string.IsNullOrEmpty(replyingMimeMessage.MessageId))
copy.InReplyTo = replyingMimeMessage.MessageId;
if (!string.IsNullOrEmpty(replyingMailItem?.ThreadId))
copy.ThreadId = replyingMailItem.ThreadId;
}
await Connection.InsertAsync(copy);
await _mimeFileService.SaveMimeMessageAsync(copy.FileId, createdDraftMimeMessage, composerAccount.Id);
ReportUIChange(new DraftCreated(copy, composerAccount));
return copy;
}
public Task<List<string>> GetMailIdsByFolderIdAsync(Guid folderId)
=> Connection.QueryScalarsAsync<string>("SELECT Id FROM MailCopy WHERE FolderId = ?", folderId);
private string BuildMailFetchQuery(MailListInitializationOptions options)
{
// If the search query is there, we should ignore some properties and trim it.
//if (!string.IsNullOrEmpty(options.SearchQuery))
//{
// options.IsFocusedOnly = null;
// filterType = FilterOptionType.All;
// searchQuery = searchQuery.Trim();
//}
// SQLite PCL doesn't support joins.
// We make the query using SqlKatka and execute it directly on SQLite-PCL.
var query = new Query("MailCopy")
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
.WhereIn("MailCopy.FolderId", options.Folders.Select(a => a.Id))
.Take(ItemLoadCount)
.SelectRaw("MailCopy.*");
if (options.SortingOptionType == SortingOptionType.ReceiveDate)
query.OrderByDesc("CreationDate");
else if (options.SortingOptionType == SortingOptionType.Sender)
query.OrderBy("FromName");
// Conditional where.
switch (options.FilterType)
{
case FilterOptionType.Unread:
query.Where("MailCopy.IsRead", false);
break;
case FilterOptionType.Flagged:
query.Where("MailCopy.IsFlagged", true);
break;
}
if (options.IsFocusedOnly != null)
query.Where("MailCopy.IsFocused", options.IsFocusedOnly.Value);
if (!string.IsNullOrEmpty(options.SearchQuery))
query.Where(a =>
a.OrWhereContains("MailCopy.PreviewText", options.SearchQuery)
.OrWhereContains("MailCopy.Subject", options.SearchQuery)
.OrWhereContains("MailCopy.FromName", options.SearchQuery)
.OrWhereContains("MailCopy.FromAddress", options.SearchQuery));
if (options.ExistingUniqueIds?.Any() ?? false)
{
query.WhereNotIn("MailCopy.UniqueId", options.ExistingUniqueIds);
}
//if (options.Skip > 0)
//{
// query.Skip(options.Skip);
//}
return query.GetRawQuery();
}
public async Task<List<IMailItem>> FetchMailsAsync(MailListInitializationOptions options)
{
var query = BuildMailFetchQuery(options);
var mails = await Connection.QueryAsync<MailCopy>(query);
// Fill in assigned account and folder for each mail.
// To speed things up a bit, we'll load account and assigned folder in groups
// to reduce the query time.
var groupedByFolders = mails.GroupBy(a => a.FolderId);
foreach (var group in groupedByFolders)
{
MailItemFolder folderAssignment = null;
MailAccount accountAssignment = null;
folderAssignment = await _folderService.GetFolderAsync(group.Key).ConfigureAwait(false);
if (folderAssignment != null)
{
accountAssignment = await _accountService.GetAccountAsync(folderAssignment.MailAccountId).ConfigureAwait(false);
}
group.ForEach(a =>
{
a.AssignedFolder = folderAssignment;
a.AssignedAccount = accountAssignment;
});
}
// Remove items that has no assigned account or folder.
mails.RemoveAll(a => a.AssignedAccount == null || a.AssignedFolder == null);
// Each account items must be threaded separately.
if (options.CreateThreads)
{
var threadedItems = new List<IMailItem>();
var groupedByAccounts = mails.GroupBy(a => a.AssignedAccount.Id);
foreach (var group in groupedByAccounts)
{
if (!group.Any()) continue;
var accountId = group.Key;
var groupAccount = mails.First(a => a.AssignedAccount.Id == accountId).AssignedAccount;
var threadingStrategy = _threadingStrategyProvider.GetStrategy(groupAccount.ProviderType);
// Only thread items from Draft and Sent folders must present here.
// Otherwise this strategy will fetch the items that are in Deleted folder as well.
var accountThreadedItems = await threadingStrategy.ThreadItemsAsync(group.ToList());
if (accountThreadedItems != null)
{
threadedItems.AddRange(accountThreadedItems);
}
}
threadedItems.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer());
return threadedItems;
}
else
{
// Threading is disabled. Just return everything as it is.
mails.Sort(options.SortingOptionType == SortingOptionType.ReceiveDate ? new DateComparer() : new NameComparer());
return new List<IMailItem>(mails);
}
}
private async Task<List<MailCopy>> GetMailItemsAsync(string mailCopyId)
{
var mailCopies = await Connection.Table<MailCopy>().Where(a => a.Id == mailCopyId).ToListAsync();
foreach (var mailCopy in mailCopies)
{
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
}
return mailCopies;
}
private async Task LoadAssignedPropertiesAsync(MailCopy mailCopy)
{
if (mailCopy == null) return;
// Load AssignedAccount and AssignedFolder.
var folder = await _folderService.GetFolderAsync(mailCopy.FolderId);
if (folder == null) return;
var account = await _accountService.GetAccountAsync(folder.MailAccountId);
if (account == null) return;
mailCopy.AssignedAccount = account;
mailCopy.AssignedFolder = folder;
}
public async Task<MailCopy> GetSingleMailItemWithoutFolderAssignmentAsync(string mailCopyId)
{
var mailCopy = await Connection.Table<MailCopy>().FirstOrDefaultAsync(a => a.Id == mailCopyId);
if (mailCopy == null) return null;
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
return mailCopy;
}
public async Task<MailCopy> GetSingleMailItemAsync(string mailCopyId, string remoteFolderId)
{
var query = new Query("MailCopy")
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
.Where("MailCopy.Id", mailCopyId)
.Where("MailItemFolder.RemoteFolderId", remoteFolderId)
.SelectRaw("MailCopy.*")
.GetRawQuery();
var mailItem = await Connection.FindWithQueryAsync<MailCopy>(query);
if (mailItem == null) return null;
await LoadAssignedPropertiesAsync(mailItem).ConfigureAwait(false);
return mailItem;
}
public async Task<MailCopy> GetSingleMailItemAsync(Guid uniqueMailId)
{
var mailItem = await Connection.FindAsync<MailCopy>(uniqueMailId);
if (mailItem == null) return null;
await LoadAssignedPropertiesAsync(mailItem).ConfigureAwait(false);
return mailItem;
}
// v2
public async Task DeleteMailAsync(Guid accountId, string mailCopyId)
{
var allMails = await GetMailItemsAsync(mailCopyId).ConfigureAwait(false);
foreach (var mailItem in allMails)
{
await DeleteMailInternalAsync(mailItem).ConfigureAwait(false);
// Delete mime file.
// Even though Gmail might have multiple copies for the same mail, we only have one MIME file for all.
// Their FileId is inserted same.
await _mimeFileService.DeleteMimeMessageAsync(accountId, mailItem.FileId);
}
}
#region Repository Calls
private async Task InsertMailAsync(MailCopy mailCopy)
{
if (mailCopy == null)
{
_logger.Warning("Null mail passed to InsertMailAsync call.");
return;
}
if (mailCopy.FolderId == Guid.Empty)
{
_logger.Warning("Invalid FolderId for MailCopyId {Id} for InsertMailAsync", mailCopy.Id);
return;
}
_logger.Debug("Inserting mail {MailCopyId} to Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
await Connection.InsertAsync(mailCopy).ConfigureAwait(false);
ReportUIChange(new MailAddedMessage(mailCopy));
}
public async Task UpdateMailAsync(MailCopy mailCopy)
{
if (mailCopy == null)
{
_logger.Warning("Null mail passed to UpdateMailAsync call.");
return;
}
_logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
await Connection.UpdateAsync(mailCopy).ConfigureAwait(false);
ReportUIChange(new MailUpdatedMessage(mailCopy));
}
private async Task DeleteMailInternalAsync(MailCopy mailCopy)
{
if (mailCopy == null)
{
_logger.Warning("Null mail passed to DeleteMailAsync call.");
return;
}
_logger.Debug("Deleting mail {Id} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
await Connection.DeleteAsync(mailCopy).ConfigureAwait(false);
ReportUIChange(new MailRemovedMessage(mailCopy));
}
#endregion
private async Task UpdateAllMailCopiesAsync(string mailCopyId, Func<MailCopy, bool> action)
{
var mailCopies = await GetMailItemsAsync(mailCopyId);
if (mailCopies == null || !mailCopies.Any())
{
_logger.Warning("Updating mail copies failed because there are no copies available with Id {MailCopyId}", mailCopyId);
return;
}
_logger.Information("Updating {MailCopyCount} mail copies with Id {MailCopyId}", mailCopies.Count, mailCopyId);
foreach (var mailCopy in mailCopies)
{
bool shouldUpdateItem = action(mailCopy);
if (shouldUpdateItem)
{
await UpdateMailAsync(mailCopy).ConfigureAwait(false);
}
else
_logger.Information("Skipped updating mail because it is already in the desired state.");
}
}
public Task ChangeReadStatusAsync(string mailCopyId, bool isRead)
=> UpdateAllMailCopiesAsync(mailCopyId, (item) =>
{
item.IsRead = isRead;
return true;
});
public Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged)
=> UpdateAllMailCopiesAsync(mailCopyId, (item) =>
{
item.IsFlagged = isFlagged;
return true;
});
public async Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
{
// Note: Folder might not be available at the moment due to user not syncing folders before the delta processing.
// This is a problem, because assignments won't be created.
// Therefore we sync folders every time before the delta processing.
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
if (localFolder == null)
{
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
_logger.Warning("Skipping assignment creation for the the message {MailCopyId}", mailCopyId);
return;
}
var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId);
if (mailCopy == null)
{
_logger.Warning("Can't create assignment for mail {MailCopyId} because it does not exist.", mailCopyId);
return;
}
// Copy one of the mail copy and assign it to the new folder.
// We don't need to create a new MIME pack.
// Therefore FileId is not changed for the new MailCopy.
mailCopy.UniqueId = Guid.NewGuid();
mailCopy.FolderId = localFolder.Id;
mailCopy.AssignedFolder = localFolder;
await InsertMailAsync(mailCopy).ConfigureAwait(false);
}
public async Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
{
var mailItem = await GetSingleMailItemAsync(mailCopyId, remoteFolderId).ConfigureAwait(false);
if (mailItem == null)
{
_logger.Warning("Mail not found with id {MailCopyId} with remote folder {RemoteFolderId}", mailCopyId, remoteFolderId);
return;
}
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
if (localFolder == null)
{
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
return;
}
await DeleteMailInternalAsync(mailItem).ConfigureAwait(false);
}
public async Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
{
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
if (account == null) return false;
if (string.IsNullOrEmpty(package.AssignedRemoteFolderId))
{
_logger.Warning("Remote folder id is not set for {MailCopyId}.", package.Copy.Id);
_logger.Warning("Ignoring creation of mail.");
return false;
}
var assignedFolder = await _folderService.GetFolderAsync(accountId, package.AssignedRemoteFolderId).ConfigureAwait(false);
if (assignedFolder == null)
{
_logger.Warning("Assigned folder not found for {MailCopyId}.", package.Copy.Id);
_logger.Warning("Ignoring creation of mail.");
return false;
}
var mailCopy = package.Copy;
var mimeMessage = package.Mime;
mailCopy.UniqueId = Guid.NewGuid();
mailCopy.AssignedAccount = account;
mailCopy.AssignedFolder = assignedFolder;
mailCopy.FolderId = assignedFolder.Id;
// Only save MIME files if they don't exists.
// This is because 1 mail may have multiple copies in different folders.
// but only single MIME to represent all.
// Save mime file to disk.
var isMimeExists = await _mimeFileService.IsMimeExistAsync(accountId, mailCopy.FileId);
if (!isMimeExists)
{
bool isMimeSaved = await _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, accountId).ConfigureAwait(false);
if (!isMimeSaved)
{
_logger.Warning("Failed to save mime file for {MailCopyId}.", mailCopy.Id);
}
}
// Save contact information.
await _contactService.SaveAddressInformationAsync(mimeMessage).ConfigureAwait(false);
// Create mail copy in the database.
// Update if exists.
var existingCopyItem = await Connection.Table<MailCopy>()
.FirstOrDefaultAsync(a => a.Id == mailCopy.Id && a.FolderId == assignedFolder.Id);
if (existingCopyItem != null)
{
mailCopy.UniqueId = existingCopyItem.UniqueId;
await UpdateMailAsync(mailCopy).ConfigureAwait(false);
return false;
}
else
{
await InsertMailAsync(mailCopy).ConfigureAwait(false);
return true;
}
}
public async Task<MimeMessage> CreateDraftMimeMessageAsync(Guid accountId, DraftCreationOptions draftCreationOptions)
{
// This unique id is stored in mime headers for Wino to identify remote message with local copy.
// Same unique id will be used for the local copy as well.
// Synchronizer will map this unique id to the local draft copy after synchronization.
var messageUniqueId = Guid.NewGuid();
var message = new MimeMessage()
{
Headers = { { Constants.WinoLocalDraftHeader, messageUniqueId.ToString() } }
};
var builder = new BodyBuilder();
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
if (account == null)
{
_logger.Warning("Can't create draft mime message because account {AccountId} does not exist.", accountId);
return null;
}
var reason = draftCreationOptions.Reason;
var referenceMessage = draftCreationOptions.ReferenceMimeMessage;
// For API synchronizers we should get this from contacts.
if (account.ServerInformation == null)
{
var fromContact = await _contactService.GetAddressInformationByAddressAsync(account.Address).ConfigureAwait(false)
?? new AddressInformation() { Name = account.Address, Address = account.Address };
message.From.Add(new MailboxAddress(fromContact.Name, fromContact.Address));
}
else
{
// For IMAP synchronizer, we have already Display Name in the settings.
message.From.Add(new MailboxAddress(account.ServerInformation.DisplayName, account.ServerInformation.Address));
}
// Manage "To"
if (reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll)
{
// Reply to the sender of the message
if (referenceMessage.ReplyTo.Count > 0)
message.To.AddRange(referenceMessage.ReplyTo);
else if (referenceMessage.From.Count > 0)
message.To.AddRange(referenceMessage.From);
else if (referenceMessage.Sender != null)
message.To.Add(referenceMessage.Sender);
if (reason == DraftCreationReason.ReplyAll)
{
// Include all of the other original recipients
message.To.AddRange(referenceMessage.To);
// Find self and remove
var self = message.To.FirstOrDefault(a => a is MailboxAddress mailboxAddress && mailboxAddress.Address == account.Address);
if (self != null)
message.To.Remove(self);
message.Cc.AddRange(referenceMessage.Cc);
}
// Manage "ThreadId-ConversationId"
if (!string.IsNullOrEmpty(referenceMessage.MessageId))
{
message.InReplyTo = referenceMessage.MessageId;
foreach (var id in referenceMessage.References)
message.References.Add(id);
message.References.Add(referenceMessage.MessageId);
}
message.Headers.Add("Thread-Topic", referenceMessage.Subject);
}
var previewer = new HtmlTextPreviewer();
if (reason == DraftCreationReason.Forward)
{
var visitor = _mimeFileService.CreateHTMLPreviewVisitor(referenceMessage, string.Empty);
visitor.Visit(referenceMessage);
builder.HtmlBody = visitor.HtmlBody;
}
else
{
// Add signature if any.
var accountSignature = await _signatureService.GetAccountSignatureAsync(account.Id);
if (accountSignature != null)
{
// Leave some space for new mail content.
builder.HtmlBody = @$"<html><br><br>{accountSignature.HtmlBody}</html>";
}
}
// Manage Subject
if (reason == DraftCreationReason.Forward && !referenceMessage.Subject.StartsWith("FW: ", StringComparison.OrdinalIgnoreCase))
message.Subject = $"FW: {referenceMessage.Subject}";
else if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) &&
!referenceMessage.Subject.StartsWith("RE: ", StringComparison.OrdinalIgnoreCase))
message.Subject = $"RE: {referenceMessage.Subject}";
else if (referenceMessage != null)
message.Subject = referenceMessage.Subject;
// Only include attachments if forwarding.
if (reason == DraftCreationReason.Forward && (referenceMessage?.Attachments?.Any() ?? false))
{
foreach (var attachment in referenceMessage.Attachments)
{
builder.Attachments.Add(attachment);
}
}
if (!string.IsNullOrEmpty(builder.HtmlBody))
{
builder.TextBody = HtmlAgilityPackExtensions.GetPreviewText(builder.HtmlBody);
}
message.Body = builder.ToMessageBody();
// Apply mail-to protocol parameters if exists.
if (draftCreationOptions.MailtoParameters != null)
{
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoSubjectParameterKey, out string subjectParameter))
message.Subject = subjectParameter;
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoBodyParameterKey, out string bodyParameter))
{
builder.TextBody = bodyParameter;
builder.HtmlBody = bodyParameter;
message.Body = builder.ToMessageBody();
}
InternetAddressList ExtractRecipients(string parameterValue)
{
var list = new InternetAddressList();
var splittedRecipients = parameterValue.Split(',');
foreach (var recipient in splittedRecipients)
list.Add(new MailboxAddress(recipient, recipient));
return list;
}
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoToParameterKey, out string toParameter))
message.To.AddRange(ExtractRecipients(toParameter));
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoCCParameterKey, out string ccParameter))
message.Cc.AddRange(ExtractRecipients(ccParameter));
if (draftCreationOptions.TryGetMailtoValue(DraftCreationOptions.MailtoBCCParameterKey, out string bccParameter))
message.Bcc.AddRange(ExtractRecipients(bccParameter));
}
else
{
// Update TextBody from existing HtmlBody if exists.
}
return message;
}
public async Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
{
var query = new Query("MailCopy")
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
.Where("MailCopy.UniqueId", localDraftCopyUniqueId)
.Where("MailItemFolder.MailAccountId", accountId)
.SelectRaw("MailCopy.*")
.GetRawQuery();
var localDraftCopy = await Connection.FindWithQueryAsync<MailCopy>(query);
if (localDraftCopy == null)
{
_logger.Warning("Draft mapping failed because local draft copy with unique id {LocalDraftCopyUniqueId} does not exist.", localDraftCopyUniqueId);
return false;
}
var oldLocalDraftId = localDraftCopy.Id;
await LoadAssignedPropertiesAsync(localDraftCopy).ConfigureAwait(false);
bool isIdChanging = localDraftCopy.Id != newMailCopyId;
localDraftCopy.Id = newMailCopyId;
localDraftCopy.DraftId = newDraftId;
localDraftCopy.ThreadId = newThreadId;
await UpdateMailAsync(localDraftCopy).ConfigureAwait(false);
ReportUIChange(new DraftMapped(oldLocalDraftId, newDraftId));
return true;
}
public Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId)
{
return UpdateAllMailCopiesAsync(mailCopyId, (item) =>
{
if (item.ThreadId != newThreadId || item.DraftId != newDraftId)
{
var oldDraftId = item.DraftId;
item.DraftId = newDraftId;
item.ThreadId = newThreadId;
ReportUIChange(new DraftMapped(oldDraftId, newDraftId));
return true;
}
return false;
});
}
public Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds)
{
var rawQuery = new Query("MailCopy")
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
.WhereIn("MailCopy.Id", downloadedMailCopyIds)
.Where("MailCopy.IsRead", false)
.Where("MailItemFolder.MailAccountId", accountId)
.Where("MailItemFolder.SpecialFolderType", SpecialFolderType.Inbox)
.SelectRaw("MailCopy.*")
.GetRawQuery();
return Connection.QueryAsync<MailCopy>(rawQuery);
}
public Task<MailAccount> GetMailAccountByUniqueIdAsync(Guid uniqueMailId)
{
var query = new Query("MailCopy")
.Join("MailItemFolder", "MailCopy.FolderId", "MailItemFolder.Id")
.Join("MailAccount", "MailItemFolder.MailAccountId", "MailAccount.Id")
.Where("MailCopy.UniqueId", uniqueMailId)
.SelectRaw("MailAccount.*")
.GetRawQuery();
return Connection.FindWithQueryAsync<MailAccount>(query);
}
}
}

Some files were not shown because too many files have changed in this diff Show More