IMAP Improvements (#558)

* Fixing an issue where scrollviewer overrides a part of template in mail list. Adjusted zoomed out header grid's corner radius.

* IDLE implementation, imap synchronization strategies basics and condstore synchronization.

* Adding iCloud and Yahoo as special IMAP handling scenario.

* iCloud special imap handling.

* Support for killing synchronizers.

* Update privacy policy url.

* Batching condstore downloads into 50, using SORT extension for searches if supported.

* Bumping some nugets. More on the imap synchronizers.

* Delegating idle synchronizations to server to post-sync operations.

* Update mailkit to resolve qresync bug with iCloud.

* Fixing remote highest mode seq checks for qresync and condstore synchronizers.

* Yahoo custom settings.

* Bump google sdk package.

* Fixing the build issue....

* NRE on canceled token accounts during setup.

* Server crash handlers.

* Remove ARM32. Upgrade server to .NET 9.

* Fix icons for yahoo and apple.

* Fixed an issue where disabled folders causing an exception on forced sync.

* Remove smtp encoding constraint.

* Remove commented code.

* Fixing merge conflict

* Addressing double registrations for mailkit remote folder events in synchronizers.

* Making sure idle canceled result is not reported.

* Fixing custom imap server dialog opening.

* Fixing the issue with account creation making the previously selected account as selected as well.

* Fixing app close behavior and logging app close.
This commit is contained in:
Burak Kaan Köse
2025-02-15 12:53:32 +01:00
committed by GitHub
parent 30f1257983
commit ee9e41c5a7
108 changed files with 2092 additions and 1166 deletions

View File

@@ -1,16 +0,0 @@
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Authentication
{
public class Office365Authenticator : OutlookAuthenticator
{
public Office365Authenticator(INativeAppService nativeAppService,
IApplicationConfiguration applicationConfiguration,
IAuthenticatorConfig authenticatorConfig) : base(nativeAppService, applicationConfiguration, authenticatorConfig)
{
}
public override MailProviderType ProviderType => MailProviderType.Office365;
}
}

View File

@@ -20,14 +20,6 @@
<Configuration>Release</Configuration> <Configuration>Release</Configuration>
<Platform>x64</Platform> <Platform>x64</Platform>
</ProjectConfiguration> </ProjectConfiguration>
<ProjectConfiguration Include="Debug|ARM">
<Configuration>Debug</Configuration>
<Platform>ARM</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM">
<Configuration>Release</Configuration>
<Platform>ARM</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|ARM64"> <ProjectConfiguration Include="Debug|ARM64">
<Configuration>Debug</Configuration> <Configuration>Debug</Configuration>
<Platform>ARM64</Platform> <Platform>ARM64</Platform>
@@ -76,7 +68,6 @@
</ItemGroup> </ItemGroup>
<Import Project="$(WapProjPath)\Microsoft.DesktopBridge.targets" /> <Import Project="$(WapProjPath)\Microsoft.DesktopBridge.targets" />
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Identity.Client" Version="4.66.2" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" PrivateAssets="all" /> <PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -81,7 +81,7 @@ namespace Wino.Calendar.ViewModels
if (accountCreationDialogResult == null) return; if (accountCreationDialogResult == null) return;
var accountCreationCancellationTokenSource = new CancellationTokenSource(); var accountCreationCancellationTokenSource = new CancellationTokenSource();
var accountCreationDialog = CalendarDialogService.GetAccountCreationDialog(accountCreationDialogResult.ProviderType); var accountCreationDialog = CalendarDialogService.GetAccountCreationDialog(accountCreationDialogResult);
accountCreationDialog.ShowDialog(accountCreationCancellationTokenSource); accountCreationDialog.ShowDialog(accountCreationCancellationTokenSource);
accountCreationDialog.State = AccountCreationDialogState.SigningIn; accountCreationDialog.State = AccountCreationDialogState.SigningIn;
@@ -92,7 +92,6 @@ namespace Wino.Calendar.ViewModels
{ {
ProviderType = accountCreationDialogResult.ProviderType, ProviderType = accountCreationDialogResult.ProviderType,
Name = accountCreationDialogResult.AccountName, Name = accountCreationDialogResult.AccountName,
AccountColorHex = accountCreationDialogResult.AccountColorHex,
Id = Guid.NewGuid() Id = Guid.NewGuid()
}; };
@@ -104,13 +103,8 @@ namespace Wino.Calendar.ViewModels
if (accountCreationDialog.State == AccountCreationDialogState.Canceled) if (accountCreationDialog.State == AccountCreationDialogState.Canceled)
throw new AccountSetupCanceledException(); throw new AccountSetupCanceledException();
tokenInformationResponse.ThrowIfFailed(); tokenInformationResponse.ThrowIfFailed();
//var tokenInformation = tokenInformationResponse.Data;
//createdAccount.Address = tokenInformation.Address;
//tokenInformation.AccountId = createdAccount.Id;
await AccountService.CreateAccountAsync(createdAccount, null); await AccountService.CreateAccountAsync(createdAccount, null);
// Sync profile information if supported. // Sync profile information if supported.

View File

@@ -9,7 +9,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Identity.Client" Version="4.66.2" />
<PackageReference Include="TimePeriodLibrary.NET" Version="2.1.5" /> <PackageReference Include="TimePeriodLibrary.NET" Version="2.1.5" />
</ItemGroup> </ItemGroup>

View File

@@ -27,7 +27,7 @@ namespace Wino.Calendar.Services
foreach (var type in providers) foreach (var type in providers)
{ {
providerList.Add(new ProviderDetail(type)); providerList.Add(new ProviderDetail(type, SpecialImapProvider.None));
} }
return providerList; return providerList;

View File

@@ -60,29 +60,6 @@
<Prefer32Bit>true</Prefer32Bit> <Prefer32Bit>true</Prefer32Bit>
<UseDotNetNativeToolchain>true</UseDotNetNativeToolchain> <UseDotNetNativeToolchain>true</UseDotNetNativeToolchain>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|ARM'">
<DebugSymbols>true</DebugSymbols>
<OutputPath>bin\ARM\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
<NoWarn>;2008</NoWarn>
<DebugType>full</DebugType>
<PlatformTarget>ARM</PlatformTarget>
<UseVSHostingProcess>false</UseVSHostingProcess>
<ErrorReport>prompt</ErrorReport>
<Prefer32Bit>true</Prefer32Bit>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|ARM'">
<OutputPath>bin\ARM\Release\</OutputPath>
<DefineConstants>TRACE;NETFX_CORE;WINDOWS_UWP</DefineConstants>
<Optimize>true</Optimize>
<NoWarn>;2008</NoWarn>
<DebugType>pdbonly</DebugType>
<PlatformTarget>ARM</PlatformTarget>
<UseVSHostingProcess>false</UseVSHostingProcess>
<ErrorReport>prompt</ErrorReport>
<Prefer32Bit>true</Prefer32Bit>
<UseDotNetNativeToolchain>true</UseDotNetNativeToolchain>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|ARM64'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|ARM64'">
<DebugSymbols>true</DebugSymbols> <DebugSymbols>true</DebugSymbols>
<OutputPath>bin\ARM64\Debug\</OutputPath> <OutputPath>bin\ARM64\Debug\</OutputPath>
@@ -334,7 +311,7 @@
<Version>6.2.14</Version> <Version>6.2.14</Version>
</PackageReference> </PackageReference>
<PackageReference Include="Win2D.uwp"> <PackageReference Include="Win2D.uwp">
<Version>1.28.0</Version> <Version>1.28.1</Version>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -72,6 +72,12 @@ namespace Wino.Core.Domain.Entities.Shared
/// </summary> /// </summary>
public Guid? MergedInboxId { get; set; } public Guid? MergedInboxId { get; set; }
/// <summary>
/// Gets or sets the additional IMAP provider assignment for the account.
/// Providers that use IMAP as a synchronizer but have special requirements.
/// </summary>
public SpecialImapProvider SpecialImapProvider { get; set; }
/// <summary> /// <summary>
/// Contains the merged inbox this account belongs to. /// Contains the merged inbox this account belongs to.
/// Ignored for all SQLite operations. /// Ignored for all SQLite operations.
@@ -95,7 +101,7 @@ namespace Wino.Core.Domain.Entities.Shared
/// <summary> /// <summary>
/// Gets whether the account can perform ProfileInformation sync type. /// Gets whether the account can perform ProfileInformation sync type.
/// </summary> /// </summary>
public bool IsProfileInfoSyncSupported => ProviderType == MailProviderType.Outlook || ProviderType == MailProviderType.Office365 || ProviderType == MailProviderType.Gmail; public bool IsProfileInfoSyncSupported => ProviderType == MailProviderType.Outlook || ProviderType == MailProviderType.Gmail;
/// <summary> /// <summary>
/// Gets whether the account can perform AliasInformation sync type. /// Gets whether the account can perform AliasInformation sync type.

View File

@@ -4,8 +4,6 @@
{ {
Outlook, Outlook,
Gmail, Gmail,
Office365, IMAP4 = 4 // 2-3 were removed after release. Don't change for backward compatibility.
Yahoo,
IMAP4
} }
} }

View File

@@ -2,13 +2,13 @@
{ {
public enum MailSynchronizationType public enum MailSynchronizationType
{ {
// Shared
UpdateProfile, // Only update profile information UpdateProfile, // Only update profile information
ExecuteRequests, // Run the queued requests, and then synchronize if needed. ExecuteRequests, // Run the queued requests, and then synchronize if needed.
FoldersOnly, // Only synchronize folder metadata. FoldersOnly, // Only synchronize folder metadata.
InboxOnly, // Only Inbox, Sent and Draft folders. InboxOnly, // Only Inbox, Sent, Draft and Deleted folders.
CustomFolders, // Only sync folders that are specified in the options. CustomFolders, // Only sync folders that are specified in the options.
FullFolders, // Synchronize all folders. This won't update profile or alias information. FullFolders, // Synchronize all folders. This won't update profile or alias information.
Alias, // Only update alias information Alias, // Only update alias information
IMAPIdle // Idle client triggered synchronization.
} }
} }

View File

@@ -0,0 +1,9 @@
namespace Wino.Core.Domain.Enums
{
public enum SpecialImapProvider
{
None,
iCloud,
Yahoo
}
}

View File

@@ -0,0 +1,10 @@
namespace Wino.Core.Domain.Exceptions
{
public class ImapSynchronizerStrategyException : System.Exception
{
public ImapSynchronizerStrategyException(string message) : base(message)
{
}
}
}

View File

@@ -1,11 +1,12 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Interfaces namespace Wino.Core.Domain.Interfaces
{ {
public interface IAccountCreationDialog public interface IAccountCreationDialog
{ {
void ShowDialog(CancellationTokenSource cancellationTokenSource); Task ShowDialogAsync(CancellationTokenSource cancellationTokenSource);
void Complete(bool cancel); void Complete(bool cancel);
AccountCreationDialogState State { get; set; } AccountCreationDialogState State { get; set; }
} }

View File

@@ -23,12 +23,6 @@ namespace Wino.Core.Domain.Interfaces
/// <param name="request">Request to queue.</param> /// <param name="request">Request to queue.</param>
void QueueRequest(IRequestBase request); void QueueRequest(IRequestBase request);
/// <summary>
/// TODO
/// </summary>
/// <returns>Whether active synchronization is stopped or not.</returns>
bool CancelActiveSynchronization();
/// <summary> /// <summary>
/// Synchronizes profile information with the server. /// Synchronizes profile information with the server.
/// Sender name and Profile picture are updated. /// Sender name and Profile picture are updated.

View File

@@ -13,5 +13,10 @@ namespace Wino.Core.Domain.Interfaces
/// Which folders to sync after this operation? /// Which folders to sync after this operation?
/// </summary> /// </summary>
List<Guid> SynchronizationFolderIds { get; } List<Guid> SynchronizationFolderIds { get; }
/// <summary>
/// If true, additional folders like Sent, Drafts and Deleted will not be synchronized
/// </summary>
bool ExcludeMustHaveFolders { get; }
} }
} }

View File

@@ -27,7 +27,7 @@ namespace Wino.Core.Domain.Interfaces
string dontAskAgainConfigurationKey = ""); string dontAskAgainConfigurationKey = "");
Task<bool> ShowCustomThemeBuilderDialogAsync(); Task<bool> ShowCustomThemeBuilderDialogAsync();
Task<AccountCreationDialogResult> ShowAccountProviderSelectionDialogAsync(List<IProviderDetail> availableProviders); Task<AccountCreationDialogResult> ShowAccountProviderSelectionDialogAsync(List<IProviderDetail> availableProviders);
IAccountCreationDialog GetAccountCreationDialog(MailProviderType type); IAccountCreationDialog GetAccountCreationDialog(AccountCreationDialogResult accountCreationDialogResult);
Task<List<SharedFile>> PickFilesAsync(params object[] typeFilters); Task<List<SharedFile>> PickFilesAsync(params object[] typeFilters);
Task<string> PickFilePathAsync(string saveFileName); Task<string> PickFilePathAsync(string saveFileName);
} }

View File

@@ -3,7 +3,7 @@ using Wino.Core.Domain.Entities.Shared;
namespace Wino.Core.Domain.Interfaces namespace Wino.Core.Domain.Interfaces
{ {
public interface ICustomServerAccountCreationDialog : IAccountCreationDialog public interface IImapAccountCreationDialog : IAccountCreationDialog
{ {
/// <summary> /// <summary>
/// Returns the custom server information from the dialog.. /// Returns the custom server information from the dialog..

View File

@@ -0,0 +1,12 @@
using MailKit.Net.Imap;
namespace Wino.Core.Domain.Interfaces
{
/// <summary>
/// Provides a synchronization strategy for synchronizing IMAP folders based on the server capabilities.
/// </summary>
public interface IImapSynchronizationStrategyProvider
{
IImapSynchronizerStrategy GetSynchronizationStrategy(IImapClient client);
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Models.MailItem;
namespace Wino.Core.Domain.Interfaces
{
public interface IImapSynchronizer
{
uint InitialMessageDownloadCountPerFolder { get; }
Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default);
Task StartIdleClientAsync();
Task StopIdleClientAsync();
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MailKit.Net.Imap;
using Wino.Core.Domain.Entities.Mail;
namespace Wino.Core.Domain.Interfaces
{
public interface IImapSynchronizerStrategy
{
/// <summary>
/// Synchronizes given folder with the ImapClient client from the client pool.
/// </summary>
/// <param name="client">Client to perform sync with. I love Mira and Jasminka</param>
/// <param name="folder">Folder to synchronize.</param>
/// <param name="synchronizer">Imap synchronizer that downloads messages.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of new downloaded message ids that don't exist locally.</returns>
Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
}
}

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MailKit;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.MailItem;
@@ -108,5 +109,13 @@ namespace Wino.Core.Domain.Interfaces
/// <param name="draftCreationOptions">Options like new email/forward/draft.</param> /// <param name="draftCreationOptions">Options like new email/forward/draft.</param>
/// <returns>Draft MailCopy and Draft MimeMessage as base64.</returns> /// <returns>Draft MailCopy and Draft MimeMessage as base64.</returns>
Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions); Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions);
/// <summary>
/// Returns ids
/// </summary>
/// <param name="folderId"></param>
/// <param name="uniqueIds"></param>
/// <returns></returns>
Task<List<MailCopy>> GetExistingMailsAsync(Guid folderId, IEnumerable<UniqueId> uniqueIds);
} }
} }

View File

@@ -5,6 +5,7 @@ namespace Wino.Core.Domain.Interfaces
public interface IProviderDetail public interface IProviderDetail
{ {
MailProviderType Type { get; } MailProviderType Type { get; }
SpecialImapProvider SpecialImapProvider { get; }
string Name { get; } string Name { get; }
string Description { get; } string Description { get; }
string ProviderImage { get; } string ProviderImage { get; }

View File

@@ -0,0 +1,10 @@
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Core.Domain.Interfaces
{
public interface ISpecialImapProviderConfigResolver
{
CustomServerInformation GetServerInformation(MailAccount account, AccountCreationDialogResult dialogResult);
}
}

View File

@@ -7,5 +7,6 @@ namespace Wino.Core.Domain.Interfaces
{ {
Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId); Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId);
Task InitializeAsync(); Task InitializeAsync();
Task DeleteSynchronizerAsync(Guid accountId);
} }
} }

View File

@@ -28,5 +28,12 @@ namespace Wino.Core.Domain.Interfaces
/// <param name="transferProgress">Optional progress reporting for download operation.</param> /// <param name="transferProgress">Optional progress reporting for download operation.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
Task DownloadMissingMimeMessageAsync(IMailItem mailItem, ITransferProgress transferProgress, CancellationToken cancellationToken = default); Task DownloadMissingMimeMessageAsync(IMailItem mailItem, ITransferProgress transferProgress, CancellationToken cancellationToken = default);
/// <summary>
/// 1. Cancel active synchronization.
/// 2. Stop all running tasks.
/// 3. Dispose all resources.
/// </summary>
Task KillSynchronizerAsync();
} }
} }

View File

@@ -2,5 +2,5 @@
namespace Wino.Core.Domain.Models.Accounts namespace Wino.Core.Domain.Models.Accounts
{ {
public record AccountCreationDialogResult(MailProviderType ProviderType, string AccountName, string AccountColorHex = ""); public record AccountCreationDialogResult(MailProviderType ProviderType, string AccountName, SpecialImapProviderDetails SpecialImapProviderDetails);
} }

View File

@@ -6,18 +6,32 @@ namespace Wino.Core.Domain.Models.Accounts
public class ProviderDetail : IProviderDetail public class ProviderDetail : IProviderDetail
{ {
public MailProviderType Type { get; } public MailProviderType Type { get; }
public SpecialImapProvider SpecialImapProvider { get; }
public string Name { get; } public string Name { get; }
public string Description { get; } public string Description { get; }
public string ProviderImage => $"/Wino.Core.UWP/Assets/Providers/{Type}.png"; public string ProviderImage
{
get
{
if (SpecialImapProvider == SpecialImapProvider.None)
{
return $"/Wino.Core.UWP/Assets/Providers/{Type}.png";
}
else
{
return $"/Wino.Core.UWP/Assets/Providers/{SpecialImapProvider}.png";
}
}
}
public bool IsSupported => Type == MailProviderType.Outlook || Type == MailProviderType.Gmail || Type == MailProviderType.IMAP4; public bool IsSupported => Type == MailProviderType.Outlook || Type == MailProviderType.Gmail || Type == MailProviderType.IMAP4;
public ProviderDetail(MailProviderType type) public ProviderDetail(MailProviderType type, SpecialImapProvider specialImapProvider)
{ {
Type = type; Type = type;
SpecialImapProvider = specialImapProvider;
switch (Type) switch (Type)
{ {
@@ -25,21 +39,29 @@ namespace Wino.Core.Domain.Models.Accounts
Name = "Outlook"; Name = "Outlook";
Description = "Outlook.com, Live.com, Hotmail, MSN"; Description = "Outlook.com, Live.com, Hotmail, MSN";
break; break;
case MailProviderType.Office365:
Name = "Office 365";
Description = "Office 365, Exchange";
break;
case MailProviderType.Gmail: case MailProviderType.Gmail:
Name = "Gmail"; Name = "Gmail";
Description = Translator.ProviderDetail_Gmail_Description; Description = Translator.ProviderDetail_Gmail_Description;
break; break;
case MailProviderType.Yahoo:
Name = "Yahoo";
Description = "Yahoo Mail";
break;
case MailProviderType.IMAP4: case MailProviderType.IMAP4:
Name = Translator.ProviderDetail_IMAP_Title; switch (specialImapProvider)
Description = Translator.ProviderDetail_IMAP_Description; {
case SpecialImapProvider.None:
Name = Translator.ProviderDetail_IMAP_Title;
Description = Translator.ProviderDetail_IMAP_Description;
break;
case SpecialImapProvider.iCloud:
Name = Translator.ProviderDetail_iCloud_Title;
Description = Translator.ProviderDetail_iCloud_Description;
break;
case SpecialImapProvider.Yahoo:
Name = Translator.ProviderDetail_Yahoo_Title;
Description = Translator.ProviderDetail_Yahoo_Description;
break;
default:
break;
}
break; break;
} }
} }

View File

@@ -0,0 +1,6 @@
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Accounts
{
public record SpecialImapProviderDetails(string Address, string Password, string SenderName, SpecialImapProvider SpecialImapProvider);
}

View File

@@ -0,0 +1,20 @@
using MailKit;
using MimeKit;
namespace Wino.Core.Domain.Models.MailItem
{
/// <summary>
/// Encapsulates all required information to create a MimeMessage for IMAP synchronizer.
/// </summary>
public class ImapMessageCreationPackage
{
public IMessageSummary MessageSummary { get; }
public MimeMessage MimeMessage { get; }
public ImapMessageCreationPackage(IMessageSummary messageSummary, MimeMessage mimeMessage)
{
MessageSummary = messageSummary;
MimeMessage = mimeMessage;
}
}
}

View File

@@ -9,7 +9,7 @@ namespace Wino.Core.Domain.Models.Synchronization
/// <summary> /// <summary>
/// Unique id of synchronization. /// Unique id of synchronization.
/// </summary> /// </summary>
public Guid Id { get; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
/// <summary> /// <summary>
/// Account to execute synchronization for. /// Account to execute synchronization for.
@@ -26,6 +26,12 @@ namespace Wino.Core.Domain.Models.Synchronization
/// </summary> /// </summary>
public List<Guid> SynchronizationFolderIds { get; set; } public List<Guid> SynchronizationFolderIds { get; set; }
/// <summary>
/// If true, additional folders like Sent,Drafts and Deleted will not be synchronized
/// with InboxOnly and CustomFolders sync type.
/// </summary>
public bool ExcludeMustHaveFolders { get; set; }
/// <summary> /// <summary>
/// When doing a linked inbox synchronization, we must ignore reporting completion to the caller for each folder. /// When doing a linked inbox synchronization, we must ignore reporting completion to the caller for each folder.
/// This Id will help tracking that. Id is unique, but this one can be the same for all sync requests /// This Id will help tracking that. Id is unique, but this one can be the same for all sync requests
@@ -33,6 +39,6 @@ namespace Wino.Core.Domain.Models.Synchronization
/// </summary> /// </summary>
public Guid? GroupedSynchronizationTrackingId { get; set; } public Guid? GroupedSynchronizationTrackingId { get; set; }
public override string ToString() => $"Type: {Type}, Folders: {(SynchronizationFolderIds == null ? "All" : string.Join(",", SynchronizationFolderIds))}"; public override string ToString() => $"Type: {Type}";
} }
} }

