Files
Wino-Mail/Wino.Core/Integration/ImapClientPool.cs

369 lines
13 KiB
C#
Raw Normal View History

2024-04-18 01:44:37 +02:00
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text;
2024-04-18 01:44:37 +02:00
using System.Threading;
using System.Threading.Tasks;
using MailKit;
2024-04-18 01:44:37 +02:00
using MailKit.Net.Imap;
using MailKit.Net.Proxy;
using MailKit.Security;
using MimeKit.Cryptography;
using MoreLinq;
2024-04-18 01:44:37 +02:00
using Serilog;
using Wino.Core.Domain.Entities.Shared;
2024-04-18 01:44:37 +02:00
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
2024-09-29 21:21:51 +02:00
using Wino.Core.Domain.Models.Connectivity;
2024-04-18 01:44:37 +02:00
namespace Wino.Core.Integration
{
/// <summary>
/// Provides a pooling mechanism for ImapClient.
/// Makes sure that we don't have too many connections to the server.
/// Rents a connected & authenticated client from the pool all the time.
/// </summary>
/// <param name="customServerInformation">Connection/Authentication info to be used to configure ImapClient.</param>
public class ImapClientPool : IDisposable
2024-04-18 01:44:37 +02:00
{
// Hardcoded implementation details for ID extension if the server supports.
// Some providers like Chinese 126 require Id to be sent before authentication.
// We don't expose any customer data here. Therefore it's safe for now.
// Later on maybe we can make it configurable and leave it to the user with passing
// real implementation details.
private readonly ImapImplementation _implementation = new()
2024-04-18 01:44:37 +02:00
{
Version = "1.8.0",
2024-04-18 01:44:37 +02:00
OS = "Windows",
Vendor = "Wino",
SupportUrl = "https://www.winomail.app",
Name = "Wino Mail User",
2024-04-18 01:44:37 +02:00
};
public bool ThrowOnSSLHandshakeCallback { get; set; }
2024-09-29 21:21:51 +02:00
public ImapClientPoolOptions ImapClientPoolOptions { get; }
2025-02-15 12:53:32 +01:00
internal WinoImapClient IdleClient { get; set; }
private readonly int MinimumPoolSize = 5;
2024-04-18 01:44:37 +02:00
2025-02-15 12:53:32 +01:00
private readonly ConcurrentStack<IImapClient> _clients = [];
private readonly SemaphoreSlim _semaphore;
2024-04-18 01:44:37 +02:00
private readonly CustomServerInformation _customServerInformation;
private readonly Stream _protocolLogStream;
2024-04-18 01:44:37 +02:00
private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
2025-02-15 12:53:32 +01:00
private bool _disposedValue;
2024-04-18 01:44:37 +02:00
2024-09-29 21:21:51 +02:00
public ImapClientPool(ImapClientPoolOptions imapClientPoolOptions)
2024-04-18 01:44:37 +02:00
{
2024-09-29 21:21:51 +02:00
_customServerInformation = imapClientPoolOptions.ServerInformation;
_protocolLogStream = imapClientPoolOptions.ProtocolLog;
// Set the maximum pool size to 5 or the custom value if it's greater.
2024-09-29 21:21:51 +02:00
_semaphore = new(Math.Max(MinimumPoolSize, _customServerInformation.MaxConcurrentClients));
CryptographyContext.Register(typeof(WindowsSecureMimeContext));
2024-09-29 21:21:51 +02:00
ImapClientPoolOptions = imapClientPoolOptions;
2024-04-18 01:44:37 +02:00
}
/// <summary>
/// Ensures all supported capabilities are enabled in this connection.
/// Reconnects and reauthenticates if necessary.
/// </summary>
/// <param name="isCreatedNew">Whether the client has been newly created.</param>
2025-02-15 12:53:32 +01:00
private async Task EnsureCapabilitiesAsync(IImapClient client, bool isCreatedNew)
2024-04-18 01:44:37 +02:00
{
try
{
bool isReconnected = await EnsureConnectedAsync(client);
bool mustDoPostAuthIdentification = false;
if ((isCreatedNew || isReconnected) && client.IsConnected)
2024-04-18 01:44:37 +02:00
{
2025-02-15 12:53:32 +01:00
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
await client.CompressAsync();
2024-04-18 01:44:37 +02:00
// Identify if the server supports ID extension.
// Some servers require it pre-authentication, some post-authentication.
// We'll observe the response here and do it after authentication if needed.
2024-04-18 01:44:37 +02:00
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
{
try
{
await client.IdentifyAsync(_implementation);
}
catch (ImapCommandException commandException) when (commandException.Response == ImapCommandResponse.No || commandException.Response == ImapCommandResponse.Bad)
{
mustDoPostAuthIdentification = true;
}
catch (Exception)
{
throw;
}
}
2024-04-18 01:44:37 +02:00
}
await EnsureAuthenticatedAsync(client);
if ((isCreatedNew || isReconnected) && client.IsAuthenticated)
2024-04-18 01:44:37 +02:00
{
if (mustDoPostAuthIdentification) await client.IdentifyAsync(_implementation);
2024-04-18 01:44:37 +02:00
// Activate post-auth capabilities.
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
2025-02-15 12:53:32 +01:00
{
await client.EnableQuickResyncAsync().ConfigureAwait(false);
2025-02-15 12:53:32 +01:00
if (client is WinoImapClient winoImapClient) winoImapClient.IsQResyncEnabled = true;
}
2024-04-18 01:44:37 +02:00
}
}
catch (Exception ex)
{
if (ex.InnerException is ImapTestSSLCertificateException imapTestSSLCertificateException)
throw imapTestSSLCertificateException;
throw new ImapClientPoolException(ex, GetProtocolLogContent());
2024-04-18 01:44:37 +02:00
}
finally
{
// Release it even if it fails.
_semaphore.Release();
}
}
public string GetProtocolLogContent()
{
if (_protocolLogStream == null) return default;
// Set the position to the beginning of the stream in case it is not already at the start
if (_protocolLogStream.CanSeek)
_protocolLogStream.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(_protocolLogStream, Encoding.UTF8, true, 1024, leaveOpen: true);
return reader.ReadToEnd();
}
2025-02-15 12:53:32 +01:00
public async Task<IImapClient> GetClientAsync()
2024-04-18 01:44:37 +02:00
{
await _semaphore.WaitAsync();
2025-02-15 12:53:32 +01:00
if (_clients.TryPop(out IImapClient item))
2024-04-18 01:44:37 +02:00
{
await EnsureCapabilitiesAsync(item, false);
2024-04-18 01:44:37 +02:00
return item;
}
var client = CreateNewClient();
await EnsureCapabilitiesAsync(client, true);
2024-04-18 01:44:37 +02:00
return client;
}
2025-02-15 12:53:32 +01:00
public void Release(IImapClient item, bool destroyClient = false)
2024-04-18 01:44:37 +02:00
{
if (item != null)
{
if (destroyClient)
{
2025-02-15 12:53:32 +01:00
if (item.IsConnected)
{
2025-02-15 12:53:32 +01:00
lock (item.SyncRoot)
{
item.Disconnect(quit: true);
}
}
2025-02-15 12:53:32 +01:00
_clients.TryPop(out _);
item.Dispose();
}
2025-02-15 12:53:32 +01:00
else if (!_disposedValue)
{
_clients.Push(item);
}
2024-04-18 01:44:37 +02:00
_semaphore.Release();
}
}
2025-02-15 12:53:32 +01:00
private IImapClient CreateNewClient()
2024-04-18 01:44:37 +02:00
{
2025-02-15 12:53:32 +01:00
WinoImapClient client = null;
// Make sure to create a ImapClient with a protocol logger if enabled.
client = _protocolLogStream != null
2025-02-15 12:53:32 +01:00
? new WinoImapClient(new ProtocolLogger(_protocolLogStream))
: new WinoImapClient();
2024-04-18 01:44:37 +02:00
HttpProxyClient proxyClient = null;
// Add proxy client if exists.
if (!string.IsNullOrEmpty(_customServerInformation.ProxyServer))
{
proxyClient = new HttpProxyClient(_customServerInformation.ProxyServer, int.Parse(_customServerInformation.ProxyServerPort));
}
client.ProxyClient = proxyClient;
2025-02-15 12:53:32 +01:00
_logger.Debug("Creating new ImapClient. Current clients: {Count}", _clients.Count);
2024-04-18 01:44:37 +02:00
return client;
}
private SecureSocketOptions GetSocketOptions(ImapConnectionSecurity connectionSecurity)
=> connectionSecurity switch
{
ImapConnectionSecurity.Auto => SecureSocketOptions.Auto,
ImapConnectionSecurity.None => SecureSocketOptions.None,
ImapConnectionSecurity.StartTls => SecureSocketOptions.StartTlsWhenAvailable,
ImapConnectionSecurity.SslTls => SecureSocketOptions.SslOnConnect,
_ => SecureSocketOptions.None
};
/// <returns>True if the connection is newly established.</returns>
2025-02-15 12:53:32 +01:00
public async Task<bool> EnsureConnectedAsync(IImapClient client)
2024-04-18 01:44:37 +02:00
{
if (client.IsConnected) return false;
2024-04-18 01:44:37 +02:00
client.ServerCertificateValidationCallback = MyServerCertificateValidationCallback;
2024-04-18 01:44:37 +02:00
await client.ConnectAsync(_customServerInformation.IncomingServer,
int.Parse(_customServerInformation.IncomingServerPort),
GetSocketOptions(_customServerInformation.IncomingServerSocketOption));
2024-09-29 21:21:51 +02:00
// Print out useful information for testing.
if (client.IsConnected && ImapClientPoolOptions.IsTestPool)
{
// Print supported authentication methods for the client.
var supportedAuthMethods = client.AuthenticationMechanisms;
if (supportedAuthMethods == null || supportedAuthMethods.Count == 0)
{
WriteToProtocolLog("There are no supported authentication mechanisms...");
}
else
{
WriteToProtocolLog($"Supported authentication mechanisms: {string.Join(", ", supportedAuthMethods)}");
}
}
return true;
2024-09-29 21:21:51 +02:00
}
private void WriteToProtocolLog(string message)
{
if (_protocolLogStream == null) return;
2025-02-15 12:53:32 +01:00
try
{
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;
}
2024-04-18 01:44:37 +02:00
}
bool MyServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
// If there are no errors, then everything went smoothly.
if (sslPolicyErrors == SslPolicyErrors.None) return true;
// Imap connectivity test will throw to alert the user here.
if (ThrowOnSSLHandshakeCallback)
{
throw new ImapTestSSLCertificateException(certificate.Issuer, certificate.GetExpirationDateString(), certificate.GetEffectiveDateString());
}
return true;
}
2025-02-15 12:53:32 +01:00
public async Task EnsureAuthenticatedAsync(IImapClient client)
2024-04-18 01:44:37 +02:00
{
if (client.IsAuthenticated) return;
var cred = new NetworkCredential(_customServerInformation.IncomingServerUsername, _customServerInformation.IncomingServerPassword);
var prefferedAuthenticationMethod = _customServerInformation.IncomingAuthenticationMethod;
if (prefferedAuthenticationMethod != ImapAuthenticationMethod.Auto)
{
// Anything beside Auto must be explicitly set for the client.
client.AuthenticationMechanisms.Clear();
var saslMechanism = GetSASLAuthenticationMethodName(prefferedAuthenticationMethod);
client.AuthenticationMechanisms.Add(saslMechanism);
2024-09-29 21:21:51 +02:00
var mechanism = SaslMechanism.Create(saslMechanism, cred);
await client.AuthenticateAsync(SaslMechanism.Create(saslMechanism, cred));
}
else
2024-04-18 01:44:37 +02:00
{
await client.AuthenticateAsync(cred);
2024-04-18 01:44:37 +02:00
}
}
2024-04-18 01:44:37 +02:00
private string GetSASLAuthenticationMethodName(ImapAuthenticationMethod method)
{
return method switch
{
ImapAuthenticationMethod.NormalPassword => "PLAIN",
ImapAuthenticationMethod.EncryptedPassword => "LOGIN",
ImapAuthenticationMethod.Ntlm => "NTLM",
ImapAuthenticationMethod.CramMd5 => "CRAM-MD5",
ImapAuthenticationMethod.DigestMd5 => "DIGEST-MD5",
_ => "PLAIN"
};
2024-04-18 01:44:37 +02:00
}
2025-02-15 12:53:32 +01:00
protected virtual void Dispose(bool disposing)
{
2025-02-15 12:53:32 +01:00
if (!_disposedValue)
{
2025-02-15 12:53:32 +01:00
if (disposing)
{
2025-02-15 12:53:32 +01:00
_clients.ForEach(client =>
{
lock (client.SyncRoot)
{
client.Disconnect(true);
}
});
2025-02-15 12:53:32 +01:00
_clients.ForEach(client =>
{
client.Dispose();
});
2025-02-15 12:53:32 +01:00
_clients.Clear();
2025-02-15 12:53:32 +01:00
_protocolLogStream?.Dispose();
}
_disposedValue = true;
}
}
2025-02-15 12:53:32 +01:00
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
2024-04-18 01:44:37 +02:00
}
}