View File

@@ -403,6 +403,10 @@
"ProviderDetail_Gmail_Description": "Google Account", "ProviderDetail_Gmail_Description": "Google Account",
"ProviderDetail_IMAP_Description": "Custom IMAP/SMTP server", "ProviderDetail_IMAP_Description": "Custom IMAP/SMTP server",
"ProviderDetail_IMAP_Title": "IMAP Server", "ProviderDetail_IMAP_Title": "IMAP Server",
"ProviderDetail_Yahoo_Title": "Yahoo Mail",
"ProviderDetail_Yahoo_Description": "Yahoo Account",
"ProviderDetail_iCloud_Title": "iCloud",
"ProviderDetail_iCloud_Description": "Apple iCloud Account",
"ProtocolLogAvailable_Message": "Protocol logs are available for diagnostics.", "ProtocolLogAvailable_Message": "Protocol logs are available for diagnostics.",
"Results": "Results", "Results": "Results",
"Right": "Right", "Right": "Right",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,76 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Messaging.UI;
namespace Wino.Core.UWP.Controls
{
public sealed partial class AccountCreationDialogControl : UserControl, IRecipient<CopyAuthURLRequested>
{
private string copyClipboardURL;
public event EventHandler CancelClicked;
public AccountCreationDialogState State
{
get { return (AccountCreationDialogState)GetValue(StateProperty); }
set { SetValue(StateProperty, value); }
}
public static readonly DependencyProperty StateProperty = DependencyProperty.Register(nameof(State), typeof(AccountCreationDialogState), typeof(AccountCreationDialogControl), new PropertyMetadata(AccountCreationDialogState.Idle, new PropertyChangedCallback(OnStateChanged)));
public AccountCreationDialogControl()
{
InitializeComponent();
}
private static void OnStateChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
{
if (obj is AccountCreationDialogControl dialog)
{
dialog.UpdateVisualStates();
}
}
private void UpdateVisualStates() => VisualStateManager.GoToState(this, State.ToString(), false);
public async void Receive(CopyAuthURLRequested message)
{
copyClipboardURL = message.AuthURL;
await Task.Delay(2000);
await Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal, () =>
{
AuthHelpDialogButton.Visibility = Windows.UI.Xaml.Visibility.Visible;
});
}
private void ControlLoaded(object sender, Windows.UI.Xaml.RoutedEventArgs e)
{
WeakReferenceMessenger.Default.Register(this);
}
private void ControlUnloaded(object sender, Windows.UI.Xaml.RoutedEventArgs e)
{
WeakReferenceMessenger.Default.UnregisterAll(this);
}
private async void CopyClicked(object sender, Windows.UI.Xaml.RoutedEventArgs e)
{
if (string.IsNullOrEmpty(copyClipboardURL)) return;
var clipboardService = WinoApplication.Current.Services.GetService<IClipboardService>();
await clipboardService.CopyClipboardAsync(copyClipboardURL);
}
private void CancelButtonClicked(object sender, Windows.UI.Xaml.RoutedEventArgs e) => CancelClicked?.Invoke(this, null);
}
}

View File

@@ -98,7 +98,9 @@ namespace Wino.Core.UWP.Controls
{ WinoIconGlyph.EventRespond, "\uE924" }, { WinoIconGlyph.EventRespond, "\uE924" },
{ WinoIconGlyph.EventReminder, "\uE923" }, { WinoIconGlyph.EventReminder, "\uE923" },
{ WinoIconGlyph.EventJoinOnline, "\uE926" }, { WinoIconGlyph.EventJoinOnline, "\uE926" },
{ WinoIconGlyph.ViewMessageSource, "\uE943" } { WinoIconGlyph.ViewMessageSource, "\uE943" },
{ WinoIconGlyph.Apple, "\uE92B" },
{ WinoIconGlyph.Yahoo, "\uE92C" }
}; };
} }
} }

View File

@@ -100,6 +100,8 @@ namespace Wino.Core.UWP.Controls
EventEditSeries, EventEditSeries,
EventJoinOnline, EventJoinOnline,
ViewMessageSource, ViewMessageSource,
Apple,
Yahoo
} }
public partial class WinoFontIcon : FontIcon public partial class WinoFontIcon : FontIcon

File diff suppressed because one or more lines are too long

View File

@@ -1,50 +1,71 @@
using System; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Windows.UI.Xaml; using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.UWP;
using Wino.Messaging.UI;
namespace Wino.Dialogs namespace Wino.Dialogs
{ {
public sealed partial class AccountCreationDialog : BaseAccountCreationDialog, IRecipient<CopyAuthURLRequested> public sealed partial class AccountCreationDialog : ContentDialog, IAccountCreationDialog
{ {
private string copyClipboardURL; private TaskCompletionSource<bool> dialogOpened = new TaskCompletionSource<bool>();
public CancellationTokenSource CancellationTokenSource { get; private set; }
public AccountCreationDialogState State
{
get { return (AccountCreationDialogState)GetValue(StateProperty); }
set { SetValue(StateProperty, value); }
}
public static readonly DependencyProperty StateProperty = DependencyProperty.Register(nameof(State), typeof(AccountCreationDialogState), typeof(AccountCreationDialog), new PropertyMetadata(AccountCreationDialogState.Idle));
public AccountCreationDialog() public AccountCreationDialog()
{ {
InitializeComponent(); InitializeComponent();
WeakReferenceMessenger.Default.Register(this);
} }
public override void OnStateChanged(AccountCreationDialogState state) // Prevent users from dismissing it by ESC key.
public void DialogClosing(ContentDialog sender, ContentDialogClosingEventArgs args)
{ {
var tt = VisualStateManager.GoToState(this, state.ToString(), true); if (args.Result == ContentDialogResult.None)
}
public async void Receive(CopyAuthURLRequested message)
{
copyClipboardURL = message.AuthURL;
await Task.Delay(2000);
await Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal, () =>
{ {
AuthHelpDialogButton.Visibility = Windows.UI.Xaml.Visibility.Visible; args.Cancel = true;
}); }
} }
private void CancelClicked(object sender, Windows.UI.Xaml.RoutedEventArgs e) => Complete(true); public void Complete(bool cancel)
private async void CopyClicked(object sender, Windows.UI.Xaml.RoutedEventArgs e)
{ {
if (string.IsNullOrEmpty(copyClipboardURL)) return; State = cancel ? AccountCreationDialogState.Canceled : AccountCreationDialogState.Completed;
var clipboardService = WinoApplication.Current.Services.GetService<IClipboardService>(); // Unregister from closing event.
await clipboardService.CopyClipboardAsync(copyClipboardURL); Closing -= DialogClosing;
if (cancel && !CancellationTokenSource.IsCancellationRequested)
{
CancellationTokenSource.Cancel();
}
Hide();
}
private void CancelClicked(object sender, System.EventArgs e) => Complete(true);
public async Task ShowDialogAsync(CancellationTokenSource cancellationTokenSource)
{
CancellationTokenSource = cancellationTokenSource;
Opened += DialogOpened;
_ = ShowAsync();
await dialogOpened.Task;
}
private void DialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args)
{
Opened -= DialogOpened;
dialogOpened?.SetResult(true);
} }
} }
} }

View File

@@ -1,60 +0,0 @@
using System.Threading;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Dialogs
{
public abstract class BaseAccountCreationDialog : ContentDialog, IAccountCreationDialog
{
public AccountCreationDialogState State
{
get { return (AccountCreationDialogState)GetValue(StateProperty); }
set { SetValue(StateProperty, value); }
}
public CancellationTokenSource CancellationTokenSource { get; private set; }
public static readonly DependencyProperty StateProperty = DependencyProperty.Register(nameof(State), typeof(AccountCreationDialogState), typeof(BaseAccountCreationDialog), new PropertyMetadata(AccountCreationDialogState.Idle, OnStateChanged));
private static void OnStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var dialog = d as BaseAccountCreationDialog;
dialog.OnStateChanged((AccountCreationDialogState)e.NewValue);
}
public abstract void OnStateChanged(AccountCreationDialogState state);
// Prevent users from dismissing it by ESC key.
public void DialogClosing(ContentDialog sender, ContentDialogClosingEventArgs args)
{
if (args.Result == ContentDialogResult.None)
{
args.Cancel = true;
}
}
public void ShowDialog(CancellationTokenSource cancellationTokenSource)
{
CancellationTokenSource = cancellationTokenSource;
_ = ShowAsync();
}
public void Complete(bool cancel)
{
State = cancel ? AccountCreationDialogState.Canceled : AccountCreationDialogState.Completed;
// Unregister from closing event.
Closing -= DialogClosing;
if (cancel && !CancellationTokenSource.IsCancellationRequested)
{
CancellationTokenSource.Cancel();
}
Hide();
}
}
}

View File

@@ -43,38 +43,91 @@
</ContentDialog.Resources> </ContentDialog.Resources>
<Grid MinWidth="400" RowSpacing="12"> <Grid MinWidth="400" RowSpacing="12">
<Grid.RowDefinitions> <Grid Visibility="{x:Bind IsProviderSelectionVisible, Mode=OneWay}">
<RowDefinition Height="Auto" /> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> <RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Account Name --> <!-- Account Name -->
<TextBox <TextBox
x:Name="AccountNameTextbox" x:Name="AccountNameTextbox"
Header="{x:Bind domain:Translator.NewAccountDialog_AccountName}" Header="{x:Bind domain:Translator.NewAccountDialog_AccountName}"
PlaceholderText="{x:Bind domain:Translator.NewAccountDialog_AccountNamePlaceholder}" PlaceholderText="{x:Bind domain:Translator.NewAccountDialog_AccountNamePlaceholder}"
TextChanged="AccountNameChanged" /> TextChanged="InputChanged" />
<!-- <!--
TODO: Move Name, Sender Name and Color Picker to another Frame. TODO: Move Name, Sender Name and Color Picker to another Frame.
Provider selection should be first, then account details. Provider selection should be first, then account details.
--> -->
<!--<Grid Grid.Row="1"> <!--<Grid Grid.Row="1">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Color" /> <TextBlock Text="Color" />
<muxc:ColorPicker x:Name="AccountColorPicker" Grid.Column="1" /> <muxc:ColorPicker x:Name="AccountColorPicker" Grid.Column="1" />
</Grid>--> </Grid>-->
<ListView <ListView
Grid.Row="2" Grid.Row="2"
Padding="0" Padding="0"
ItemTemplate="{StaticResource NewMailProviderTemplate}" ItemTemplate="{StaticResource NewMailProviderTemplate}"
ItemsSource="{x:Bind Providers}" ItemsSource="{x:Bind Providers}"
SelectedItem="{x:Bind SelectedMailProvider, Mode=TwoWay}" SelectedItem="{x:Bind SelectedMailProvider, Mode=TwoWay}"
SelectionMode="Single" /> SelectionMode="Single" />
</Grid>
<!-- Known special IMAP login details. -->
<Grid RowSpacing="12" Visibility="{x:Bind IsSpecialImapServerPartVisible, Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Button Click="BackClicked">
<Button.Content>
<Viewbox Width="16">
<PathIcon Data="F1 M 20 9.375 C 20 9.544271 19.93815 9.690756 19.814453 9.814453 C 19.690754 9.938151 19.54427 10 19.375 10 L 2.138672 10 L 9.814453 17.685547 C 9.93815 17.809244 10 17.955729 10 18.125 C 10 18.294271 9.93815 18.440756 9.814453 18.564453 C 9.690755 18.68815 9.544271 18.75 9.375 18.75 C 9.205729 18.75 9.059244 18.68815 8.935547 18.564453 L 0.214844 9.84375 C 0.143229 9.772136 0.089518 9.700521 0.053711 9.628906 C 0.017904 9.557292 0 9.472656 0 9.375 C 0 9.277344 0.017904 9.192709 0.053711 9.121094 C 0.089518 9.049479 0.143229 8.977865 0.214844 8.90625 L 8.935547 0.185547 C 9.059244 0.06185 9.205729 0 9.375 0 C 9.544271 0 9.690755 0.06185 9.814453 0.185547 C 9.93815 0.309246 10 0.45573 10 0.625 C 10 0.794271 9.93815 0.940756 9.814453 1.064453 L 2.138672 8.75 L 19.375 8.75 C 19.54427 8.75 19.690754 8.81185 19.814453 8.935547 C 19.93815 9.059245 20 9.205729 20 9.375 Z " />
</Viewbox>
</Button.Content>
</Button>
<Image
Width="150"
Height="50"
HorizontalAlignment="Center"
Source="{x:Bind SelectedMailProvider.ProviderImage, Mode=OneWay}" />
<TextBox
x:Name="DisplayNameTextBox"
Grid.Row="1"
Header="Display Name"
PlaceholderText="eg. John Doe"
TextChanged="InputChanged" />
<TextBox
x:Name="SpecialImapAddress"
Grid.Row="2"
Header="E-mail Address"
PlaceholderText="eg. johndoe@testmail.com"
TextChanged="InputChanged" />
<PasswordBox
x:Name="AppSpecificPassword"
Grid.Row="3"
Header="App-Specific Password"
PasswordChanged="ImapPasswordChanged" />
<HyperlinkButton
Grid.Row="4"
HorizontalAlignment="Right"
Click="AppSpecificHelpButtonClicked"
Content="How do I get app-specific password?" />
</Grid>
</Grid> </Grid>
</ContentDialog> </ContentDialog>

View File

@@ -1,6 +1,9 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using Windows.System;
using Windows.UI.Xaml; using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Controls;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
@@ -8,6 +11,17 @@ namespace Wino.Core.UWP.Dialogs
{ {
public sealed partial class NewAccountDialog : ContentDialog public sealed partial class NewAccountDialog : ContentDialog
{ {
private Dictionary<SpecialImapProvider, string> helpingLinks = new Dictionary<SpecialImapProvider, string>()
{
{ SpecialImapProvider.iCloud, "https://support.apple.com/en-us/102654" },
{ SpecialImapProvider.Yahoo, "http://help.yahoo.com/kb/SLN15241.html" },
};
public static readonly DependencyProperty IsProviderSelectionVisibleProperty = DependencyProperty.Register(nameof(IsProviderSelectionVisible), typeof(bool), typeof(NewAccountDialog), new PropertyMetadata(true));
public static readonly DependencyProperty IsSpecialImapServerPartVisibleProperty = DependencyProperty.Register(nameof(IsSpecialImapServerPartVisible), typeof(bool), typeof(NewAccountDialog), new PropertyMetadata(false));
public static readonly DependencyProperty SelectedMailProviderProperty = DependencyProperty.Register(nameof(SelectedMailProvider), typeof(ProviderDetail), typeof(NewAccountDialog), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedProviderChanged)));
/// <summary> /// <summary>
/// Gets or sets current selected mail provider in the dialog. /// Gets or sets current selected mail provider in the dialog.
/// </summary> /// </summary>
@@ -17,7 +31,18 @@ namespace Wino.Core.UWP.Dialogs
set { SetValue(SelectedMailProviderProperty, value); } set { SetValue(SelectedMailProviderProperty, value); }
} }
public static readonly DependencyProperty SelectedMailProviderProperty = DependencyProperty.Register(nameof(SelectedMailProvider), typeof(ProviderDetail), typeof(NewAccountDialog), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedProviderChanged)));
public bool IsProviderSelectionVisible
{
get { return (bool)GetValue(IsProviderSelectionVisibleProperty); }
set { SetValue(IsProviderSelectionVisibleProperty, value); }
}
public bool IsSpecialImapServerPartVisible
{
get { return (bool)GetValue(IsSpecialImapServerPartVisibleProperty); }
set { SetValue(IsSpecialImapServerPartVisibleProperty, value); }
}
// List of available mail providers for now. // List of available mail providers for now.
@@ -45,16 +70,40 @@ namespace Wino.Core.UWP.Dialogs
private void CreateClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) private void CreateClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{ {
if (IsSpecialImapServerPartVisible)
{
// Special imap detail input.
var details = new SpecialImapProviderDetails(SpecialImapAddress.Text.Trim(), AppSpecificPassword.Password.Trim(), DisplayNameTextBox.Text.Trim(), SelectedMailProvider.SpecialImapProvider);
Result = new AccountCreationDialogResult(SelectedMailProvider.Type, AccountNameTextbox.Text.Trim(), details);
Hide();
return;
}
Validate(); Validate();
if (IsSecondaryButtonEnabled) if (IsSecondaryButtonEnabled)
{ {
Result = new AccountCreationDialogResult(SelectedMailProvider.Type, AccountNameTextbox.Text.Trim()); if (SelectedMailProvider.SpecialImapProvider != SpecialImapProvider.None)
Hide(); {
// This step requires app-sepcific password login for some providers.
args.Cancel = true;
IsProviderSelectionVisible = false;
IsSpecialImapServerPartVisible = true;
Validate();
}
else
{
Result = new AccountCreationDialogResult(SelectedMailProvider.Type, AccountNameTextbox.Text.Trim(), null);
Hide();
}
} }
} }
private void AccountNameChanged(object sender, TextChangedEventArgs e) => Validate(); private void InputChanged(object sender, TextChangedEventArgs e) => Validate();
private void SenderNameChanged(object sender, TextChangedEventArgs e) => Validate(); private void SenderNameChanged(object sender, TextChangedEventArgs e) => Validate();
private void Validate() private void Validate()
@@ -68,7 +117,10 @@ namespace Wino.Core.UWP.Dialogs
{ {
bool shouldEnable = SelectedMailProvider != null bool shouldEnable = SelectedMailProvider != null
&& SelectedMailProvider.IsSupported && SelectedMailProvider.IsSupported
&& !string.IsNullOrEmpty(AccountNameTextbox.Text); && !string.IsNullOrEmpty(AccountNameTextbox.Text)
&& (IsSpecialImapServerPartVisible ? (!string.IsNullOrEmpty(AppSpecificPassword.Password)
&& !string.IsNullOrEmpty(DisplayNameTextBox.Text)
&& EmailValidation.EmailValidator.Validate(SpecialImapAddress.Text)) : true);
IsPrimaryButtonEnabled = shouldEnable; IsPrimaryButtonEnabled = shouldEnable;
} }
@@ -79,5 +131,22 @@ namespace Wino.Core.UWP.Dialogs
} }
private void DialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args) => Validate(); private void DialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args) => Validate();
private void BackClicked(object sender, RoutedEventArgs e)
{
IsSpecialImapServerPartVisible = false;
IsProviderSelectionVisible = true;
Validate();
}
private void ImapPasswordChanged(object sender, RoutedEventArgs e) => Validate();
private async void AppSpecificHelpButtonClicked(object sender, RoutedEventArgs e)
{
var helpUrl = helpingLinks[SelectedMailProvider.SpecialImapProvider];
await Launcher.LaunchUriAsync(new Uri(helpUrl));
}
} }
} }

View File

@@ -11,6 +11,7 @@ using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Markup; using Windows.UI.Xaml.Markup;
using Windows.UI.Xaml.Media; using Windows.UI.Xaml.Media;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.MailItem;
using Wino.Core.UWP.Controls; using Wino.Core.UWP.Controls;
@@ -262,17 +263,31 @@ namespace Wino.Helpers
}; };
} }
public static WinoIconGlyph GetProviderIcon(MailProviderType providerType)
public static WinoIconGlyph GetProviderIcon(MailProviderType providerType, SpecialImapProvider specialImapProvider)
{ {
return providerType switch if (specialImapProvider == SpecialImapProvider.None)
{ {
MailProviderType.Outlook => WinoIconGlyph.Microsoft, return providerType switch
MailProviderType.Gmail => WinoIconGlyph.Google, {
MailProviderType.Office365 => WinoIconGlyph.Microsoft, MailProviderType.Outlook => WinoIconGlyph.Microsoft,
MailProviderType.IMAP4 => WinoIconGlyph.IMAP, MailProviderType.Gmail => WinoIconGlyph.Google,
_ => WinoIconGlyph.None, MailProviderType.IMAP4 => WinoIconGlyph.IMAP,
}; _ => WinoIconGlyph.None,
};
}
else
{
return specialImapProvider switch
{
SpecialImapProvider.iCloud => WinoIconGlyph.Apple,
SpecialImapProvider.Yahoo => WinoIconGlyph.Yahoo,
_ => WinoIconGlyph.None,
};
}
} }
public static WinoIconGlyph GetProviderIcon(MailAccount account)
=> GetProviderIcon(account.ProviderType, account.SpecialImapProvider);
public static Geometry GetPathGeometry(string pathMarkup) public static Geometry GetPathGeometry(string pathMarkup)
{ {

View File

@@ -131,7 +131,7 @@ namespace Wino.Core.UWP.Services
return file; return file;
} }
public virtual IAccountCreationDialog GetAccountCreationDialog(MailProviderType type) public virtual IAccountCreationDialog GetAccountCreationDialog(AccountCreationDialogResult accountCreationDialogResult)
{ {
return new AccountCreationDialog return new AccountCreationDialog
{ {

View File

@@ -18,6 +18,7 @@ using Wino.Core.Integration.Json;
using Wino.Messaging; using Wino.Messaging;
using Wino.Messaging.Client.Connection; using Wino.Messaging.Client.Connection;
using Wino.Messaging.Enums; using Wino.Messaging.Enums;
using Wino.Messaging.Server;
using Wino.Messaging.UI; using Wino.Messaging.UI;
namespace Wino.Core.UWP.Services namespace Wino.Core.UWP.Services
@@ -256,6 +257,9 @@ namespace Wino.Core.UWP.Services
case nameof(CopyAuthURLRequested): case nameof(CopyAuthURLRequested):
WeakReferenceMessenger.Default.Send(JsonSerializer.Deserialize(messageJson, CommunicationMessagesContext.Default.CopyAuthURLRequested)); WeakReferenceMessenger.Default.Send(JsonSerializer.Deserialize(messageJson, CommunicationMessagesContext.Default.CopyAuthURLRequested));
break; break;
case nameof(NewMailSynchronizationRequested):
WeakReferenceMessenger.Default.Send(JsonSerializer.Deserialize<NewMailSynchronizationRequested>(messageJson));
break;
default: default:
throw new Exception("Invalid data type name passed to client."); throw new Exception("Invalid data type name passed to client.");
} }

View File

@@ -29,7 +29,7 @@
Header="{x:Bind Account.Name}" Header="{x:Bind Account.Name}"
IsClickEnabled="True"> IsClickEnabled="True">
<winuiControls:SettingsCard.HeaderIcon> <winuiControls:SettingsCard.HeaderIcon>
<coreControls:WinoFontIcon FontSize="64" Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(ProviderDetail.Type)}" /> <coreControls:WinoFontIcon FontSize="64" Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(Account)}" />
</winuiControls:SettingsCard.HeaderIcon> </winuiControls:SettingsCard.HeaderIcon>
</winuiControls:SettingsCard> </winuiControls:SettingsCard>
</DataTemplate> </DataTemplate>

View File

@@ -24,6 +24,7 @@
<None Remove="Assets\FileTypes\type_rar.png" /> <None Remove="Assets\FileTypes\type_rar.png" />
<None Remove="Assets\FileTypes\type_video.png" /> <None Remove="Assets\FileTypes\type_video.png" />
<None Remove="Assets\Providers\Gmail.png" /> <None Remove="Assets\Providers\Gmail.png" />
<None Remove="Assets\Providers\iCloud.png" />
<None Remove="Assets\Providers\IMAP4.png" /> <None Remove="Assets\Providers\IMAP4.png" />
<None Remove="Assets\Providers\Office 365.png" /> <None Remove="Assets\Providers\Office 365.png" />
<None Remove="Assets\Providers\Outlook.png" /> <None Remove="Assets\Providers\Outlook.png" />
@@ -69,6 +70,7 @@
<Content Include="Assets\FileTypes\type_rar.png" /> <Content Include="Assets\FileTypes\type_rar.png" />
<Content Include="Assets\FileTypes\type_video.png" /> <Content Include="Assets\FileTypes\type_video.png" />
<Content Include="Assets\Providers\Gmail.png" /> <Content Include="Assets\Providers\Gmail.png" />
<Content Include="Assets\Providers\iCloud.png" />
<Content Include="Assets\Providers\IMAP4.png" /> <Content Include="Assets\Providers\IMAP4.png" />
<Content Include="Assets\Providers\Office 365.png" /> <Content Include="Assets\Providers\Office 365.png" />
<Content Include="Assets\Providers\Outlook.png" /> <Content Include="Assets\Providers\Outlook.png" />
@@ -99,6 +101,7 @@
<PackageReference Include="Microsoft.AppCenter.Analytics" /> <PackageReference Include="Microsoft.AppCenter.Analytics" />
<PackageReference Include="Microsoft.NETCore.UniversalWindowsPlatform" /> <PackageReference Include="Microsoft.NETCore.UniversalWindowsPlatform" />
<PackageReference Include="Win2D.uwp" /> <PackageReference Include="Win2D.uwp" />
<PackageReference Include="EmailValidation" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" /> <ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />

View File

@@ -4,6 +4,7 @@ using Wino.Authentication;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration.Processors; using Wino.Core.Integration.Processors;
using Wino.Core.Services; using Wino.Core.Services;
using Wino.Core.Synchronizers.ImapSync;
namespace Wino.Core namespace Wino.Core
{ {
@@ -28,6 +29,11 @@ namespace Wino.Core
services.AddTransient<IUnsubscriptionService, UnsubscriptionService>(); services.AddTransient<IUnsubscriptionService, UnsubscriptionService>();
services.AddTransient<IOutlookAuthenticator, OutlookAuthenticator>(); services.AddTransient<IOutlookAuthenticator, OutlookAuthenticator>();
services.AddTransient<IGmailAuthenticator, GmailAuthenticator>(); services.AddTransient<IGmailAuthenticator, GmailAuthenticator>();
services.AddTransient<IImapSynchronizationStrategyProvider, ImapSynchronizationStrategyProvider>();
services.AddTransient<CondstoreSynchronizer>();
services.AddTransient<QResyncSynchronizer>();
services.AddTransient<UidBasedSynchronizer>();
} }
} }
} }

View File

@@ -25,8 +25,6 @@ namespace Wino.Core.Integration
/// Provides a pooling mechanism for ImapClient. /// Provides a pooling mechanism for ImapClient.
/// Makes sure that we don't have too many connections to the server. /// Makes sure that we don't have too many connections to the server.
/// Rents a connected & authenticated client from the pool all the time. /// 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> /// </summary>
/// <param name="customServerInformation">Connection/Authentication info to be used to configure ImapClient.</param> /// <param name="customServerInformation">Connection/Authentication info to be used to configure ImapClient.</param>
public class ImapClientPool : IDisposable public class ImapClientPool : IDisposable
@@ -48,14 +46,16 @@ namespace Wino.Core.Integration
public bool ThrowOnSSLHandshakeCallback { get; set; } public bool ThrowOnSSLHandshakeCallback { get; set; }
public ImapClientPoolOptions ImapClientPoolOptions { get; } public ImapClientPoolOptions ImapClientPoolOptions { get; }
internal WinoImapClient IdleClient { get; set; }
private readonly int MinimumPoolSize = 5; private readonly int MinimumPoolSize = 5;
private readonly ConcurrentStack<ImapClient> _clients = []; private readonly ConcurrentStack<IImapClient> _clients = [];
private readonly SemaphoreSlim _semaphore; private readonly SemaphoreSlim _semaphore;
private readonly CustomServerInformation _customServerInformation; private readonly CustomServerInformation _customServerInformation;
private readonly Stream _protocolLogStream; private readonly Stream _protocolLogStream;
private readonly ILogger _logger = Log.ForContext<ImapClientPool>(); private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
private bool _disposedValue;
public ImapClientPool(ImapClientPoolOptions imapClientPoolOptions) public ImapClientPool(ImapClientPoolOptions imapClientPoolOptions)
{ {
@@ -74,7 +74,7 @@ namespace Wino.Core.Integration
/// Reconnects and reauthenticates if necessary. /// Reconnects and reauthenticates if necessary.
/// </summary> /// </summary>
/// <param name="isCreatedNew">Whether the client has been newly created.</param> /// <param name="isCreatedNew">Whether the client has been newly created.</param>
private async Task EnsureCapabilitiesAsync(ImapClient client, bool isCreatedNew) private async Task EnsureCapabilitiesAsync(IImapClient client, bool isCreatedNew)
{ {
try try
{ {
@@ -84,6 +84,9 @@ namespace Wino.Core.Integration
if ((isCreatedNew || isReconnected) && client.IsConnected) if ((isCreatedNew || isReconnected) && client.IsConnected)
{ {
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
await client.CompressAsync();
// Identify if the server supports ID extension. // Identify if the server supports ID extension.
// Some servers require it pre-authentication, some post-authentication. // Some servers require it pre-authentication, some post-authentication.
// We'll observe the response here and do it after authentication if needed. // We'll observe the response here and do it after authentication if needed.
@@ -113,10 +116,11 @@ namespace Wino.Core.Integration
// Activate post-auth capabilities. // Activate post-auth capabilities.
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync)) if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
await client.EnableQuickResyncAsync(); {
await client.EnableQuickResyncAsync().ConfigureAwait(false);
if (client.Capabilities.HasFlag(ImapCapabilities.Compress)) if (client is WinoImapClient winoImapClient) winoImapClient.IsQResyncEnabled = true;
await client.CompressAsync(); }
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -145,11 +149,11 @@ namespace Wino.Core.Integration
return reader.ReadToEnd(); return reader.ReadToEnd();
} }
public async Task<ImapClient> GetClientAsync() public async Task<IImapClient> GetClientAsync()
{ {
await _semaphore.WaitAsync(); await _semaphore.WaitAsync();
if (_clients.TryPop(out ImapClient item)) if (_clients.TryPop(out IImapClient item))
{ {
await EnsureCapabilitiesAsync(item, false); await EnsureCapabilitiesAsync(item, false);
@@ -163,20 +167,24 @@ namespace Wino.Core.Integration
return client; return client;
} }
public void Release(ImapClient item, bool destroyClient = false) public void Release(IImapClient item, bool destroyClient = false)
{ {
if (item != null) if (item != null)
{ {
if (destroyClient) if (destroyClient)
{ {
lock (item.SyncRoot) if (item.IsConnected)
{ {
item.Disconnect(true); lock (item.SyncRoot)
{
item.Disconnect(quit: true);
}
} }
_clients.TryPop(out _);
item.Dispose(); item.Dispose();
} }
else else if (!_disposedValue)
{ {
_clients.Push(item); _clients.Push(item);
} }
@@ -185,23 +193,15 @@ namespace Wino.Core.Integration
} }
} }
public void DestroyClient(ImapClient client) private IImapClient CreateNewClient()
{ {
if (client == null) return; WinoImapClient client = null;
client.Disconnect(true);
client.Dispose();
}
private ImapClient CreateNewClient()
{
ImapClient client = null;
// Make sure to create a ImapClient with a protocol logger if enabled. // Make sure to create a ImapClient with a protocol logger if enabled.
client = _protocolLogStream != null client = _protocolLogStream != null
? new ImapClient(new ProtocolLogger(_protocolLogStream)) ? new WinoImapClient(new ProtocolLogger(_protocolLogStream))
: new ImapClient(); : new WinoImapClient();
HttpProxyClient proxyClient = null; HttpProxyClient proxyClient = null;
@@ -213,7 +213,7 @@ namespace Wino.Core.Integration
client.ProxyClient = proxyClient; client.ProxyClient = proxyClient;
_logger.Debug("Created new ImapClient. Current clients: {Count}", _clients.Count); _logger.Debug("Creating new ImapClient. Current clients: {Count}", _clients.Count);
return client; return client;
} }
@@ -229,7 +229,7 @@ namespace Wino.Core.Integration
}; };
/// <returns>True if the connection is newly established.</returns> /// <returns>True if the connection is newly established.</returns>
public async Task<bool> EnsureConnectedAsync(ImapClient client) public async Task<bool> EnsureConnectedAsync(IImapClient client)
{ {
if (client.IsConnected) return false; if (client.IsConnected) return false;
@@ -263,8 +263,20 @@ namespace Wino.Core.Integration
{ {
if (_protocolLogStream == null) return; if (_protocolLogStream == null) return;
var messageBytes = Encoding.UTF8.GetBytes($"W: {message}\n"); try
_protocolLogStream.Write(messageBytes, 0, messageBytes.Length); {
var messageBytes = Encoding.UTF8.GetBytes($"W: {message}\n");
_protocolLogStream.Write(messageBytes, 0, messageBytes.Length);
}
catch (ObjectDisposedException)
{
Log.Warning($"Protocol log stream is disposed. Cannot write to it.");
}
catch (Exception)
{
throw;
}
} }
bool MyServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) bool MyServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
@@ -281,7 +293,7 @@ namespace Wino.Core.Integration
return true; return true;
} }
public async Task EnsureAuthenticatedAsync(ImapClient client) public async Task EnsureAuthenticatedAsync(IImapClient client)
{ {
if (client.IsAuthenticated) return; if (client.IsAuthenticated) return;
@@ -319,27 +331,38 @@ namespace Wino.Core.Integration
}; };
} }
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_clients.ForEach(client =>
{
lock (client.SyncRoot)
{
client.Disconnect(true);
}
});
_clients.ForEach(client =>
{
client.Dispose();
});
_clients.Clear();
_protocolLogStream?.Dispose();
}
_disposedValue = true;
}
}
public void Dispose() public void Dispose()
{ {
_clients.ForEach(client => Dispose(disposing: true);
{ GC.SuppressFinalize(this);
lock (client.SyncRoot)
{
client.Disconnect(true);
}
});
_clients.ForEach(client =>
{
client.Dispose();
});
_clients.Clear();
if (_protocolLogStream != null)
{
_protocolLogStream.Dispose();
}
} }
} }
} }

View File

@@ -0,0 +1,60 @@
using MailKit;
using MailKit.Net.Imap;
using Serilog;
namespace Wino.Core.Integration
{
/// <summary>
/// Extended class for ImapClient that is used in Wino.
/// </summary>
internal class WinoImapClient : ImapClient
{
/// <summary>
/// Gets or internally sets whether the QRESYNC extension is enabled.
/// It is set by ImapClientPool immidiately after the authentication.
/// </summary>
public bool IsQResyncEnabled { get; internal set; }
public WinoImapClient()
{
HookEvents();
}
public WinoImapClient(IProtocolLogger protocolLogger) : base(protocolLogger)
{
HookEvents();
}
private void HookEvents()
{
Disconnected += ClientDisconnected;
}
private void UnhookEvents()
{
Disconnected -= ClientDisconnected;
}
private void ClientDisconnected(object sender, DisconnectedEventArgs e)
{
if (e.IsRequested)
{
Log.Debug("Imap client is disconnected on request.");
}
else
{
Log.Debug("Imap client connection is dropped by server.");
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
UnhookEvents();
}
}
}
}

View File

@@ -1,9 +0,0 @@
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

@@ -7,10 +7,10 @@ namespace Wino.Core.Requests.Bundles
{ {
public class ImapRequest public class ImapRequest
{ {
public Func<ImapClient, IRequestBase, Task> IntegratorTask { get; } public Func<IImapClient, IRequestBase, Task> IntegratorTask { get; }
public IRequestBase Request { get; } public IRequestBase Request { get; }
public ImapRequest(Func<ImapClient, IRequestBase, Task> integratorTask, IRequestBase request) public ImapRequest(Func<IImapClient, IRequestBase, Task> integratorTask, IRequestBase request)
{ {
IntegratorTask = integratorTask; IntegratorTask = integratorTask;
Request = request; Request = request;
@@ -19,7 +19,7 @@ namespace Wino.Core.Requests.Bundles
public class ImapRequest<TRequestBaseType> : ImapRequest where TRequestBaseType : IRequestBase public class ImapRequest<TRequestBaseType> : ImapRequest where TRequestBaseType : IRequestBase
{ {
public ImapRequest(Func<ImapClient, TRequestBaseType, Task> integratorTask, TRequestBaseType request) public ImapRequest(Func<IImapClient, TRequestBaseType, Task> integratorTask, TRequestBaseType request)
: base((client, request) => integratorTask(client, (TRequestBaseType)request), request) : base((client, request) => integratorTask(client, (TRequestBaseType)request), request)
{ {
} }

View File

@@ -11,6 +11,7 @@ namespace Wino.Core.Requests.Folder
{ {
public record EmptyFolderRequest(MailItemFolder Folder, List<MailCopy> MailsToDelete) : FolderRequestBase(Folder, FolderSynchronizerOperation.EmptyFolder), ICustomFolderSynchronizationRequest public record EmptyFolderRequest(MailItemFolder Folder, List<MailCopy> MailsToDelete) : FolderRequestBase(Folder, FolderSynchronizerOperation.EmptyFolder), ICustomFolderSynchronizationRequest
{ {
public bool ExcludeMustHaveFolders => false;
public override void ApplyUIChanges() public override void ApplyUIChanges()
{ {
foreach (var item in MailsToDelete) foreach (var item in MailsToDelete)

View File

@@ -32,5 +32,7 @@ namespace Wino.Core.Requests.Folder
} }
public List<Guid> SynchronizationFolderIds => [Folder.Id]; public List<Guid> SynchronizationFolderIds => [Folder.Id];
public bool ExcludeMustHaveFolders => true;
} }
} }

View File

@@ -21,6 +21,7 @@ namespace Wino.Core.Requests.Mail
public record ArchiveRequest(bool IsArchiving, MailCopy Item, MailItemFolder FromFolder, MailItemFolder ToFolder = null) public record ArchiveRequest(bool IsArchiving, MailCopy Item, MailItemFolder FromFolder, MailItemFolder ToFolder = null)
: MailRequestBase(Item), ICustomFolderSynchronizationRequest : MailRequestBase(Item), ICustomFolderSynchronizationRequest
{ {
public bool ExcludeMustHaveFolders => false;
public List<Guid> SynchronizationFolderIds public List<Guid> SynchronizationFolderIds
{ {
get get

View File

@@ -14,6 +14,8 @@ namespace Wino.Core.Requests.Mail
{ {
public List<Guid> SynchronizationFolderIds => [Item.FolderId]; public List<Guid> SynchronizationFolderIds => [Item.FolderId];
public bool ExcludeMustHaveFolders => true;
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.ChangeFlag; public override MailSynchronizerOperation Operation => MailSynchronizerOperation.ChangeFlag;
public override void ApplyUIChanges() public override void ApplyUIChanges()

View File

@@ -13,6 +13,8 @@ namespace Wino.Core.Requests.Mail
: MailRequestBase(DraftPreperationRequest.CreatedLocalDraftCopy), : MailRequestBase(DraftPreperationRequest.CreatedLocalDraftCopy),
ICustomFolderSynchronizationRequest ICustomFolderSynchronizationRequest
{ {
public bool ExcludeMustHaveFolders => false;
public List<Guid> SynchronizationFolderIds => public List<Guid> SynchronizationFolderIds =>
[ [
DraftPreperationRequest.CreatedLocalDraftCopy.AssignedFolder.Id DraftPreperationRequest.CreatedLocalDraftCopy.AssignedFolder.Id

View File

@@ -17,7 +17,7 @@ namespace Wino.Core.Requests.Mail
ICustomFolderSynchronizationRequest ICustomFolderSynchronizationRequest
{ {
public List<Guid> SynchronizationFolderIds => [Item.FolderId]; public List<Guid> SynchronizationFolderIds => [Item.FolderId];
public bool ExcludeMustHaveFolders => false;
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Delete; public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Delete;
public override void ApplyUIChanges() public override void ApplyUIChanges()

View File

@@ -15,6 +15,8 @@ namespace Wino.Core.Requests.Mail
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.MarkRead; public override MailSynchronizerOperation Operation => MailSynchronizerOperation.MarkRead;
public bool ExcludeMustHaveFolders => true;
public override void ApplyUIChanges() public override void ApplyUIChanges()
{ {
Item.IsRead = IsRead; Item.IsRead = IsRead;

View File

@@ -13,7 +13,7 @@ namespace Wino.Core.Requests.Mail
: MailRequestBase(Item), ICustomFolderSynchronizationRequest : MailRequestBase(Item), ICustomFolderSynchronizationRequest
{ {
public List<Guid> SynchronizationFolderIds => new() { FromFolder.Id, ToFolder.Id }; public List<Guid> SynchronizationFolderIds => new() { FromFolder.Id, ToFolder.Id };
public bool ExcludeMustHaveFolders => false;
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Move; public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Move;
public override void ApplyUIChanges() public override void ApplyUIChanges()

View File

@@ -28,6 +28,8 @@ namespace Wino.Core.Requests.Mail
} }
} }
public bool ExcludeMustHaveFolders => false;
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Send; public override MailSynchronizerOperation Operation => MailSynchronizerOperation.Send;
public override void ApplyUIChanges() public override void ApplyUIChanges()

View File

@@ -28,7 +28,6 @@ namespace Wino.Core.Services
return providerType switch return providerType switch
{ {
MailProviderType.Outlook => new OutlookAuthenticator(_nativeAppService, _applicationConfiguration, _authenticatorConfig), MailProviderType.Outlook => new OutlookAuthenticator(_nativeAppService, _applicationConfiguration, _authenticatorConfig),
MailProviderType.Office365 => new Office365Authenticator(_nativeAppService, _applicationConfiguration, _authenticatorConfig),
MailProviderType.Gmail => new GmailAuthenticator(_authenticatorConfig), MailProviderType.Gmail => new GmailAuthenticator(_authenticatorConfig),
_ => throw new ArgumentException(Translator.Exception_UnsupportedProvider), _ => throw new ArgumentException(Translator.Exception_UnsupportedProvider),
}; };

View File

@@ -13,6 +13,7 @@ namespace Wino.Core.Services
private bool isInitialized = false; private bool isInitialized = false;
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
private readonly IApplicationConfiguration _applicationConfiguration; private readonly IApplicationConfiguration _applicationConfiguration;
private readonly IOutlookChangeProcessor _outlookChangeProcessor; private readonly IOutlookChangeProcessor _outlookChangeProcessor;
private readonly IGmailChangeProcessor _gmailChangeProcessor; private readonly IGmailChangeProcessor _gmailChangeProcessor;
@@ -28,6 +29,7 @@ namespace Wino.Core.Services
IOutlookAuthenticator outlookAuthenticator, IOutlookAuthenticator outlookAuthenticator,
IGmailAuthenticator gmailAuthenticator, IGmailAuthenticator gmailAuthenticator,
IAccountService accountService, IAccountService accountService,
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
IApplicationConfiguration applicationConfiguration) IApplicationConfiguration applicationConfiguration)
{ {
_outlookChangeProcessor = outlookChangeProcessor; _outlookChangeProcessor = outlookChangeProcessor;
@@ -36,6 +38,7 @@ namespace Wino.Core.Services
_outlookAuthenticator = outlookAuthenticator; _outlookAuthenticator = outlookAuthenticator;
_gmailAuthenticator = gmailAuthenticator; _gmailAuthenticator = gmailAuthenticator;
_accountService = accountService; _accountService = accountService;
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
_applicationConfiguration = applicationConfiguration; _applicationConfiguration = applicationConfiguration;
} }
@@ -51,6 +54,7 @@ namespace Wino.Core.Services
{ {
synchronizer = CreateNewSynchronizer(account); synchronizer = CreateNewSynchronizer(account);
return await GetAccountSynchronizerAsync(accountId); return await GetAccountSynchronizerAsync(accountId);
} }
} }
@@ -65,12 +69,11 @@ namespace Wino.Core.Services
switch (providerType) switch (providerType)
{ {
case Domain.Enums.MailProviderType.Outlook: case Domain.Enums.MailProviderType.Outlook:
case Domain.Enums.MailProviderType.Office365:
return new OutlookSynchronizer(mailAccount, _outlookAuthenticator, _outlookChangeProcessor); return new OutlookSynchronizer(mailAccount, _outlookAuthenticator, _outlookChangeProcessor);
case Domain.Enums.MailProviderType.Gmail: case Domain.Enums.MailProviderType.Gmail:
return new GmailSynchronizer(mailAccount, _gmailAuthenticator, _gmailChangeProcessor); return new GmailSynchronizer(mailAccount, _gmailAuthenticator, _gmailChangeProcessor);
case Domain.Enums.MailProviderType.IMAP4: case Domain.Enums.MailProviderType.IMAP4:
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration); return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _imapSynchronizationStrategyProvider, _applicationConfiguration);
default: default:
break; break;
} }
@@ -82,6 +85,12 @@ namespace Wino.Core.Services
{ {
var synchronizer = CreateIntegratorWithDefaultProcessor(account); var synchronizer = CreateIntegratorWithDefaultProcessor(account);
if (synchronizer is IImapSynchronizer imapSynchronizer)
{
// Start the idle client for IMAP synchronizer.
_ = imapSynchronizer.StartIdleClientAsync();
}
synchronizerCache.Add(synchronizer); synchronizerCache.Add(synchronizer);
return synchronizer; return synchronizer;
@@ -100,5 +109,18 @@ namespace Wino.Core.Services
isInitialized = true; isInitialized = true;
} }
public async Task DeleteSynchronizerAsync(Guid accountId)
{
var synchronizer = synchronizerCache.Find(a => a.Account.Id == accountId);
if (synchronizer != null)
{
// Stop the current synchronization.
await synchronizer.KillSynchronizerAsync();
synchronizerCache.Remove(synchronizer);
}
}
} }
} }

View File

@@ -23,7 +23,7 @@ namespace Wino.Core.Services
{ {
return mailProviderType switch return mailProviderType switch
{ {
MailProviderType.Outlook or MailProviderType.Office365 => _outlookThreadingStrategy, MailProviderType.Outlook => _outlookThreadingStrategy,
MailProviderType.Gmail => _gmailThreadingStrategy, MailProviderType.Gmail => _gmailThreadingStrategy,
_ => _imapThreadStrategy, _ => _imapThreadStrategy,
}; };

View File

@@ -163,8 +163,7 @@ namespace Wino.Core.Services
MailItemFolder archiveFolder = null; MailItemFolder archiveFolder = null;
bool shouldRequireArchiveFolder = mailItem.AssignedAccount.ProviderType == MailProviderType.Outlook bool shouldRequireArchiveFolder = mailItem.AssignedAccount.ProviderType == MailProviderType.Outlook
|| mailItem.AssignedAccount.ProviderType == MailProviderType.IMAP4 || mailItem.AssignedAccount.ProviderType == MailProviderType.IMAP4;
|| mailItem.AssignedAccount.ProviderType == MailProviderType.Office365;
if (shouldRequireArchiveFolder) if (shouldRequireArchiveFolder)
{ {

View File

@@ -51,9 +51,6 @@ namespace Wino.Core.Synchronizers
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
public abstract Task ExecuteNativeRequestsAsync(List<IRequestBundle<TBaseRequest>> batchedRequests, CancellationToken cancellationToken = default); public abstract Task ExecuteNativeRequestsAsync(List<IRequestBundle<TBaseRequest>> batchedRequests, CancellationToken cancellationToken = default);
// TODO: What if account is deleted during synchronization?
public bool CancelActiveSynchronization() => true;
/// <summary> /// <summary>
/// Refreshes remote mail account profile if possible. /// Refreshes remote mail account profile if possible.
/// Profile picture, sender name and mailbox settings (todo) will be handled in this step. /// Profile picture, sender name and mailbox settings (todo) will be handled in this step.

View File

@@ -1193,5 +1193,15 @@ namespace Wino.Core.Synchronizers.Mail
} }
#endregion #endregion
public override async Task KillSynchronizerAsync()
{
await base.KillSynchronizerAsync();
_gmailService.Dispose();
_peopleService.Dispose();
_calendarService.Dispose();
_googleHttpClient.Dispose();
}
} }
} }

View File

@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration;
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
namespace Wino.Core.Synchronizers.ImapSync
{
/// <summary>
/// RFC 4551 CONDSTORE IMAP Synchronization strategy.
/// </summary>
internal class CondstoreSynchronizer : ImapSynchronizationStrategyBase
{
public CondstoreSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService)
{
}
public async override Task<List<string>> HandleSynchronizationAsync(IImapClient client,
MailItemFolder folder,
IImapSynchronizer synchronizer,
CancellationToken cancellationToken = default)
{
if (client is not WinoImapClient winoClient)
throw new ArgumentException("Client must be of type WinoImapClient.", nameof(client));
if (!client.Capabilities.HasFlag(ImapCapabilities.CondStore))
throw new ImapSynchronizerStrategyException("Server does not support CONDSTORE.");
IMailFolder remoteFolder = null;
var downloadedMessageIds = new List<string>();
try
{
remoteFolder = await winoClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
var localHighestModSeq = (ulong)folder.HighestModeSeq;
bool isInitialSynchronization = localHighestModSeq == 0;
// There are some changes on new messages or flag changes.
// Deletions are tracked separately because some servers do not increase
// the MODSEQ value for deleted messages.
if (remoteFolder.HighestModSeq > localHighestModSeq)
{
var changedUids = await GetChangedUidsAsync(client, remoteFolder, synchronizer, cancellationToken).ConfigureAwait(false);
// Get locally exists mails for the returned UIDs.
downloadedMessageIds = await HandleChangedUIdsAsync(synchronizer, remoteFolder, changedUids, cancellationToken).ConfigureAwait(false);
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
await FolderService.UpdateFolderAsync(folder).ConfigureAwait(false);
}
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
return downloadedMessageIds;
}
catch (FolderNotFoundException)
{
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
return default;
}
catch (Exception)
{
throw;
}
finally
{
if (!cancellationToken.IsCancellationRequested)
{
if (remoteFolder != null)
{
if (remoteFolder.IsOpen)
{
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
}
}
internal override async Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient winoClient, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
{
var localHighestModSeq = (ulong)Folder.HighestModeSeq;
var remoteHighestModSeq = remoteFolder.HighestModSeq;
// Search for emails with a MODSEQ greater than the last known value.
// Use SORT extension if server supports.
IList<UniqueId> changedUids = null;
if (winoClient.Capabilities.HasFlag(ImapCapabilities.Sort))
{
// Highest mod seq must be greater than 0 for SORT.
changedUids = await remoteFolder.SortAsync(SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)), [OrderBy.ReverseDate], cancellationToken).ConfigureAwait(false);
}
else
{
changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
}
changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
// For initial synchronizations, take the first allowed number of items.
// For consequtive synchronizations, take all the items. We don't want to miss any changes.
// Smaller uid means newer message. For initial sync, we need start taking items from the top.
bool isInitialSynchronization = localHighestModSeq == 0;
if (isInitialSynchronization)
{
changedUids = changedUids.OrderByDescending(a => a.Id).Take((int)synchronizer.InitialMessageDownloadCountPerFolder).ToList();
}
return changedUids;
}
}
}

View File

@@ -0,0 +1,185 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using MoreLinq;
using Serilog;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Services.Extensions;
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
namespace Wino.Core.Synchronizers.ImapSync
{
public abstract class ImapSynchronizationStrategyBase : IImapSynchronizerStrategy
{
// Minimum summary items to Fetch for mail synchronization from IMAP.
protected readonly MessageSummaryItems MailSynchronizationFlags =
MessageSummaryItems.Flags |
MessageSummaryItems.UniqueId |
MessageSummaryItems.ThreadId |
MessageSummaryItems.EmailId |
MessageSummaryItems.Headers |
MessageSummaryItems.PreviewText |
MessageSummaryItems.GMailThreadId |
MessageSummaryItems.References |
MessageSummaryItems.ModSeq;
protected IFolderService FolderService { get; }
protected IMailService MailService { get; }
protected MailItemFolder Folder { get; set; }
protected ImapSynchronizationStrategyBase(IFolderService folderService, IMailService mailService)
{
FolderService = folderService;
MailService = mailService;
}
public abstract Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
internal abstract Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
protected async Task<List<string>> HandleChangedUIdsAsync(IImapSynchronizer synchronizer, IMailFolder remoteFolder, IList<UniqueId> changedUids, CancellationToken cancellationToken)
{
List<string> downloadedMessageIds = new();
var existingMails = await MailService.GetExistingMailsAsync(Folder.Id, changedUids).ConfigureAwait(false);
var existingMailUids = existingMails.Select(m => MailkitClientExtensions.ResolveUidStruct(m.Id)).ToArray();
// These are the non-existing mails. They will be downloaded + processed.
var newMessageIds = changedUids.Except(existingMailUids).ToList();
var deletedMessageIds = existingMailUids.Except(changedUids).ToList();
// Fetch minimum data for the existing mails in one query.
var existingFlagData = await remoteFolder.FetchAsync(existingMailUids, MessageSummaryItems.Flags | MessageSummaryItems.UniqueId).ConfigureAwait(false);
foreach (var update in existingFlagData)
{
if (update.UniqueId == null)
{
Log.Warning($"Couldn't fetch UniqueId for the mail. FetchAsync failed.");
continue;
}
if (update.Flags == null)
{
Log.Warning($"Couldn't fetch flags for the mail with UID {update.UniqueId.Id}. FetchAsync failed.");
continue;
}
var existingMail = existingMails.FirstOrDefault(m => MailkitClientExtensions.ResolveUidStruct(m.Id).Id == update.UniqueId.Id);
if (existingMail == null)
{
Log.Warning($"Couldn't find the mail with UID {update.UniqueId.Id} in the local database. Flag update is ignored.");
continue;
}
await HandleMessageFlagsChangeAsync(existingMail, update.Flags.Value).ConfigureAwait(false);
}
// Fetch the new mails in batch.
var batchedMessageIds = newMessageIds.Batch(50);
foreach (var group in batchedMessageIds)
{
var summaries = await remoteFolder.FetchAsync(group, MailSynchronizationFlags, cancellationToken).ConfigureAwait(false);
foreach (var summary in summaries)
{
var mimeMessage = await remoteFolder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false);
var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage);
var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, Folder, cancellationToken).ConfigureAwait(false);
if (mailPackages != null)
{
foreach (var package in mailPackages)
{
// Local draft is mapped. We don't need to create a new mail copy.
if (package == null) continue;
bool isCreatedNew = await MailService.CreateMailAsync(Folder.MailAccountId, package).ConfigureAwait(false);
// This is upsert. We are not interested in updated mails.
if (isCreatedNew) downloadedMessageIds.Add(package.Copy.Id);
}
}
}
}
return downloadedMessageIds;
}
protected async Task HandleMessageFlagsChangeAsync(UniqueId? uniqueId, MessageFlags flags)
{
if (Folder == null) return;
if (uniqueId == null) return;
var localMailCopyId = MailkitClientExtensions.CreateUid(Folder.Id, uniqueId.Value.Id);
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
var isRead = MailkitClientExtensions.GetIsRead(flags);
await MailService.ChangeReadStatusAsync(localMailCopyId, isRead).ConfigureAwait(false);
await MailService.ChangeFlagStatusAsync(localMailCopyId, isFlagged).ConfigureAwait(false);
}
protected async Task HandleMessageFlagsChangeAsync(MailCopy mailCopy, MessageFlags flags)
{
if (mailCopy == null) return;
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
var isRead = MailkitClientExtensions.GetIsRead(flags);
if (isFlagged != mailCopy.IsFlagged)
{
await MailService.ChangeFlagStatusAsync(mailCopy.Id, isFlagged).ConfigureAwait(false);
}
if (isRead != mailCopy.IsRead)
{
await MailService.ChangeReadStatusAsync(mailCopy.Id, isRead).ConfigureAwait(false);
}
}
protected async Task HandleMessageDeletedAsync(IList<UniqueId> uniqueIds)
{
if (Folder == null) return;
if (uniqueIds == null || uniqueIds.Count == 0) return;
foreach (var uniqueId in uniqueIds)
{
if (uniqueId == null) continue;
var localMailCopyId = MailkitClientExtensions.CreateUid(Folder.Id, uniqueId.Id);
await MailService.DeleteMailAsync(Folder.MailAccountId, localMailCopyId).ConfigureAwait(false);
}
}
protected void OnMessagesVanished(object sender, MessagesVanishedEventArgs args)
=> HandleMessageDeletedAsync(args.UniqueIds).ConfigureAwait(false);
protected void OnMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs args)
=> HandleMessageFlagsChangeAsync(args.UniqueId, args.Flags).ConfigureAwait(false);
protected async Task ManageUUIdBasedDeletedMessagesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken = default)
{
var allUids = (await FolderService.GetKnownUidsForFolderAsync(localFolder.Id)).Select(a => new UniqueId(a)).ToList();
if (allUids.Count > 0)
{
var remoteAllUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken);
var deletedUids = allUids.Except(remoteAllUids).ToList();
await HandleMessageDeletedAsync(deletedUids).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,31 @@
using MailKit.Net.Imap;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration;
namespace Wino.Core.Synchronizers.ImapSync
{
internal class ImapSynchronizationStrategyProvider : IImapSynchronizationStrategyProvider
{
private readonly QResyncSynchronizer _qResyncSynchronizer;
private readonly CondstoreSynchronizer _condstoreSynchronizer;
private readonly UidBasedSynchronizer _uidBasedSynchronizer;
public ImapSynchronizationStrategyProvider(QResyncSynchronizer qResyncSynchronizer, CondstoreSynchronizer condstoreSynchronizer, UidBasedSynchronizer uidBasedSynchronizer)
{
_qResyncSynchronizer = qResyncSynchronizer;
_condstoreSynchronizer = condstoreSynchronizer;
_uidBasedSynchronizer = uidBasedSynchronizer;
}
public IImapSynchronizerStrategy GetSynchronizationStrategy(IImapClient client)
{
if (client is not WinoImapClient winoImapClient)
throw new System.ArgumentException("Client must be of type WinoImapClient.", nameof(client));
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync) && winoImapClient.IsQResyncEnabled) return _qResyncSynchronizer;
if (client.Capabilities.HasFlag(ImapCapabilities.CondStore)) return _condstoreSynchronizer;
return _uidBasedSynchronizer;
}
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration;
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
namespace Wino.Core.Synchronizers.ImapSync
{
/// <summary>
/// RFC 5162 QRESYNC IMAP Synchronization strategy.
/// </summary>
internal class QResyncSynchronizer : ImapSynchronizationStrategyBase
{
public QResyncSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService)
{
}
public override async Task<List<string>> HandleSynchronizationAsync(IImapClient client,
MailItemFolder folder,
IImapSynchronizer synchronizer,
CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
if (client is not WinoImapClient winoClient)
throw new ImapSynchronizerStrategyException("Client must be of type WinoImapClient.");
if (!client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
throw new ImapSynchronizerStrategyException("Server does not support QRESYNC.");
if (!winoClient.IsQResyncEnabled)
throw new ImapSynchronizerStrategyException("QRESYNC is not enabled for WinoImapClient.");
// Ready to implement QRESYNC synchronization.
IMailFolder remoteFolder = null;
Folder = folder;
try
{
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
// Check the Uid validity first.
// If they don't match, clear all the local data and perform full-resync.
bool isCacheValid = remoteFolder.UidValidity == folder.UidValidity;
if (!isCacheValid)
{
// TODO: Remove all local data.
}
// Perform QRESYNC synchronization.
var localHighestModSeq = (ulong)folder.HighestModeSeq;
remoteFolder.MessagesVanished += OnMessagesVanished;
remoteFolder.MessageFlagsChanged += OnMessageFlagsChanged;
var allUids = await FolderService.GetKnownUidsForFolderAsync(folder.Id);
var allUniqueIds = allUids.Select(a => new UniqueId(a)).ToList();
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, allUniqueIds).ConfigureAwait(false);
var changedUids = await GetChangedUidsAsync(client, remoteFolder, synchronizer, cancellationToken).ConfigureAwait(false);
downloadedMessageIds = await HandleChangedUIdsAsync(synchronizer, remoteFolder, changedUids, cancellationToken).ConfigureAwait(false);
// Update the local folder with the new highest mod-seq and validity.
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
folder.UidValidity = remoteFolder.UidValidity;
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
await FolderService.UpdateFolderAsync(folder).ConfigureAwait(false);
}
catch (FolderNotFoundException)
{
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
return default;
}
catch (Exception)
{
throw;
}
finally
{
if (!cancellationToken.IsCancellationRequested)
{
if (remoteFolder != null)
{
remoteFolder.MessagesVanished -= OnMessagesVanished;
remoteFolder.MessageFlagsChanged -= OnMessageFlagsChanged;
if (remoteFolder.IsOpen)
{
await remoteFolder.CloseAsync();
}
}
}
}
return downloadedMessageIds;
}
internal override async Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
{
var localHighestModSeq = (ulong)Folder.HighestModeSeq;
return await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration;
namespace Wino.Core.Synchronizers.ImapSync
{
/// <summary>
/// Uid based IMAP Synchronization strategy.
/// </summary>
internal class UidBasedSynchronizer : ImapSynchronizationStrategyBase
{
public UidBasedSynchronizer(IFolderService folderService, Domain.Interfaces.IMailService mailService) : base(folderService, mailService)
{
}
public override async Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
{
if (client is not WinoImapClient winoClient)
throw new ArgumentException("Client must be of type WinoImapClient.", nameof(client));
Folder = folder;
var downloadedMessageIds = new List<string>();
IMailFolder remoteFolder = null;
try
{
remoteFolder = await winoClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
// Fetch UIDs from the remote folder
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
remoteUids = remoteUids.OrderByDescending(a => a.Id).Take((int)synchronizer.InitialMessageDownloadCountPerFolder).ToList();
await HandleChangedUIdsAsync(synchronizer, remoteFolder, remoteUids, cancellationToken).ConfigureAwait(false);
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
}
catch (FolderNotFoundException)
{
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
return default;
}
catch (Exception)
{
throw;
}
finally
{
if (!cancellationToken.IsCancellationRequested)
{
if (remoteFolder != null)
{
if (remoteFolder.IsOpen)
{
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
}
return downloadedMessageIds;
}
internal override Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
}

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -7,8 +8,6 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using MailKit; using MailKit;
using MailKit.Net.Imap; using MailKit.Net.Imap;
using MailKit.Search;
using MimeKit;
using MoreLinq; using MoreLinq;
using Serilog; using Serilog;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
@@ -22,72 +21,55 @@ using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Extensions; using Wino.Core.Extensions;
using Wino.Core.Integration; using Wino.Core.Integration;
using Wino.Core.Integration.Processors; using Wino.Core.Integration.Processors;
using Wino.Core.Mime;
using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Folder; using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail; using Wino.Core.Requests.Mail;
using Wino.Messaging.Server;
using Wino.Messaging.UI; using Wino.Messaging.UI;
using Wino.Services.Extensions; using Wino.Services.Extensions;
namespace Wino.Core.Synchronizers.Mail namespace Wino.Core.Synchronizers.Mail
{ {
public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreationPackage, object> public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreationPackage, object>, IImapSynchronizer
{ {
private CancellationTokenSource idleDoneToken; [Obsolete("N/A")]
private CancellationTokenSource cancelInboxListeningToken = new CancellationTokenSource(); public override uint BatchModificationSize => 1000;
public override uint InitialMessageDownloadCountPerFolder => 500;
private IMailFolder inboxFolder; #region Idle Implementation
private CancellationTokenSource idleCancellationTokenSource;
private CancellationTokenSource idleDoneTokenSource;
#endregion
private readonly ILogger _logger = Log.ForContext<ImapSynchronizer>(); private readonly ILogger _logger = Log.ForContext<ImapSynchronizer>();
private readonly ImapClientPool _clientPool; private readonly ImapClientPool _clientPool;
private readonly IImapChangeProcessor _imapChangeProcessor; private readonly IImapChangeProcessor _imapChangeProcessor;
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
private readonly IApplicationConfiguration _applicationConfiguration; private readonly IApplicationConfiguration _applicationConfiguration;
// Minimum summary items to Fetch for mail synchronization from IMAP.
private readonly MessageSummaryItems mailSynchronizationFlags =
MessageSummaryItems.Flags |
MessageSummaryItems.UniqueId |
MessageSummaryItems.ThreadId |
MessageSummaryItems.EmailId |
MessageSummaryItems.Headers |
MessageSummaryItems.PreviewText |
MessageSummaryItems.GMailThreadId |
MessageSummaryItems.References |
MessageSummaryItems.ModSeq;
/// <summary>
/// Timer that keeps the <see cref="InboxClient"/> alive for the lifetime of the pool.
/// Sends NOOP command to the server periodically.
/// </summary>
private Timer _noOpTimer;
/// <summary>
/// ImapClient that keeps the Inbox folder opened all the time for listening notifications.
/// </summary>
private ImapClient _inboxIdleClient;
public override uint BatchModificationSize => 1000;
public override uint InitialMessageDownloadCountPerFolder => 250;
public ImapSynchronizer(MailAccount account, public ImapSynchronizer(MailAccount account,
IImapChangeProcessor imapChangeProcessor, IImapChangeProcessor imapChangeProcessor,
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
IApplicationConfiguration applicationConfiguration) : base(account) IApplicationConfiguration applicationConfiguration) : base(account)
{ {
// Create client pool with account protocol log. // Create client pool with account protocol log.
_imapChangeProcessor = imapChangeProcessor; _imapChangeProcessor = imapChangeProcessor;
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
_applicationConfiguration = applicationConfiguration; _applicationConfiguration = applicationConfiguration;
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, CreateAccountProtocolLogFileStream()); var protocolLogStream = CreateAccountProtocolLogFileStream();
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
_clientPool = new ImapClientPool(poolOptions); _clientPool = new ImapClientPool(poolOptions);
idleDoneToken = new CancellationTokenSource();
} }
private Stream CreateAccountProtocolLogFileStream() private Stream CreateAccountProtocolLogFileStream()
{ {
if (Account == null) throw new ArgumentNullException(nameof(Account)); if (Account == null) throw new ArgumentNullException(nameof(Account));
var logFile = Path.Combine(_applicationConfiguration.ApplicationDataFolderPath, $"Protocol_{Account.Address}.log"); var logFile = Path.Combine(_applicationConfiguration.ApplicationDataFolderPath, $"Protocol_{Account.Address}_{Account.Id}.log");
// Each session should start a new log. // Each session should start a new log.
if (File.Exists(logFile)) File.Delete(logFile); if (File.Exists(logFile)) File.Delete(logFile);
@@ -95,136 +77,10 @@ namespace Wino.Core.Synchronizers.Mail
return new FileStream(logFile, FileMode.CreateNew); return new FileStream(logFile, FileMode.CreateNew);
} }
// TODO
// private async void NoOpTimerTriggered(object state) => await AwaitInboxIdleAsync();
private async Task AwaitInboxIdleAsync()
{
if (_inboxIdleClient == null)
{
_logger.Warning("InboxClient is null. Cannot send NOOP command.");
return;
}
await _clientPool.EnsureConnectedAsync(_inboxIdleClient);
await _clientPool.EnsureAuthenticatedAsync(_inboxIdleClient);
try
{
if (inboxFolder == null)
{
inboxFolder = _inboxIdleClient.Inbox;
await inboxFolder.OpenAsync(FolderAccess.ReadOnly, cancelInboxListeningToken.Token);
}
idleDoneToken = new CancellationTokenSource();
await _inboxIdleClient.IdleAsync(idleDoneToken.Token, cancelInboxListeningToken.Token);
}
finally
{
idleDoneToken.Dispose();
idleDoneToken = null;
}
}
private async Task StopInboxListeningAsync()
{
if (inboxFolder != null)
{
inboxFolder.CountChanged -= InboxFolderCountChanged;
inboxFolder.MessageExpunged -= InboxFolderMessageExpunged;
inboxFolder.MessageFlagsChanged -= InboxFolderMessageFlagsChanged;
}
if (_noOpTimer != null)
{
_noOpTimer.Dispose();
_noOpTimer = null;
}
if (idleDoneToken != null)
{
idleDoneToken.Cancel();
idleDoneToken.Dispose();
idleDoneToken = null;
}
if (_inboxIdleClient != null)
{
await _inboxIdleClient.DisconnectAsync(true);
_inboxIdleClient.Dispose();
_inboxIdleClient = null;
}
}
/// <summary>
/// Tries to connect & authenticate with the given credentials.
/// Prepares synchronizer for active listening of Inbox folder.
/// </summary>
public async Task StartInboxListeningAsync()
{
_inboxIdleClient = await _clientPool.GetClientAsync();
// Run it every 8 minutes after 1 minute delay.
// _noOpTimer = new Timer(NoOpTimerTriggered, null, 60000, 8 * 60 * 1000);
await _clientPool.EnsureConnectedAsync(_inboxIdleClient);
await _clientPool.EnsureAuthenticatedAsync(_inboxIdleClient);
if (!_inboxIdleClient.Capabilities.HasFlag(ImapCapabilities.Idle))
{
_logger.Information("Imap server does not support IDLE command. Listening live changes is not supported for {Name}", Account.Name);
return;
}
inboxFolder = _inboxIdleClient.Inbox;
if (inboxFolder == null)
{
_logger.Information("Inbox folder is null. Cannot listen for changes.");
return;
}
inboxFolder.CountChanged += InboxFolderCountChanged;
inboxFolder.MessageExpunged += InboxFolderMessageExpunged;
inboxFolder.MessageFlagsChanged += InboxFolderMessageFlagsChanged;
while (!cancelInboxListeningToken.IsCancellationRequested)
{
await AwaitInboxIdleAsync();
}
await StopInboxListeningAsync();
}
private void InboxFolderMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs e)
{
Console.WriteLine("Flags have changed for message #{0} ({1}).", e.Index, e.Flags);
}
private void InboxFolderMessageExpunged(object sender, MessageEventArgs e)
{
_logger.Information("Inbox folder message expunged");
}
private void InboxFolderCountChanged(object sender, EventArgs e)
{
_logger.Information("Inbox folder count changed.");
}
/// <summary>
/// Parses List of string of mail copy ids and return valid uIds.
/// Follow the rules for creating arbitrary unique id for mail copies.
/// </summary>
private UniqueIdSet GetUniqueIds(IEnumerable<string> mailCopyIds)
=> new(mailCopyIds.Select(a => new UniqueId(MailkitClientExtensions.ResolveUid(a))));
/// <summary> /// <summary>
/// Returns UniqueId for the given mail copy id. /// Returns UniqueId for the given mail copy id.
/// </summary> /// </summary>
private UniqueId GetUniqueId(string mailCopyId) private UniqueId GetUniqueId(string mailCopyId) => new(MailkitClientExtensions.ResolveUid(mailCopyId));
=> new(MailkitClientExtensions.ResolveUid(mailCopyId));
#region Mail Integrations #region Mail Integrations
@@ -319,8 +175,6 @@ namespace Wino.Core.Synchronizers.Mail
var singleRequest = request.Request; var singleRequest = request.Request;
singleRequest.Mime.Prepare(EncodingConstraint.None);
using var smtpClient = new MailKit.Net.Smtp.SmtpClient(); using var smtpClient = new MailKit.Net.Smtp.SmtpClient();
if (smtpClient.IsConnected && client.IsAuthenticated) return; if (smtpClient.IsConnected && client.IsAuthenticated) return;
@@ -400,22 +254,19 @@ namespace Wino.Core.Synchronizers.Mail
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{ {
var imapFolder = message.MailFolder; var mailCopy = message.MessageSummary.GetMailDetails(assignedFolder, message.MimeMessage);
var summary = message.MessageSummary;
var mimeMessage = await imapFolder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false);
var mailCopy = summary.GetMailDetails(assignedFolder, mimeMessage);
// Draft folder message updates must be updated as IsDraft. // Draft folder message updates must be updated as IsDraft.
// I couldn't find it in MimeMessage... // I couldn't find it in MimeMesssage...
mailCopy.IsDraft = assignedFolder.SpecialFolderType == SpecialFolderType.Draft; mailCopy.IsDraft = assignedFolder.SpecialFolderType == SpecialFolderType.Draft;
// Check draft mapping. // Check draft mapping.
// This is the same implementation as in the OutlookSynchronizer. // This is the same implementation as in the OutlookSynchronizer.
if (mimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader) if (message.MimeMessage != null &&
&& Guid.TryParse(mimeMessage.Headers[Domain.Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId)) message.MimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader) &&
Guid.TryParse(message.MimeMessage.Headers[Domain.Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId))
{ {
// This message belongs to existing local draft copy. // This message belongs to existing local draft copy.
// We don't need to create a new mail copy for this message, just update the existing one. // We don't need to create a new mail copy for this message, just update the existing one.
@@ -427,7 +278,7 @@ namespace Wino.Core.Synchronizers.Mail
// Local copy doesn't exists. Continue execution to insert mail copy. // Local copy doesn't exists. Continue execution to insert mail copy.
} }
var package = new NewMailItemPackage(mailCopy, mimeMessage, assignedFolder.RemoteFolderId); var package = new NewMailItemPackage(mailCopy, message.MimeMessage, assignedFolder.RemoteFolderId);
return return
[ [
@@ -463,7 +314,13 @@ namespace Wino.Core.Synchronizers.Mail
PublishSynchronizationProgress(progress); PublishSynchronizationProgress(progress);
var folderDownloadedMessageIds = await SynchronizeFolderInternalAsync(folder, cancellationToken).ConfigureAwait(false); var folderDownloadedMessageIds = await SynchronizeFolderInternalAsync(folder, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
if (cancellationToken.IsCancellationRequested) return MailSynchronizationResult.Canceled;
if (folderDownloadedMessageIds != null)
{
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
}
} }
} }
@@ -497,7 +354,7 @@ namespace Wino.Core.Synchronizers.Mail
// At this point this client is ready to execute async commands. // At this point this client is ready to execute async commands.
// Each task bundle will await and execution will continue in case of error. // Each task bundle will await and execution will continue in case of error.
ImapClient executorClient = null; IImapClient executorClient = null;
bool isCrashed = false; bool isCrashed = false;
@@ -550,7 +407,7 @@ namespace Wino.Core.Synchronizers.Mail
/// <param name="executorClient">ImapClient from the pool</param> /// <param name="executorClient">ImapClient from the pool</param>
/// <param name="remoteFolder">Assigning remote folder.</param> /// <param name="remoteFolder">Assigning remote folder.</param>
/// <param name="localFolder">Assigning local folder.</param> /// <param name="localFolder">Assigning local folder.</param>
private void AssignSpecialFolderType(ImapClient executorClient, IMailFolder remoteFolder, MailItemFolder localFolder) private void AssignSpecialFolderType(IImapClient executorClient, IMailFolder remoteFolder, MailItemFolder localFolder)
{ {
// Inbox is awlawys available. Don't miss it for assignment even though XList or SpecialUser is not supported. // Inbox is awlawys available. Don't miss it for assignment even though XList or SpecialUser is not supported.
if (executorClient.Inbox == remoteFolder) if (executorClient.Inbox == remoteFolder)
@@ -592,7 +449,7 @@ namespace Wino.Core.Synchronizers.Mail
var localFolders = await _imapChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false); var localFolders = await _imapChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
ImapClient executorClient = null; IImapClient executorClient = null;
try try
{ {
@@ -674,6 +531,11 @@ namespace Wino.Core.Synchronizers.Mail
if (remoteFolder.IsNamespace && !remoteFolder.Attributes.HasFlag(FolderAttributes.Inbox) || !remoteFolder.Exists) if (remoteFolder.IsNamespace && !remoteFolder.Attributes.HasFlag(FolderAttributes.Inbox) || !remoteFolder.Exists)
continue; continue;
// Check for NoSelect folders. These are not selectable folders.
// TODO: With new MailKit version 'CanOpen' will be implemented for ease of use. Use that one.
if (remoteFolder.Attributes.HasFlag(FolderAttributes.NoSelect))
continue;
var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.FullName); var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.FullName);
if (existingLocalFolder == null) if (existingLocalFolder == null)
@@ -768,264 +630,42 @@ namespace Wino.Core.Synchronizers.Mail
} }
} }
private async Task<IEnumerable<string>> SynchronizeFolderInternalAsync(MailItemFolder folder, CancellationToken cancellationToken = default) private async Task<IEnumerable<string>> SynchronizeFolderInternalAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
{ {
if (!folder.IsSynchronizationEnabled) return default; if (!folder.IsSynchronizationEnabled) return default;
var downloadedMessageIds = new List<string>(); IImapClient availableClient = null;
// STEP1: Ask for flag changes for older mails.
// STEP2: Get new mail changes.
// https://www.rfc-editor.org/rfc/rfc4549 - Section 4.3
var _synchronizationClient = await _clientPool.GetClientAsync();
IMailFolder imapFolder = null;
var knownMailIds = new UniqueIdSet();
var locallyKnownMailUids = await _imapChangeProcessor.GetKnownUidsForFolderAsync(folder.Id);
knownMailIds.AddRange(locallyKnownMailUids.Select(a => new UniqueId(a)));
var highestUniqueId = Math.Max(0, locallyKnownMailUids.Count == 0 ? 0 : locallyKnownMailUids.Max());
var missingMailIds = new UniqueIdSet();
var uidValidity = folder.UidValidity;
var highestModeSeq = folder.HighestModeSeq;
var logger = Log.ForContext("FolderName", folder.FolderName);
logger.Verbose("HighestModeSeq: {HighestModeSeq}, HighestUniqueId: {HighestUniqueId}, UIDValidity: {UIDValidity}", highestModeSeq, highestUniqueId, uidValidity);
// Event handlers are placed here to handle existing MailItemFolder and IIMailFolder from MailKit.
// MailKit doesn't expose folder data when these events are emitted.
// Use local folder's UidValidty because cache might've been expired for remote IMAP folder.
// That will make our mail copy id invalid.
EventHandler<MessagesVanishedEventArgs> MessageVanishedHandler = async (s, e) =>
{
if (imapFolder == null) return;
foreach (var uniqueId in e.UniqueIds)
{
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, uniqueId.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, localMailCopyId);
}
};
EventHandler<MessageFlagsChangedEventArgs> MessageFlagsChangedHandler = async (s, e) =>
{
if (imapFolder == null) return;
if (e.UniqueId == null) return;
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, e.UniqueId.Value.Id);
var isFlagged = MailkitClientExtensions.GetIsFlagged(e.Flags);
var isRead = MailkitClientExtensions.GetIsRead(e.Flags);
await _imapChangeProcessor.ChangeMailReadStatusAsync(localMailCopyId, isRead);
await _imapChangeProcessor.ChangeFlagStatusAsync(localMailCopyId, isFlagged);
};
EventHandler<MessageEventArgs> MessageExpungedHandler = async (s, e) =>
{
if (imapFolder == null) return;
if (e.UniqueId == null) return;
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, e.UniqueId.Value.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, localMailCopyId);
};
retry:
try try
{ {
imapFolder = await _synchronizationClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken);
imapFolder.MessageFlagsChanged += MessageFlagsChangedHandler; availableClient = await _clientPool.GetClientAsync().ConfigureAwait(false);
// TODO: Bug: Enabling quick re-sync actually doesn't enable it. var strategy = _imapSynchronizationStrategyProvider.GetSynchronizationStrategy(availableClient);
return await strategy.HandleSynchronizationAsync(availableClient, folder, this, cancellationToken).ConfigureAwait(false);
var qsyncEnabled = false; // _synchronizationClient.Capabilities.HasFlag(ImapCapabilities.QuickResync);
var condStoreEnabled = _synchronizationClient.Capabilities.HasFlag(ImapCapabilities.CondStore);
if (qsyncEnabled)
{
imapFolder.MessagesVanished += MessageVanishedHandler;
await imapFolder.OpenAsync(FolderAccess.ReadWrite, uidValidity, (ulong)highestModeSeq, knownMailIds, cancellationToken);
// Check the folder validity.
// We'll delete our existing cache if it's not.
// Get all messages after the last successful synchronization date.
// This is fine for Wino synchronization because we're not really looking to
// synchronize all folder.
var allMessageIds = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken);
if (uidValidity != imapFolder.UidValidity)
{
// TODO: Cache is invalid. Delete all local cache.
//await ChangeProcessor.FolderService.ClearImapFolderCacheAsync(folder.Id);
folder.UidValidity = imapFolder.UidValidity;
missingMailIds.AddRange(allMessageIds);
}
else
{
// Cache is valid.
// Add missing mails only.
missingMailIds.AddRange(allMessageIds.Except(knownMailIds).Where(a => a.Id > highestUniqueId));
}
}
else
{
// QSYNC extension is not enabled for the server.
// We rely on ConditionalStore.
imapFolder.MessageExpunged += MessageExpungedHandler;
await imapFolder.OpenAsync(FolderAccess.ReadWrite, cancellationToken);
// Get all messages after the last succesful synchronization date.
// This is fine for Wino synchronization because we're not really looking to
// synchronize all folder.
var allMessageIds = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken);
if (uidValidity != imapFolder.UidValidity)
{
// TODO: Cache is invalid. Delete all local cache.
// await ChangeProcessor.FolderService.ClearImapFolderCacheAsync(folder.Id);
folder.UidValidity = imapFolder.UidValidity;
missingMailIds.AddRange(allMessageIds);
}
else
{
// Cache is valid.
var purgedMessages = knownMailIds.Except(allMessageIds);
foreach (var purgedMessage in purgedMessages)
{
var mailId = MailkitClientExtensions.CreateUid(folder.Id, purgedMessage.Id);
await _imapChangeProcessor.DeleteMailAsync(Account.Id, mailId);
}
IList<IMessageSummary> changed;
if (knownMailIds.Count > 0)
{
// CONDSTORE enabled. Fetch items with highest mode seq for known items
// to track flag changes. Otherwise just get changes without the mode seq.
if (condStoreEnabled)
changed = await imapFolder.FetchAsync(knownMailIds, (ulong)highestModeSeq, MessageSummaryItems.Flags | MessageSummaryItems.ModSeq | MessageSummaryItems.UniqueId);
else
changed = await imapFolder.FetchAsync(knownMailIds, MessageSummaryItems.Flags | MessageSummaryItems.UniqueId);
foreach (var changedItem in changed)
{
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, changedItem.UniqueId.Id);
var isFlagged = changedItem.Flags.GetIsFlagged();
var isRead = changedItem.Flags.GetIsRead();
await _imapChangeProcessor.ChangeMailReadStatusAsync(localMailCopyId, isRead);
await _imapChangeProcessor.ChangeFlagStatusAsync(localMailCopyId, isFlagged);
}
}
// We're only interested in items that has highier known uid than we fetched before.
// Others are just older messages.
missingMailIds.AddRange(allMessageIds.Except(knownMailIds).Where(a => a.Id > highestUniqueId));
}
}
// Fetch completely missing new items in the end.
// Limit check.
if (missingMailIds.Count > InitialMessageDownloadCountPerFolder)
{
missingMailIds = new UniqueIdSet(missingMailIds.TakeLast((int)InitialMessageDownloadCountPerFolder));
}
// In case of the high input, we'll batch them by 50 to reflect changes quickly.
var batchedMissingMailIds = missingMailIds.Batch(50).Select(a => new UniqueIdSet(a, SortOrder.Ascending));
foreach (var batchMissingMailIds in batchedMissingMailIds)
{
var summaries = await imapFolder.FetchAsync(batchMissingMailIds, mailSynchronizationFlags, cancellationToken).ConfigureAwait(false);
foreach (var summary in summaries)
{
// We pass the opened folder and summary to retrieve raw MimeMessage.
var creationPackage = new ImapMessageCreationPackage(summary, imapFolder);
var createdMailPackages = await CreateNewMailPackagesAsync(creationPackage, folder, cancellationToken).ConfigureAwait(false);
// Local draft is mapped. We don't need to create a new mail copy.
if (createdMailPackages == null)
continue;
foreach (var mailPackage in createdMailPackages)
{
bool isCreated = await _imapChangeProcessor.CreateMailAsync(Account.Id, mailPackage).ConfigureAwait(false);
if (isCreated)
{
downloadedMessageIds.Add(mailPackage.Copy.Id);
}
}
}
}
if (folder.HighestModeSeq != (long)imapFolder.HighestModSeq)
{
folder.HighestModeSeq = (long)imapFolder.HighestModSeq;
await _imapChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
}
// Update last synchronization date for the folder..
await _imapChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
return downloadedMessageIds;
} }
catch (FolderNotFoundException) catch (IOException)
{ {
await _imapChangeProcessor.DeleteFolderAsync(Account.Id, folder.RemoteFolderId).ConfigureAwait(false); _clientPool.Release(availableClient, false);
return default; goto retry;
}
catch (OperationCanceledException)
{
// Ignore cancellations.
} }
catch (Exception) catch (Exception)
{ {
throw;
} }
finally finally
{ {
if (imapFolder != null) _clientPool.Release(availableClient, false);
{
imapFolder.MessageFlagsChanged -= MessageFlagsChangedHandler;
imapFolder.MessageExpunged -= MessageExpungedHandler;
imapFolder.MessagesVanished -= MessageVanishedHandler;
if (imapFolder.IsOpen)
await imapFolder.CloseAsync();
}
_clientPool.Release(_synchronizationClient);
} }
}
return new List<string>();
}
/// <summary> /// <summary>
/// Whether the local folder should be updated with the remote folder. /// Whether the local folder should be updated with the remote folder.
@@ -1037,8 +677,141 @@ namespace Wino.Core.Synchronizers.Mail
=> !localFolder.FolderName.Equals(remoteFolder.Name, StringComparison.OrdinalIgnoreCase); => !localFolder.FolderName.Equals(remoteFolder.Name, StringComparison.OrdinalIgnoreCase);
protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public async Task StartIdleClientAsync()
{ {
throw new NotImplementedException(); IImapClient idleClient = null;
IMailFolder inboxFolder = null;
bool? reconnect = null;
try
{
var client = await _clientPool.GetClientAsync().ConfigureAwait(false);
if (!client.Capabilities.HasFlag(ImapCapabilities.Idle))
{
Log.Debug($"{Account.Name} does not support Idle command. Ignored.");
return;
}
if (client.Inbox == null)
{
Log.Warning($"{Account.Name} does not have an Inbox folder for idle client to track. Ignored.");
return;
}
// Setup idle client.
idleClient = client;
idleDoneTokenSource ??= new CancellationTokenSource();
idleCancellationTokenSource ??= new CancellationTokenSource();
inboxFolder = client.Inbox;
await inboxFolder.OpenAsync(FolderAccess.ReadOnly, idleCancellationTokenSource.Token);
inboxFolder.CountChanged += IdleNotificationTriggered;
inboxFolder.MessageFlagsChanged += IdleNotificationTriggered;
inboxFolder.MessageExpunged += IdleNotificationTriggered;
inboxFolder.MessagesVanished += IdleNotificationTriggered;
Log.Debug("Starting an idle client for {Name}", Account.Name);
await client.IdleAsync(idleDoneTokenSource.Token, idleCancellationTokenSource.Token);
}
catch (ImapProtocolException protocolException)
{
Log.Warning(protocolException, "Idle client received protocol exception.");
reconnect = true;
}
catch (IOException ioException)
{
Log.Warning(ioException, "Idle client received IO exception.");
reconnect = true;
}
catch (OperationCanceledException)
{
reconnect = !IsDisposing;
}
catch (Exception ex)
{
Log.Warning(ex, "Idle client failed to start.");
reconnect = false;
}
finally
{
if (inboxFolder != null)
{
inboxFolder.CountChanged -= IdleNotificationTriggered;
inboxFolder.MessageFlagsChanged -= IdleNotificationTriggered;
inboxFolder.MessageExpunged -= IdleNotificationTriggered;
inboxFolder.MessagesVanished -= IdleNotificationTriggered;
}
if (idleDoneTokenSource != null)
{
idleDoneTokenSource.Dispose();
idleDoneTokenSource = null;
}
if (idleClient != null)
{
// Killing the client is not necessary. We can re-use it later.
_clientPool.Release(idleClient, destroyClient: false);
idleClient = null;
}
if (reconnect == true)
{
Log.Information("Idle client is reconnecting.");
_ = StartIdleClientAsync();
}
else if (reconnect == false)
{
Log.Information("Finalized idle client.");
}
}
}
private void RequestIdleChangeSynchronization()
{
Debug.WriteLine("Detected idle change.");
// We don't really need to act on the count change in detail.
// Our synchronization should be enough to handle the changes with on-demand sync.
// We can just trigger a sync here IMAPIdle type.
var options = new MailSynchronizationOptions()
{
AccountId = Account.Id,
Type = MailSynchronizationType.IMAPIdle
};
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options, SynchronizationSource.Client));
}
private void IdleNotificationTriggered(object sender, EventArgs e)
=> RequestIdleChangeSynchronization();
public Task StopIdleClientAsync()
{
idleDoneTokenSource?.Cancel();
idleCancellationTokenSource?.Cancel();
return Task.CompletedTask;
}
public override async Task KillSynchronizerAsync()
{
await base.KillSynchronizerAsync();
await StopIdleClientAsync();
// Make sure the client pool safely disconnects all ImapClients.
_clientPool.Dispose();
} }
} }
} }

View File

@@ -1165,5 +1165,12 @@ namespace Wino.Core.Synchronizers.Mail
return !localCalendarName.Equals(remoteCalendarName, StringComparison.OrdinalIgnoreCase); return !localCalendarName.Equals(remoteCalendarName, StringComparison.OrdinalIgnoreCase);
} }
public override async Task KillSynchronizerAsync()
{
await base.KillSynchronizerAsync();
_graphClient.Dispose();
}
} }
} }

View File

@@ -25,11 +25,13 @@ namespace Wino.Core.Synchronizers
{ {
public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEventType> : BaseSynchronizer<TBaseRequest>, IWinoSynchronizerBase public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEventType> : BaseSynchronizer<TBaseRequest>, IWinoSynchronizerBase
{ {
protected bool IsDisposing { get; private set; }
protected Dictionary<MailSynchronizationOptions, CancellationTokenSource> PendingSynchronizationRequest = new();
protected ILogger Logger = Log.ForContext<WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEventType>>(); protected ILogger Logger = Log.ForContext<WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEventType>>();
protected WinoSynchronizer(MailAccount account) : base(account) protected WinoSynchronizer(MailAccount account) : base(account) { }
{
}
/// <summary> /// <summary>
/// How many items per single HTTP call can be modified. /// How many items per single HTTP call can be modified.
@@ -83,94 +85,129 @@ namespace Wino.Core.Synchronizers
{ {
try try
{ {
activeSynchronizationCancellationToken = cancellationToken; if (!ShouldQueueMailSynchronization(options))
State = AccountSynchronizerState.ExecutingRequests;
List<IRequestBundle<TBaseRequest>> nativeRequests = new();
List<IRequestBase> requestCopies = new(changeRequestQueue);
var keys = changeRequestQueue.GroupBy(a => a.GroupingKey());
foreach (var group in keys)
{ {
var key = group.Key; Log.Debug($"{options.Type} synchronization is ignored.");
return MailSynchronizationResult.Canceled;
if (key is MailSynchronizerOperation mailSynchronizerOperation)
{
switch (mailSynchronizerOperation)
{
case MailSynchronizerOperation.MarkRead:
nativeRequests.AddRange(MarkRead(new BatchMarkReadRequest(group.Cast<MarkReadRequest>())));
break;
case MailSynchronizerOperation.Move:
nativeRequests.AddRange(Move(new BatchMoveRequest(group.Cast<MoveRequest>())));
break;
case MailSynchronizerOperation.Delete:
nativeRequests.AddRange(Delete(new BatchDeleteRequest(group.Cast<DeleteRequest>())));
break;
case MailSynchronizerOperation.CreateDraft:
nativeRequests.AddRange(CreateDraft(group.ElementAt(0) as CreateDraftRequest));
break;
case MailSynchronizerOperation.Send:
nativeRequests.AddRange(SendDraft(group.ElementAt(0) as SendDraftRequest));
break;
case MailSynchronizerOperation.ChangeFlag:
nativeRequests.AddRange(ChangeFlag(new BatchChangeFlagRequest(group.Cast<ChangeFlagRequest>())));
break;
case MailSynchronizerOperation.AlwaysMoveTo:
nativeRequests.AddRange(AlwaysMoveTo(new BatchAlwaysMoveToRequest(group.Cast<AlwaysMoveToRequest>())));
break;
case MailSynchronizerOperation.MoveToFocused:
nativeRequests.AddRange(MoveToFocused(new BatchMoveToFocusedRequest(group.Cast<MoveToFocusedRequest>())));
break;
case MailSynchronizerOperation.Archive:
nativeRequests.AddRange(Archive(new BatchArchiveRequest(group.Cast<ArchiveRequest>())));
break;
default:
break;
}
}
else if (key is FolderSynchronizerOperation folderSynchronizerOperation)
{
switch (folderSynchronizerOperation)
{
case FolderSynchronizerOperation.RenameFolder:
nativeRequests.AddRange(RenameFolder(group.ElementAt(0) as RenameFolderRequest));
break;
case FolderSynchronizerOperation.EmptyFolder:
nativeRequests.AddRange(EmptyFolder(group.ElementAt(0) as EmptyFolderRequest));
break;
case FolderSynchronizerOperation.MarkFolderRead:
nativeRequests.AddRange(MarkFolderAsRead(group.ElementAt(0) as MarkFolderAsReadRequest));
break;
default:
break;
}
}
} }
changeRequestQueue.Clear(); var newCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Console.WriteLine($"Prepared {nativeRequests.Count()} native requests"); PendingSynchronizationRequest.Add(options, newCancellationTokenSource);
activeSynchronizationCancellationToken = newCancellationTokenSource.Token;
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
PublishSynchronizationProgress(1); PublishSynchronizationProgress(1);
await ExecuteNativeRequestsAsync(nativeRequests, activeSynchronizationCancellationToken); // ImapSynchronizer will send this type when an Idle client receives a notification of changes.
// We should not execute requests in this case.
bool shouldExecuteRequests = options.Type != MailSynchronizationType.IMAPIdle;
PublishUnreadItemChanges(); bool shouldDelayExecution = false;
int maxExecutionDelay = 0;
// Execute request sync options should be re-calculated after execution. if (shouldExecuteRequests && changeRequestQueue.Any())
// This is the part we decide which individual folders must be synchronized {
// after the batch request execution. State = AccountSynchronizerState.ExecutingRequests;
if (options.Type == MailSynchronizationType.ExecuteRequests)
options = GetSynchronizationOptionsAfterRequestExecution(requestCopies); List<IRequestBundle<TBaseRequest>> nativeRequests = new();
List<IRequestBase> requestCopies = new(changeRequestQueue);
var keys = changeRequestQueue.GroupBy(a => a.GroupingKey());
foreach (var group in keys)
{
var key = group.Key;
if (key is MailSynchronizerOperation mailSynchronizerOperation)
{
switch (mailSynchronizerOperation)
{
case MailSynchronizerOperation.MarkRead:
nativeRequests.AddRange(MarkRead(new BatchMarkReadRequest(group.Cast<MarkReadRequest>())));
break;
case MailSynchronizerOperation.Move:
nativeRequests.AddRange(Move(new BatchMoveRequest(group.Cast<MoveRequest>())));
break;
case MailSynchronizerOperation.Delete:
nativeRequests.AddRange(Delete(new BatchDeleteRequest(group.Cast<DeleteRequest>())));
break;
case MailSynchronizerOperation.CreateDraft:
nativeRequests.AddRange(CreateDraft(group.ElementAt(0) as CreateDraftRequest));
break;
case MailSynchronizerOperation.Send:
nativeRequests.AddRange(SendDraft(group.ElementAt(0) as SendDraftRequest));
break;
case MailSynchronizerOperation.ChangeFlag:
nativeRequests.AddRange(ChangeFlag(new BatchChangeFlagRequest(group.Cast<ChangeFlagRequest>())));
break;
case MailSynchronizerOperation.AlwaysMoveTo:
nativeRequests.AddRange(AlwaysMoveTo(new BatchAlwaysMoveToRequest(group.Cast<AlwaysMoveToRequest>())));
break;
case MailSynchronizerOperation.MoveToFocused:
nativeRequests.AddRange(MoveToFocused(new BatchMoveToFocusedRequest(group.Cast<MoveToFocusedRequest>())));
break;
case MailSynchronizerOperation.Archive:
nativeRequests.AddRange(Archive(new BatchArchiveRequest(group.Cast<ArchiveRequest>())));
break;
default:
break;
}
}
else if (key is FolderSynchronizerOperation folderSynchronizerOperation)
{
switch (folderSynchronizerOperation)
{
case FolderSynchronizerOperation.RenameFolder:
nativeRequests.AddRange(RenameFolder(group.ElementAt(0) as RenameFolderRequest));
break;
case FolderSynchronizerOperation.EmptyFolder:
nativeRequests.AddRange(EmptyFolder(group.ElementAt(0) as EmptyFolderRequest));
break;
case FolderSynchronizerOperation.MarkFolderRead:
nativeRequests.AddRange(MarkFolderAsRead(group.ElementAt(0) as MarkFolderAsReadRequest));
break;
default:
break;
}
}
}
changeRequestQueue.Clear();
Console.WriteLine($"Prepared {nativeRequests.Count()} native requests");
await ExecuteNativeRequestsAsync(nativeRequests, activeSynchronizationCancellationToken).ConfigureAwait(false);
PublishUnreadItemChanges();
// Execute request sync options should be re-calculated after execution.
// This is the part we decide which individual folders must be synchronized
// after the batch request execution.
if (options.Type == MailSynchronizationType.ExecuteRequests)
options = GetSynchronizationOptionsAfterRequestExecution(requestCopies, options.Id);
// Let servers to finish their job. Sometimes the servers doesn't respond immediately.
// Bug: if Outlook can't create the message in Sent Items folder before this delay,
// message will not appear in user's inbox since it's not in the Sent Items folder.
shouldDelayExecution =
(Account.ProviderType == MailProviderType.Outlook)
&& requestCopies.Any(a => a.ResynchronizationDelay > 0);
if (shouldDelayExecution)
{
maxExecutionDelay = requestCopies.Aggregate(0, (max, next) => Math.Max(max, next.ResynchronizationDelay));
}
// In terms of flag/read changes, there is no point of synchronizing must have folders.
options.ExcludeMustHaveFolders = requestCopies.All(a => a is ICustomFolderSynchronizationRequest request && request.ExcludeMustHaveFolders);
}
State = AccountSynchronizerState.Synchronizing; State = AccountSynchronizerState.Synchronizing;
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
// Handle special synchronization types. // Handle special synchronization types.
// Profile information sync. // Profile information sync.
@@ -213,19 +250,9 @@ namespace Wino.Core.Synchronizers
} }
} }
// Let servers to finish their job. Sometimes the servers doesn't respond immediately.
// Bug: if Outlook can't create the message in Sent Items folder before this delay,
// message will not appear in user's inbox since it's not in the Sent Items folder.
bool shouldDelayExecution =
(Account.ProviderType == MailProviderType.Outlook || Account.ProviderType == MailProviderType.Office365)
&& requestCopies.Any(a => a.ResynchronizationDelay > 0);
if (shouldDelayExecution) if (shouldDelayExecution)
{ {
var maxDelay = requestCopies.Aggregate(0, (max, next) => Math.Max(max, next.ResynchronizationDelay)); await Task.Delay(maxExecutionDelay);
await Task.Delay(maxDelay);
} }
// Start the internal synchronization. // Start the internal synchronization.
@@ -249,6 +276,15 @@ namespace Wino.Core.Synchronizers
} }
finally finally
{ {
// Find the request and remove it from the pending list.
var pendingRequest = PendingSynchronizationRequest.FirstOrDefault(a => a.Key.Id == options.Id);
if (pendingRequest.Key != null)
{
PendingSynchronizationRequest.Remove(pendingRequest.Key);
}
// Reset account progress to hide the progress. // Reset account progress to hide the progress.
PublishSynchronizationProgress(0); PublishSynchronizationProgress(0);
@@ -288,7 +324,7 @@ namespace Wino.Core.Synchronizers
/// </summary> /// </summary>
/// <param name="batches">Batch requests to run in synchronization.</param> /// <param name="batches">Batch requests to run in synchronization.</param>
/// <returns>New synchronization options with minimal HTTP effort.</returns> /// <returns>New synchronization options with minimal HTTP effort.</returns>
private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List<IRequestBase> requests) private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List<IRequestBase> requests, Guid existingSynchronizationId)
{ {
List<Guid> synchronizationFolderIds = requests List<Guid> synchronizationFolderIds = requests
.Where(a => a is ICustomFolderSynchronizationRequest) .Where(a => a is ICustomFolderSynchronizationRequest)
@@ -301,6 +337,8 @@ namespace Wino.Core.Synchronizers
AccountId = Account.Id, AccountId = Account.Id,
}; };
options.Id = existingSynchronizationId;
if (synchronizationFolderIds.Count > 0) if (synchronizationFolderIds.Count > 0)
{ {
// Gather FolderIds to synchronize. // Gather FolderIds to synchronize.
@@ -317,6 +355,35 @@ namespace Wino.Core.Synchronizers
return options; return options;
} }
/// <summary>
/// Checks if the mail synchronization should be queued or not.
/// </summary>
/// <param name="options">New mail sync request.</param>
/// <returns>Whether sync should be queued or not.</returns>
private bool ShouldQueueMailSynchronization(MailSynchronizationOptions options)
{
// Multiple IMAPIdle requests are ignored.
if (options.Type == MailSynchronizationType.IMAPIdle &&
PendingSynchronizationRequest.Any(a => a.Key.Type == MailSynchronizationType.IMAPIdle))
{
return false;
}
// Executing requests may trigger idle sync.
// If there are pending execute requests cancel idle change.
// TODO: Ideally this check should only work for Inbox execute requests.
// Check if request folders contains Inbox.
if (options.Type == MailSynchronizationType.IMAPIdle &&
PendingSynchronizationRequest.Any(a => a.Key.Type == MailSynchronizationType.ExecuteRequests))
{
return false;
}
return true;
}
#region Mail/Folder Operations #region Mail/Folder Operations
public virtual bool DelaySendOperationSynchronization() => false; public virtual bool DelaySendOperationSynchronization() => false;
@@ -349,12 +416,12 @@ namespace Wino.Core.Synchronizers
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
public virtual Task DownloadMissingMimeMessageAsync(IMailItem mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual Task DownloadMissingMimeMessageAsync(IMailItem mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public List<IRequestBundle<ImapRequest>> CreateSingleTaskBundle(Func<ImapClient, IRequestBase, Task> action, IRequestBase request, IUIChangeRequest uIChangeRequest) public List<IRequestBundle<ImapRequest>> CreateSingleTaskBundle(Func<IImapClient, IRequestBase, Task> action, IRequestBase request, IUIChangeRequest uIChangeRequest)
{ {
return [new ImapRequestBundle(new ImapRequest(action, request), request, uIChangeRequest)]; return [new ImapRequestBundle(new ImapRequest(action, request), request, uIChangeRequest)];
} }
public List<IRequestBundle<ImapRequest>> CreateTaskBundle<TSingeRequestType>(Func<ImapClient, TSingeRequestType, Task> value, public List<IRequestBundle<ImapRequest>> CreateTaskBundle<TSingeRequestType>(Func<IImapClient, TSingeRequestType, Task> value,
List<TSingeRequestType> requests) List<TSingeRequestType> requests)
where TSingeRequestType : IRequestBase, IUIChangeRequest where TSingeRequestType : IRequestBase, IUIChangeRequest
{ {
@@ -368,6 +435,21 @@ namespace Wino.Core.Synchronizers
return ret; return ret;
} }
public virtual Task KillSynchronizerAsync()
{
IsDisposing = true;
CancelAllSynchronizations();
return Task.CompletedTask;
}
protected void CancelAllSynchronizations()
{
foreach (var request in PendingSynchronizationRequest)
{
request.Value.Cancel();
request.Value.Dispose();
}
}
} }
} }

View File

@@ -7,32 +7,34 @@
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio> <AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly> <ProduceReferenceAssembly>true</ProduceReferenceAssembly>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Diagnostics" /> <PackageReference Include="CommunityToolkit.Diagnostics" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Google.Apis.Calendar.v3" /> <PackageReference Include="Google.Apis.Calendar.v3" />
<PackageReference Include="Google.Apis.Gmail.v1" /> <PackageReference Include="Google.Apis.Gmail.v1" />
<PackageReference Include="Google.Apis.PeopleService.v1" /> <PackageReference Include="Google.Apis.PeopleService.v1" />
<PackageReference Include="HtmlAgilityPack" /> <PackageReference Include="HtmlAgilityPack" />
<PackageReference Include="HtmlKit" /> <PackageReference Include="HtmlKit" />
<PackageReference Include="MailKit" /> <PackageReference Include="MailKit" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Graph" /> <PackageReference Include="Microsoft.Graph" />
<PackageReference Include="Microsoft.Identity.Client" /> <PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="Microsoft.Identity.Client.Broker" /> <PackageReference Include="Microsoft.Identity.Client.Broker" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" /> <PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
<PackageReference Include="MimeKit" /> <PackageReference Include="MimeKit" />
<PackageReference Include="morelinq" /> <PackageReference Include="morelinq" />
<PackageReference Include="Nito.AsyncEx.Tasks" /> <PackageReference Include="Nito.AsyncEx.Tasks" />
<PackageReference Include="NodaTime" /> <PackageReference Include="NodaTime" />
<PackageReference Include="Serilog" /> <PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Exceptions" /> <PackageReference Include="Serilog.Exceptions" />
<PackageReference Include="Serilog.Sinks.Debug" /> <PackageReference Include="Serilog.Sinks.Debug" />
<PackageReference Include="Serilog.Sinks.File" /> <PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="SkiaSharp" /> <PackageReference Include="SkiaSharp" />
<PackageReference Include="SqlKata" /> <PackageReference Include="SqlKata" />
<PackageReference Include="System.Text.Encoding.CodePages" /> <PackageReference Include="System.Text.Encoding.CodePages" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Wino.Authentication\Wino.Authentication.csproj" /> <ProjectReference Include="..\Wino.Authentication\Wino.Authentication.csproj" />
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" /> <ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />

View File

@@ -12,6 +12,7 @@ using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Messaging.Client.Navigation; using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Server;
using Wino.Messaging.UI; using Wino.Messaging.UI;
namespace Wino.Mail.ViewModels namespace Wino.Mail.ViewModels
@@ -20,6 +21,7 @@ namespace Wino.Mail.ViewModels
{ {
private readonly IMailDialogService _dialogService; private readonly IMailDialogService _dialogService;
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly IWinoServerConnectionManager _serverConnectionManager;
private readonly IFolderService _folderService; private readonly IFolderService _folderService;
public MailAccount Account { get; set; } public MailAccount Account { get; set; }
@@ -48,10 +50,12 @@ namespace Wino.Mail.ViewModels
public AccountDetailsPageViewModel(IMailDialogService dialogService, public AccountDetailsPageViewModel(IMailDialogService dialogService,
IAccountService accountService, IAccountService accountService,
IWinoServerConnectionManager serverConnectionManager,
IFolderService folderService) IFolderService folderService)
{ {
_dialogService = dialogService; _dialogService = dialogService;
_accountService = accountService; _accountService = accountService;
_serverConnectionManager = serverConnectionManager;
_folderService = folderService; _folderService = folderService;
} }
@@ -102,13 +106,17 @@ namespace Wino.Mail.ViewModels
if (!confirmation) if (!confirmation)
return; return;
await _accountService.DeleteAccountAsync(Account);
// TODO: Server: Cancel ongoing calls from server for this account. var isSynchronizerKilledResponse = await _serverConnectionManager.GetResponseAsync<bool, KillAccountSynchronizerRequested>(new KillAccountSynchronizerRequested(Account.Id));
_dialogService.InfoBarMessage(Translator.Info_AccountDeletedTitle, string.Format(Translator.Info_AccountDeletedMessage, Account.Name), InfoBarMessageType.Success); if (isSynchronizerKilledResponse.IsSuccess)
{
await _accountService.DeleteAccountAsync(Account);
Messenger.Send(new BackBreadcrumNavigationRequested()); _dialogService.InfoBarMessage(Translator.Info_AccountDeletedTitle, string.Format(Translator.Info_AccountDeletedMessage, Account.Name), InfoBarMessageType.Success);
Messenger.Send(new BackBreadcrumNavigationRequested());
}
} }
public override async void OnNavigatedTo(NavigationMode mode, object parameters) public override async void OnNavigatedTo(NavigationMode mode, object parameters)

View File

@@ -28,18 +28,25 @@ namespace Wino.Mail.ViewModels
{ {
public partial class AccountManagementViewModel : AccountManagementPageViewModelBase public partial class AccountManagementViewModel : AccountManagementPageViewModelBase
{ {
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
private readonly IImapTestService _imapTestService;
public IMailDialogService MailDialogService { get; } public IMailDialogService MailDialogService { get; }
public AccountManagementViewModel(IMailDialogService dialogService, public AccountManagementViewModel(IMailDialogService dialogService,
IWinoServerConnectionManager winoServerConnectionManager, IWinoServerConnectionManager winoServerConnectionManager,
INavigationService navigationService, INavigationService navigationService,
IAccountService accountService, IAccountService accountService,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
IProviderService providerService, IProviderService providerService,
IImapTestService imapTestService,
IStoreManagementService storeManagementService, IStoreManagementService storeManagementService,
IAuthenticationProvider authenticationProvider, IAuthenticationProvider authenticationProvider,
IPreferencesService preferencesService) : base(dialogService, winoServerConnectionManager, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService) IPreferencesService preferencesService) : base(dialogService, winoServerConnectionManager, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService)
{ {
MailDialogService = dialogService; MailDialogService = dialogService;
_specialImapProviderConfigResolver = specialImapProviderConfigResolver;
_imapTestService = imapTestService;
} }
[RelayCommand] [RelayCommand]
@@ -93,7 +100,7 @@ namespace Wino.Mail.ViewModels
if (accountCreationDialogResult != null) if (accountCreationDialogResult != null)
{ {
creationDialog = MailDialogService.GetAccountCreationDialog(accountCreationDialogResult.ProviderType); creationDialog = MailDialogService.GetAccountCreationDialog(accountCreationDialogResult);
CustomServerInformation customServerInformation = null; CustomServerInformation customServerInformation = null;
@@ -101,17 +108,17 @@ namespace Wino.Mail.ViewModels
{ {
ProviderType = accountCreationDialogResult.ProviderType, ProviderType = accountCreationDialogResult.ProviderType,
Name = accountCreationDialogResult.AccountName, Name = accountCreationDialogResult.AccountName,
AccountColorHex = accountCreationDialogResult.AccountColorHex, SpecialImapProvider = accountCreationDialogResult.SpecialImapProviderDetails?.SpecialImapProvider ?? SpecialImapProvider.None,
Id = Guid.NewGuid() Id = Guid.NewGuid()
}; };
creationDialog.ShowDialog(accountCreationCancellationTokenSource); await creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource);
creationDialog.State = AccountCreationDialogState.SigningIn; creationDialog.State = AccountCreationDialogState.SigningIn;
string tokenInformation = string.Empty; string tokenInformation = string.Empty;
// Custom server implementation requires more async waiting. // Custom server implementation requires more async waiting.
if (creationDialog is ICustomServerAccountCreationDialog customServerDialog) if (creationDialog is IImapAccountCreationDialog customServerDialog)
{ {
// Pass along the account properties and perform initial navigation on the imap frame. // Pass along the account properties and perform initial navigation on the imap frame.
customServerDialog.StartImapConnectionSetup(createdAccount); customServerDialog.StartImapConnectionSetup(createdAccount);
@@ -130,20 +137,39 @@ namespace Wino.Mail.ViewModels
} }
else else
{ {
// OAuth authentication is handled here. // Hanle special imap providers like iCloud and Yahoo.
// Server authenticates, returns the token info here. if (accountCreationDialogResult.SpecialImapProviderDetails != null)
{
// Special imap provider testing dialog. This is only available for iCloud and Yahoo.
customServerInformation = _specialImapProviderConfigResolver.GetServerInformation(createdAccount, accountCreationDialogResult);
customServerInformation.Id = Guid.NewGuid();
customServerInformation.AccountId = createdAccount.Id;
var tokenInformationResponse = await WinoServerConnectionManager createdAccount.SenderName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName;
.GetResponseAsync<TokenInformationEx, AuthorizationRequested>(new AuthorizationRequested(accountCreationDialogResult.ProviderType, createdAccount.Address = customServerInformation.Address;
createdAccount,
createdAccount.ProviderType == MailProviderType.Gmail), accountCreationCancellationTokenSource.Token);
if (creationDialog.State == AccountCreationDialogState.Canceled) await _imapTestService.TestImapConnectionAsync(customServerInformation, true);
throw new AccountSetupCanceledException(); }
else
{
// OAuth authentication is handled here.
// Server authenticates, returns the token info here.
createdAccount.Address = tokenInformationResponse.Data.AccountAddress; var tokenInformationResponse = await WinoServerConnectionManager
.GetResponseAsync<TokenInformationEx, AuthorizationRequested>(new AuthorizationRequested(accountCreationDialogResult.ProviderType,
createdAccount,
createdAccount.ProviderType == MailProviderType.Gmail), accountCreationCancellationTokenSource.Token);
tokenInformationResponse.ThrowIfFailed(); if (creationDialog.State == AccountCreationDialogState.Canceled)
throw new AccountSetupCanceledException();
if (!tokenInformationResponse.IsSuccess)
throw new Exception(tokenInformationResponse.Message);
createdAccount.Address = tokenInformationResponse.Data.AccountAddress;
tokenInformationResponse.ThrowIfFailed();
}
} }
// Address is still doesn't have a value for API synchronizers. // Address is still doesn't have a value for API synchronizers.
@@ -183,7 +209,7 @@ namespace Wino.Mail.ViewModels
await AccountService.UpdateProfileInformationAsync(createdAccount.Id, profileSynchronizationResult.ProfileInformation); await AccountService.UpdateProfileInformationAsync(createdAccount.Id, profileSynchronizationResult.ProfileInformation);
} }
if (creationDialog is ICustomServerAccountCreationDialog customServerAccountCreationDialog) if (creationDialog is IImapAccountCreationDialog customServerAccountCreationDialog)
customServerAccountCreationDialog.ShowPreparingFolders(); customServerAccountCreationDialog.ShowPreparingFolders();
else else
creationDialog.State = AccountCreationDialogState.PreparingFolders; creationDialog.State = AccountCreationDialogState.PreparingFolders;
@@ -227,14 +253,6 @@ namespace Wino.Mail.ViewModels
await AccountService.CreateRootAliasAsync(createdAccount.Id, createdAccount.Address); await AccountService.CreateRootAliasAsync(createdAccount.Id, createdAccount.Address);
} }
// TODO: Temporary disabled. Is this even needed? Users can configure special folders manually later on if discovery fails.
// Check if Inbox folder is available for the account after synchronization.
//var isInboxAvailable = await _folderService.IsInboxAvailableForAccountAsync(createdAccount.Id);
//if (!isInboxAvailable)
// throw new Exception(Translator.Exception_InboxNotAvailable);
// Send changes to listeners. // Send changes to listeners.
ReportUIChange(new AccountCreatedMessage(createdAccount)); ReportUIChange(new AccountCreatedMessage(createdAccount));
@@ -250,6 +268,10 @@ namespace Wino.Mail.ViewModels
{ {
// Ignore // Ignore
} }
catch (ImapClientPoolException clientPoolException)
{
DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, clientPoolException.InnerException.Message, InfoBarMessageType.Error);
}
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, WinoErrors.AccountCreation); Log.Error(ex, WinoErrors.AccountCreation);

View File

@@ -243,7 +243,9 @@ namespace Wino.Mail.ViewModels
await RecreateMenuItemsAsync(); await RecreateMenuItemsAsync();
await ProcessLaunchOptionsAsync(); await ProcessLaunchOptionsAsync();
#if !DEBUG
await ForceAllAccountSynchronizationsAsync(); await ForceAllAccountSynchronizationsAsync();
#endif
await MakeSureEnableStartupLaunchAsync(); await MakeSureEnableStartupLaunchAsync();
await ConfigureBackgroundTasksAsync(); await ConfigureBackgroundTasksAsync();
} }
@@ -878,6 +880,8 @@ namespace Wino.Mail.ViewModels
protected override async void OnAccountCreated(MailAccount createdAccount) protected override async void OnAccountCreated(MailAccount createdAccount)
{ {
latestSelectedAccountMenuItem = null;
await RecreateMenuItemsAsync(); await RecreateMenuItemsAsync();
if (!MenuItems.TryGetAccountMenuItem(createdAccount.Id, out IAccountMenuItem createdMenuItem)) return; if (!MenuItems.TryGetAccountMenuItem(createdAccount.Id, out IAccountMenuItem createdMenuItem)) return;

View File

@@ -233,6 +233,7 @@ namespace Wino
} }
catch (WinoServerException serverException) catch (WinoServerException serverException)
{ {
// TODO: Exception context is lost.
var dialogService = Services.GetService<IMailDialogService>(); var dialogService = Services.GetService<IMailDialogService>();
dialogService.InfoBarMessage(Translator.Info_SyncFailedTitle, serverException.Message, InfoBarMessageType.Error); dialogService.InfoBarMessage(Translator.Info_SyncFailedTitle, serverException.Message, InfoBarMessageType.Error);
@@ -241,6 +242,8 @@ namespace Wino
protected override async void OnApplicationCloseRequested(object sender, SystemNavigationCloseRequestedPreviewEventArgs e) protected override async void OnApplicationCloseRequested(object sender, SystemNavigationCloseRequestedPreviewEventArgs e)
{ {
Log.Information("App close requested.");
var deferral = e.GetDeferral(); var deferral = e.GetDeferral();
// Wino should notify user on app close if: // Wino should notify user on app close if:

View File

@@ -41,7 +41,7 @@
</TransitionCollection> </TransitionCollection>
</coreControls:WinoNavigationViewItem.ContentTransitions> </coreControls:WinoNavigationViewItem.ContentTransitions>
<muxc:NavigationViewItem.Icon> <muxc:NavigationViewItem.Icon>
<coreControls:WinoFontIcon FontSize="12" Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(Parameter.ProviderType)}" /> <coreControls:WinoFontIcon FontSize="12" Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(Parameter)}" />
</muxc:NavigationViewItem.Icon> </muxc:NavigationViewItem.Icon>
<muxc:NavigationViewItem.InfoBadge> <muxc:NavigationViewItem.InfoBadge>
<muxc:InfoBadge <muxc:InfoBadge

View File

@@ -34,7 +34,7 @@
Grid.RowSpan="2" Grid.RowSpan="2"
VerticalAlignment="Center" VerticalAlignment="Center"
FontSize="24" FontSize="24"
Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(ProviderDetail.Type)}" /> Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(ProviderDetail.Type, ProviderDetail.SpecialImapProvider)}" />
<TextBlock <TextBlock
Grid.Column="1" Grid.Column="1"
FontWeight="SemiBold" FontWeight="SemiBold"

View File

@@ -7,6 +7,7 @@ using Windows.UI.Xaml.Media.Animation;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Messaging.Client.Mails; using Wino.Messaging.Client.Mails;
using Wino.Views.ImapSetup; using Wino.Views.ImapSetup;
@@ -24,10 +25,10 @@ namespace Wino.Dialogs
IRecipient<ImapSetupNavigationRequested>, IRecipient<ImapSetupNavigationRequested>,
IRecipient<ImapSetupBackNavigationRequested>, IRecipient<ImapSetupBackNavigationRequested>,
IRecipient<ImapSetupDismissRequested>, IRecipient<ImapSetupDismissRequested>,
ICustomServerAccountCreationDialog IImapAccountCreationDialog
{ {
private TaskCompletionSource<CustomServerInformation> _getServerInfoTaskCompletionSource = new TaskCompletionSource<CustomServerInformation>(); private TaskCompletionSource<CustomServerInformation> _getServerInfoTaskCompletionSource = new TaskCompletionSource<CustomServerInformation>();
private TaskCompletionSource<bool> dialogOpened = new TaskCompletionSource<bool>();
private bool isDismissRequested = false; private bool isDismissRequested = false;
public NewImapSetupDialog() public NewImapSetupDialog()
@@ -77,8 +78,21 @@ namespace Wino.Dialogs
public void Receive(ImapSetupDismissRequested message) => _getServerInfoTaskCompletionSource.TrySetResult(message.CompletedServerInformation); public void Receive(ImapSetupDismissRequested message) => _getServerInfoTaskCompletionSource.TrySetResult(message.CompletedServerInformation);
public void ShowDialog(CancellationTokenSource cancellationTokenSource) public async Task ShowDialogAsync(CancellationTokenSource cancellationTokenSource)
=> _ = ShowAsync(); {
Opened += DialogOpened;
_ = ShowAsync();
await dialogOpened.Task;
}
private void DialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args)
{
Opened -= DialogOpened;
dialogOpened?.SetResult(true);
}
public void ShowPreparingFolders() public void ShowPreparingFolders()
{ {
@@ -86,7 +100,7 @@ namespace Wino.Dialogs
} }
public void StartImapConnectionSetup(MailAccount account) => ImapFrame.Navigate(typeof(WelcomeImapSetupPage), account, new DrillInNavigationTransitionInfo()); public void StartImapConnectionSetup(MailAccount account) => ImapFrame.Navigate(typeof(WelcomeImapSetupPage), account, new DrillInNavigationTransitionInfo());
public void StartImapConnectionSetup(AccountCreationDialogResult accountCreationDialogResult) => ImapFrame.Navigate(typeof(WelcomeImapSetupPage), accountCreationDialogResult, new DrillInNavigationTransitionInfo());
private void ImapSetupDialogClosed(ContentDialog sender, ContentDialogClosedEventArgs args) => WeakReferenceMessenger.Default.UnregisterAll(this); private void ImapSetupDialogClosed(ContentDialog sender, ContentDialogClosedEventArgs args) => WeakReferenceMessenger.Default.UnregisterAll(this);

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Controls;
using Wino.Controls;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.UWP.Controls; using Wino.Core.UWP.Controls;
using Wino.Helpers; using Wino.Helpers;
@@ -22,7 +21,7 @@ namespace Wino.MenuFlyouts
foreach (var account in _accounts) foreach (var account in _accounts)
{ {
var pathData = new WinoFontIcon() { Icon = XamlHelpers.GetProviderIcon(account.ProviderType) }; var pathData = new WinoFontIcon() { Icon = XamlHelpers.GetProviderIcon(account) };
var menuItem = new MenuFlyoutItem() { Tag = account.Address, Icon = pathData, Text = $"{account.Name} ({account.Address})", MinHeight = 55 }; var menuItem = new MenuFlyoutItem() { Tag = account.Address, Icon = pathData, Text = $"{account.Name} ({account.Address})", MinHeight = 55 };
menuItem.Click += AccountClicked; menuItem.Click += AccountClicked;

View File

@@ -10,6 +10,7 @@ using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.UWP.Extensions; using Wino.Core.UWP.Extensions;
@@ -30,18 +31,27 @@ namespace Wino.Services
} }
public override IAccountCreationDialog GetAccountCreationDialog(MailProviderType type) public override IAccountCreationDialog GetAccountCreationDialog(AccountCreationDialogResult accountCreationDialogResult)
{ {
if (type == MailProviderType.IMAP4) if (accountCreationDialogResult.SpecialImapProviderDetails == null)
{ {
return new NewImapSetupDialog if (accountCreationDialogResult.ProviderType == MailProviderType.IMAP4)
{ {
RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme() return new NewImapSetupDialog
}; {
RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme()
};
}
else
{
return base.GetAccountCreationDialog(accountCreationDialogResult);
}
} }
else else
{ {
return base.GetAccountCreationDialog(type); // Special IMAP provider like iCloud or Yahoo.
return base.GetAccountCreationDialog(accountCreationDialogResult);
} }
} }

View File

@@ -20,20 +20,15 @@ namespace Wino.Mail.Services
public List<IProviderDetail> GetAvailableProviders() public List<IProviderDetail> GetAvailableProviders()
{ {
var providerList = new List<IProviderDetail>(); var providerList = new List<IProviderDetail>
var providers = new MailProviderType[]
{ {
MailProviderType.Outlook, new ProviderDetail(MailProviderType.Outlook, SpecialImapProvider.None),
MailProviderType.Gmail, new ProviderDetail(MailProviderType.Gmail, SpecialImapProvider.None),
MailProviderType.IMAP4 new ProviderDetail(MailProviderType.IMAP4, SpecialImapProvider.iCloud),
new ProviderDetail(MailProviderType.IMAP4, SpecialImapProvider.Yahoo),
new ProviderDetail(MailProviderType.IMAP4, SpecialImapProvider.None)
}; };
foreach (var type in providers)
{
providerList.Add(new ProviderDetail(type));
}
return providerList; return providerList;
} }
} }

View File

@@ -24,7 +24,7 @@
Header="{x:Bind Account.Name}" Header="{x:Bind Account.Name}"
IsClickEnabled="True"> IsClickEnabled="True">
<controls:SettingsCard.HeaderIcon> <controls:SettingsCard.HeaderIcon>
<coreControls:WinoFontIcon FontSize="64" Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(ProviderDetail.Type)}" /> <coreControls:WinoFontIcon FontSize="64" Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(Account)}" />
</controls:SettingsCard.HeaderIcon> </controls:SettingsCard.HeaderIcon>
<controls:SettingsCard.ActionIcon> <controls:SettingsCard.ActionIcon>
<PathIcon <PathIcon
@@ -45,7 +45,7 @@
Header="{x:Bind Account.Name}" Header="{x:Bind Account.Name}"
IsClickEnabled="True"> IsClickEnabled="True">
<controls:SettingsCard.HeaderIcon> <controls:SettingsCard.HeaderIcon>
<coreControls:WinoFontIcon FontSize="64" Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(ProviderDetail.Type)}" /> <coreControls:WinoFontIcon FontSize="64" Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(Account)}" />
</controls:SettingsCard.HeaderIcon> </controls:SettingsCard.HeaderIcon>
<controls:SettingsCard.ActionIcon> <controls:SettingsCard.ActionIcon>

View File

@@ -9,6 +9,7 @@ using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.AutoDiscovery; using Wino.Core.Domain.Models.AutoDiscovery;
using Wino.Messaging.Client.Mails; using Wino.Messaging.Client.Mails;
@@ -35,6 +36,10 @@ namespace Wino.Views.ImapSetup
{ {
DisplayNameBox.Text = accountProperties.Name; DisplayNameBox.Text = accountProperties.Name;
} }
else if (e.Parameter is AccountCreationDialogResult creationDialogResult)
{
WeakReferenceMessenger.Default.Send(new ImapSetupNavigationRequested(typeof(TestingImapConnectionPage), creationDialogResult));
}
} }
private async void SignInClicked(object sender, RoutedEventArgs e) private async void SignInClicked(object sender, RoutedEventArgs e)

View File

@@ -422,6 +422,7 @@
<!-- Mail Items --> <!-- Mail Items -->
<muxc:RefreshContainer <muxc:RefreshContainer
Grid.Row="2" Grid.Row="2"
Margin="-4,0"
RefreshRequested="PullToRefreshRequested" RefreshRequested="PullToRefreshRequested"
Visibility="{x:Bind ViewModel.IsEmpty, Converter={StaticResource ReverseBooleanToVisibilityConverter}, Mode=OneWay}"> Visibility="{x:Bind ViewModel.IsEmpty, Converter={StaticResource ReverseBooleanToVisibilityConverter}, Mode=OneWay}">
<SemanticZoom x:Name="SemanticZoomContainer" CanChangeViews="{x:Bind ViewModel.PreferencesService.IsSemanticZoomEnabled, Mode=OneWay}"> <SemanticZoom x:Name="SemanticZoomContainer" CanChangeViews="{x:Bind ViewModel.PreferencesService.IsSemanticZoomEnabled, Mode=OneWay}">
@@ -444,7 +445,7 @@
</ListView.ItemContainerTransitions> </ListView.ItemContainerTransitions>
<ListView.ItemsPanel> <ListView.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<ItemsStackPanel AreStickyGroupHeadersEnabled="True" /> <ItemsStackPanel Margin="8,0,12,0" AreStickyGroupHeadersEnabled="True" />
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ListView.ItemsPanel> </ListView.ItemsPanel>
<ListView.Resources> <ListView.Resources>
@@ -466,13 +467,13 @@
<Style TargetType="ListViewItem"> <Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" /> <Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" /> <Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0,12" /> <Setter Property="Margin" Value="12" />
<Setter Property="Padding" Value="0" /> <Setter Property="Padding" Value="0" />
</Style> </Style>
</ListView.Resources> </ListView.Resources>
<ListView.ItemTemplate> <ListView.ItemTemplate>
<DataTemplate x:DataType="ICollectionViewGroup"> <DataTemplate x:DataType="ICollectionViewGroup">
<Grid Background="{ThemeResource MailListHeaderBackgroundColor}" CornerRadius="4"> <Grid Background="{ThemeResource MailListHeaderBackgroundColor}" CornerRadius="6">
<TextBlock <TextBlock
Margin="12,0" Margin="12,0"
HorizontalAlignment="Center" HorizontalAlignment="Center"

View File

@@ -0,0 +1,11 @@
using System;
using Wino.Core.Domain.Interfaces;
namespace Wino.Messaging.Server
{
/// <summary>
/// Client message that requests to kill the account synchronizer.
/// </summary>
/// <param name="AccountId">Account id to kill synchronizer for.</param>
public record KillAccountSynchronizerRequested(Guid AccountId) : IClientMessage;
}

View File

@@ -8,7 +8,7 @@ namespace Wino.Messaging.Server
/// Triggers a new mail synchronization if possible. /// Triggers a new mail synchronization if possible.
/// </summary> /// </summary>
/// <param name="Options">Options for synchronization.</param> /// <param name="Options">Options for synchronization.</param>
public record NewMailSynchronizationRequested(MailSynchronizationOptions Options, SynchronizationSource Source) : IClientMessage; public record NewMailSynchronizationRequested(MailSynchronizationOptions Options, SynchronizationSource Source) : IClientMessage, IUIMessage;
/// <summary> /// <summary>
/// Triggers a new calendar synchronization if possible. /// Triggers a new calendar synchronization if possible.

View File

@@ -11,7 +11,7 @@
<Identity <Identity
Name="58272BurakKSE.WinoMailPreview" Name="58272BurakKSE.WinoMailPreview"
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911" Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
Version="1.9.43.0" /> Version="1.9.50.0" />
<Extensions> <Extensions>
<!-- Publisher Cache Folders --> <!-- Publisher Cache Folders -->

View File

@@ -48,12 +48,13 @@
<EntryPointProjectUniqueName>..\Wino.Mail\Wino.Mail.csproj</EntryPointProjectUniqueName> <EntryPointProjectUniqueName>..\Wino.Mail\Wino.Mail.csproj</EntryPointProjectUniqueName>
<GenerateAppInstallerFile>False</GenerateAppInstallerFile> <GenerateAppInstallerFile>False</GenerateAppInstallerFile>
<AppxPackageSigningTimestampDigestAlgorithm>SHA256</AppxPackageSigningTimestampDigestAlgorithm> <AppxPackageSigningTimestampDigestAlgorithm>SHA256</AppxPackageSigningTimestampDigestAlgorithm>
<AppxAutoIncrementPackageRevision>False</AppxAutoIncrementPackageRevision> <AppxAutoIncrementPackageRevision>True</AppxAutoIncrementPackageRevision>
<AppxPackageDir>$(USERPROFILE)\Desktop\Packages\</AppxPackageDir> <AppxPackageDir>$(USERPROFILE)\Desktop\Packages\</AppxPackageDir>
<GenerateTestArtifacts>True</GenerateTestArtifacts> <GenerateTestArtifacts>True</GenerateTestArtifacts>
<AppxBundlePlatforms>x86|x64|arm64</AppxBundlePlatforms> <AppxBundlePlatforms>x64</AppxBundlePlatforms>
<GenerateTemporaryStoreCertificate>True</GenerateTemporaryStoreCertificate> <GenerateTemporaryStoreCertificate>True</GenerateTemporaryStoreCertificate>
<HoursBetweenUpdateChecks>0</HoursBetweenUpdateChecks> <HoursBetweenUpdateChecks>0</HoursBetweenUpdateChecks>
<AppxSymbolPackageEnabled>True</AppxSymbolPackageEnabled>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<AppxBundle>Always</AppxBundle> <AppxBundle>Always</AppxBundle>

View File

@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using System.Windows; using System.Windows;
using H.NotifyIcon; using H.NotifyIcon;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Windows.Storage; using Windows.Storage;
using Wino.Calendar.Services; using Wino.Calendar.Services;
using Wino.Core; using Wino.Core;
@@ -188,6 +189,9 @@ namespace Wino.Server
if (isCreatedNew) if (isCreatedNew)
{ {
AppDomain.CurrentDomain.UnhandledException += ServerCrashed;
Application.Current.DispatcherUnhandledException += UIThreadCrash;
TaskScheduler.UnobservedTaskException += TaskCrashed;
// Ensure proper encodings are available for MimeKit // Ensure proper encodings are available for MimeKit
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
@@ -237,6 +241,12 @@ namespace Wino.Server
} }
} }
private void TaskCrashed(object sender, UnobservedTaskExceptionEventArgs e) => Log.Error(e.Exception, "Task crashed.");
private void UIThreadCrash(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e) => Log.Error(e.Exception, "UI thread crashed.");
private void ServerCrashed(object sender, UnhandledExceptionEventArgs e) => Log.Error((Exception)e.ExceptionObject, "Server crashed.");
protected override void OnExit(ExitEventArgs e) protected override void OnExit(ExitEventArgs e)
{ {
notifyIcon?.Dispose(); notifyIcon?.Dispose();

View File

@@ -22,6 +22,7 @@ namespace Wino.Server.Core
nameof(ServerTerminationModeChanged) => App.Current.Services.GetService<ServerTerminationModeHandler>(), nameof(ServerTerminationModeChanged) => App.Current.Services.GetService<ServerTerminationModeHandler>(),
nameof(TerminateServerRequested) => App.Current.Services.GetService<TerminateServerRequestHandler>(), nameof(TerminateServerRequested) => App.Current.Services.GetService<TerminateServerRequestHandler>(),
nameof(ImapConnectivityTestRequested) => App.Current.Services.GetService<ImapConnectivityTestHandler>(), nameof(ImapConnectivityTestRequested) => App.Current.Services.GetService<ImapConnectivityTestHandler>(),
nameof(KillAccountSynchronizerRequested) => App.Current.Services.GetService<KillAccountSynchronizerHandler>(),
_ => throw new Exception($"Server handler for {typeName} is not registered."), _ => throw new Exception($"Server handler for {typeName} is not registered."),
}; };
} }
@@ -39,6 +40,7 @@ namespace Wino.Server.Core
serviceCollection.AddTransient<ServerTerminationModeHandler>(); serviceCollection.AddTransient<ServerTerminationModeHandler>();
serviceCollection.AddTransient<TerminateServerRequestHandler>(); serviceCollection.AddTransient<TerminateServerRequestHandler>();
serviceCollection.AddTransient<ImapConnectivityTestHandler>(); serviceCollection.AddTransient<ImapConnectivityTestHandler>();
serviceCollection.AddTransient<KillAccountSynchronizerHandler>();
} }
} }
} }

View File

@@ -0,0 +1,30 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Server;
using Wino.Messaging.Server;
using Wino.Server.Core;
namespace Wino.Server.MessageHandlers
{
public class KillAccountSynchronizerHandler : ServerMessageHandler<KillAccountSynchronizerRequested, bool>
{
private readonly ISynchronizerFactory _synchronizerFactory;
public override WinoServerResponse<bool> FailureDefaultResponse(Exception ex)
=> WinoServerResponse<bool>.CreateErrorResponse(ex.Message);
public KillAccountSynchronizerHandler(ISynchronizerFactory synchronizerFactory)
{
_synchronizerFactory = synchronizerFactory;
}
protected override async Task<WinoServerResponse<bool>> HandleAsync(KillAccountSynchronizerRequested message, CancellationToken cancellationToken = default)
{
await _synchronizerFactory.DeleteSynchronizerAsync(message.AccountId);
return WinoServerResponse<bool>.CreateSuccessResponse(true);
}
}
}

View File

@@ -46,6 +46,7 @@ namespace Wino.Server.MessageHandlers
bool shouldReportSynchronizationResult = bool shouldReportSynchronizationResult =
message.Options.Type != MailSynchronizationType.ExecuteRequests && message.Options.Type != MailSynchronizationType.ExecuteRequests &&
message.Options.Type != MailSynchronizationType.IMAPIdle &&
message.Source == SynchronizationSource.Client; message.Source == SynchronizationSource.Client;
try try
@@ -64,6 +65,12 @@ namespace Wino.Server.MessageHandlers
var isSynchronizationSucceeded = synchronizationResult.CompletedState == SynchronizationCompletedState.Success; var isSynchronizationSucceeded = synchronizationResult.CompletedState == SynchronizationCompletedState.Success;
// IDLE requests might be canceled successfully.
if (message.Options.Type == MailSynchronizationType.IMAPIdle && synchronizationResult.CompletedState == SynchronizationCompletedState.Canceled)
{
isSynchronizationSucceeded = true;
}
// Update badge count of the notification task. // Update badge count of the notification task.
if (isSynchronizationSucceeded) if (isSynchronizationSucceeded)
{ {

View File

@@ -42,7 +42,8 @@ namespace Wino.Server
IRecipient<ServerTerminationModeChanged>, IRecipient<ServerTerminationModeChanged>,
IRecipient<AccountSynchronizationProgressUpdatedMessage>, IRecipient<AccountSynchronizationProgressUpdatedMessage>,
IRecipient<AccountFolderConfigurationUpdated>, IRecipient<AccountFolderConfigurationUpdated>,
IRecipient<CopyAuthURLRequested> IRecipient<CopyAuthURLRequested>,
IRecipient<NewMailSynchronizationRequested>
{ {
private readonly System.Timers.Timer _timer; private readonly System.Timers.Timer _timer;
private static object connectionLock = new object(); private static object connectionLock = new object();
@@ -140,6 +141,7 @@ namespace Wino.Server
public async void Receive(AccountFolderConfigurationUpdated message) => await SendMessageAsync(MessageType.UIMessage, message); public async void Receive(AccountFolderConfigurationUpdated message) => await SendMessageAsync(MessageType.UIMessage, message);
public async void Receive(CopyAuthURLRequested message) => await SendMessageAsync(MessageType.UIMessage, message); public async void Receive(CopyAuthURLRequested message) => await SendMessageAsync(MessageType.UIMessage, message);
public async void Receive(NewMailSynchronizationRequested message) => await SendMessageAsync(MessageType.UIMessage, message);
#endregion #endregion
@@ -321,6 +323,9 @@ namespace Wino.Server
KillServer(); KillServer();
break; break;
case nameof(KillAccountSynchronizerRequested):
await ExecuteServerMessageSafeAsync(args, JsonSerializer.Deserialize<KillAccountSynchronizerRequested>(messageJson, _jsonSerializerOptions));
break;
default: default:
Debug.WriteLine($"Missing handler for {typeName} in the server. Check ServerContext.cs - HandleServerMessageAsync."); Debug.WriteLine($"Missing handler for {typeName} in the server. Check ServerContext.cs - HandleServerMessageAsync.");
break; break;

View File

@@ -319,6 +319,8 @@ namespace Wino.Services
} }
} }
ReportUIChange(new AccountRemovedMessage(account)); ReportUIChange(new AccountRemovedMessage(account));
} }
@@ -502,7 +504,7 @@ namespace Wino.Services
account.Preferences = preferences; account.Preferences = preferences;
// Outlook & Office 365 supports Focused inbox. Enabled by default. // Outlook & Office 365 supports Focused inbox. Enabled by default.
bool isMicrosoftProvider = account.ProviderType == MailProviderType.Outlook || account.ProviderType == MailProviderType.Office365; bool isMicrosoftProvider = account.ProviderType == MailProviderType.Outlook;
// TODO: This should come from account settings API. // TODO: This should come from account settings API.
// Wino doesn't have MailboxSettings yet. // Wino doesn't have MailboxSettings yet.

View File

@@ -5,7 +5,6 @@ using MimeKit;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Services.Extensions;
namespace Wino.Services.Extensions namespace Wino.Services.Extensions
{ {
@@ -22,6 +21,9 @@ namespace Wino.Services.Extensions
throw new ArgumentOutOfRangeException(nameof(mailCopyId), mailCopyId, "Invalid mailCopyId format."); throw new ArgumentOutOfRangeException(nameof(mailCopyId), mailCopyId, "Invalid mailCopyId format.");
} }
public static UniqueId ResolveUidStruct(string mailCopyId)
=> new UniqueId(ResolveUid(mailCopyId));
public static string CreateUid(Guid folderId, uint messageUid) public static string CreateUid(Guid folderId, uint messageUid)
=> $"{folderId}{MailCopyUidSeparator}{messageUid}"; => $"{folderId}{MailCopyUidSeparator}{messageUid}";

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