|
|
|
@@ -1,4 +1,4 @@
|
|
|
|
|
using System;
|
|
|
|
|
using System;
|
|
|
|
|
using System.Collections.Concurrent;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Net;
|
|
|
|
@@ -6,13 +6,12 @@ using System.Net.Security;
|
|
|
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Channels;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using System.Timers;
|
|
|
|
|
using MailKit.Net.Imap;
|
|
|
|
|
using MailKit.Net.Proxy;
|
|
|
|
|
using MailKit.Security;
|
|
|
|
|
using MimeKit.Cryptography;
|
|
|
|
|
using MoreLinq;
|
|
|
|
|
using Serilog;
|
|
|
|
|
using Wino.Core.Domain.Entities.Shared;
|
|
|
|
|
using Wino.Core.Domain.Enums;
|
|
|
|
@@ -20,19 +19,32 @@ using Wino.Core.Domain.Exceptions;
|
|
|
|
|
using Wino.Core.Domain.Models.Connectivity;
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
/// Connection state for tracking individual client health.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public enum ImapClientState
|
|
|
|
|
{
|
|
|
|
|
Available,
|
|
|
|
|
InUse,
|
|
|
|
|
Idle,
|
|
|
|
|
Reconnecting,
|
|
|
|
|
Failed,
|
|
|
|
|
Disposed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Provides an enhanced pooling mechanism for ImapClient with Channel-based async rental.
|
|
|
|
|
/// Maintains minimum active connections and a dedicated IDLE client.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="customServerInformation">Connection/Authentication info to be used to configure ImapClient.</param>
|
|
|
|
|
public class ImapClientPool : IDisposable
|
|
|
|
|
{
|
|
|
|
|
// 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 const int MinActiveConnections = 3;
|
|
|
|
|
private const int IdleConnectionReserved = 1;
|
|
|
|
|
private const int KeepAliveIntervalMs = 4 * 60 * 1000; // 4 minutes
|
|
|
|
|
private const int ConnectionMonitorIntervalMs = 30 * 1000; // 30 seconds
|
|
|
|
|
private const int MaintenanceIntervalMs = 60 * 1000; // 1 minute
|
|
|
|
|
|
|
|
|
|
private readonly ImapImplementation _implementation = new()
|
|
|
|
|
{
|
|
|
|
|
Version = "1.8.0",
|
|
|
|
@@ -42,180 +54,520 @@ public class ImapClientPool : IDisposable
|
|
|
|
|
Name = "Wino Mail User",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
|
|
|
|
|
private readonly CustomServerInformation _customServerInformation;
|
|
|
|
|
private readonly Stream _protocolLogStream;
|
|
|
|
|
private readonly ConcurrentDictionary<WinoImapClient, ImapClientState> _clientStates = new();
|
|
|
|
|
private readonly Channel<WinoImapClient> _availableClients;
|
|
|
|
|
private readonly CancellationTokenSource _maintenanceCts = new();
|
|
|
|
|
private readonly object _idleClientLock = new();
|
|
|
|
|
|
|
|
|
|
private WinoImapClient _dedicatedIdleClient;
|
|
|
|
|
private bool _disposedValue;
|
|
|
|
|
private bool _initialized;
|
|
|
|
|
private Task _maintenanceTask;
|
|
|
|
|
|
|
|
|
|
public bool ThrowOnSSLHandshakeCallback { get; set; }
|
|
|
|
|
public ImapClientPoolOptions ImapClientPoolOptions { get; }
|
|
|
|
|
|
|
|
|
|
private bool _disposedValue;
|
|
|
|
|
private readonly int MinimumPoolSize = 5;
|
|
|
|
|
private readonly ConcurrentStack<IImapClient> _clients = new();
|
|
|
|
|
private readonly SemaphoreSlim _semaphore;
|
|
|
|
|
private readonly CustomServerInformation _customServerInformation;
|
|
|
|
|
private readonly Stream _protocolLogStream;
|
|
|
|
|
private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
|
|
|
|
|
private readonly System.Timers.Timer _keepAliveTimer;
|
|
|
|
|
private readonly System.Timers.Timer _connectionMonitorTimer;
|
|
|
|
|
|
|
|
|
|
private const int KeepAliveInterval = 4 * 60 * 1000; // 4 minutes
|
|
|
|
|
private const int ConnectionMonitorInterval = 30 * 1000; // 30 seconds
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the current health status of the connection pool.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public ConnectionPoolHealth Health => GetHealthInternal();
|
|
|
|
|
|
|
|
|
|
public ImapClientPool(ImapClientPoolOptions imapClientPoolOptions)
|
|
|
|
|
{
|
|
|
|
|
_customServerInformation = imapClientPoolOptions.ServerInformation;
|
|
|
|
|
_protocolLogStream = imapClientPoolOptions.ProtocolLog;
|
|
|
|
|
|
|
|
|
|
// Set the maximum pool size to 5 or the custom value if it's greater.
|
|
|
|
|
_semaphore = new(Math.Max(MinimumPoolSize, _customServerInformation.MaxConcurrentClients));
|
|
|
|
|
|
|
|
|
|
CryptographyContext.Register(typeof(WindowsSecureMimeContext));
|
|
|
|
|
ImapClientPoolOptions = imapClientPoolOptions;
|
|
|
|
|
|
|
|
|
|
_keepAliveTimer = new System.Timers.Timer(KeepAliveInterval);
|
|
|
|
|
_connectionMonitorTimer = new System.Timers.Timer(ConnectionMonitorInterval);
|
|
|
|
|
CryptographyContext.Register(typeof(WindowsSecureMimeContext));
|
|
|
|
|
|
|
|
|
|
_keepAliveTimer.Elapsed += KeepAliveTimerElapsed;
|
|
|
|
|
_connectionMonitorTimer.Elapsed += ConnectionMonitorTimerElapsed;
|
|
|
|
|
// Create unbounded channel for available clients
|
|
|
|
|
_availableClients = Channel.CreateUnbounded<WinoImapClient>(new UnboundedChannelOptions
|
|
|
|
|
{
|
|
|
|
|
SingleReader = false,
|
|
|
|
|
SingleWriter = false,
|
|
|
|
|
AllowSynchronousContinuations = false
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task PreWarmPoolAsync()
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Initializes the pool by creating minimum connections and starting maintenance.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
if (_initialized) return;
|
|
|
|
|
|
|
|
|
|
_logger.Information("Initializing IMAP client pool with {MinConnections} connections", MinActiveConnections);
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
for (int i = 0; i < MinimumPoolSize; i++)
|
|
|
|
|
// Create initial connections
|
|
|
|
|
for (int i = 0; i < MinActiveConnections; i++)
|
|
|
|
|
{
|
|
|
|
|
var client = CreateNewClient();
|
|
|
|
|
await EnsureCapabilitiesAsync(client, true);
|
|
|
|
|
_clients.Push(client);
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
var client = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (client != null)
|
|
|
|
|
{
|
|
|
|
|
_clientStates[client] = ImapClientState.Available;
|
|
|
|
|
await _availableClients.Writer.WriteAsync(client, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start monitoring timers after pool is warmed
|
|
|
|
|
_keepAliveTimer.Start();
|
|
|
|
|
_connectionMonitorTimer.Start();
|
|
|
|
|
// Create dedicated IDLE client
|
|
|
|
|
_dedicatedIdleClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (_dedicatedIdleClient != null)
|
|
|
|
|
{
|
|
|
|
|
_clientStates[_dedicatedIdleClient] = ImapClientState.Idle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start maintenance task
|
|
|
|
|
_maintenanceTask = Task.Run(() => MaintenanceLoopAsync(_maintenanceCts.Token), _maintenanceCts.Token);
|
|
|
|
|
|
|
|
|
|
_initialized = true;
|
|
|
|
|
_logger.Information("IMAP client pool initialized. Health: {Health}", Health.Summary);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Error(ex, "Failed to pre-warm client pool");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async void KeepAliveTimerElapsed(object sender, ElapsedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
foreach (var client in _clients)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (client.IsConnected && !((WinoImapClient)client).IsBusy())
|
|
|
|
|
{
|
|
|
|
|
await SendNoOpAsync(client);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Warning(ex, "Failed to send NOOP to client");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async void ConnectionMonitorTimerElapsed(object sender, ElapsedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
foreach (var client in _clients)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (!client.IsConnected && !((WinoImapClient)client).IsBusy())
|
|
|
|
|
{
|
|
|
|
|
await EnsureCapabilitiesAsync(client, false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Warning(ex, "Failed to reconnect client");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task SendNoOpAsync(IImapClient client)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await client.NoOpAsync();
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Warning(ex, "NOOP command failed");
|
|
|
|
|
_logger.Error(ex, "Failed to initialize IMAP client pool");
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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>
|
|
|
|
|
private async Task EnsureCapabilitiesAsync(IImapClient client, bool isCreatedNew)
|
|
|
|
|
/// Pre-warms the pool (legacy compatibility method).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Task PreWarmPoolAsync() => InitializeAsync(CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Rents a client from the pool. Blocks until a client is available.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<WinoImapClient> RentAsync(CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
if (!_initialized)
|
|
|
|
|
await InitializeAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
bool isReconnected = await EnsureConnectedAsync(client);
|
|
|
|
|
bool mustDoPostAuthIdentification = false;
|
|
|
|
|
|
|
|
|
|
if ((isCreatedNew || isReconnected) && client.IsConnected)
|
|
|
|
|
// Try to get an available client from the channel
|
|
|
|
|
if (_availableClients.Reader.TryRead(out var client))
|
|
|
|
|
{
|
|
|
|
|
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
|
|
|
|
|
await client.CompressAsync();
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
|
|
|
|
|
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
|
|
|
|
|
if (client != null && _clientStates.TryGetValue(client, out var state) && state == ImapClientState.Available)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await client.IdentifyAsync(_implementation);
|
|
|
|
|
// Ensure client is still connected
|
|
|
|
|
await EnsureClientReadyAsync(client, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
_clientStates[client] = ImapClientState.InUse;
|
|
|
|
|
return client;
|
|
|
|
|
}
|
|
|
|
|
catch (ImapCommandException commandException) when (commandException.Response == ImapCommandResponse.No || commandException.Response == ImapCommandResponse.Bad)
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
mustDoPostAuthIdentification = true;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception)
|
|
|
|
|
{
|
|
|
|
|
throw;
|
|
|
|
|
_logger.Warning(ex, "Client from pool was not ready, marking as failed");
|
|
|
|
|
_clientStates[client] = ImapClientState.Failed;
|
|
|
|
|
// Continue to try next client or create new one
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await EnsureAuthenticatedAsync(client);
|
|
|
|
|
|
|
|
|
|
if ((isCreatedNew || isReconnected) && client.IsAuthenticated)
|
|
|
|
|
// No available client, try to create a new one
|
|
|
|
|
var newClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (newClient != null)
|
|
|
|
|
{
|
|
|
|
|
if (mustDoPostAuthIdentification) await client.IdentifyAsync(_implementation);
|
|
|
|
|
_clientStates[newClient] = ImapClientState.InUse;
|
|
|
|
|
return newClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Activate post-auth capabilities.
|
|
|
|
|
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
|
|
|
|
|
// Wait a bit before retrying
|
|
|
|
|
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new OperationCanceledException(cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets a client from the pool (legacy compatibility method).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<IImapClient> GetClientAsync() => await RentAsync(CancellationToken.None).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Returns a client to the pool.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void Return(WinoImapClient client, bool isFaulted = false)
|
|
|
|
|
{
|
|
|
|
|
if (client == null) return;
|
|
|
|
|
|
|
|
|
|
if (isFaulted || !client.IsConnected)
|
|
|
|
|
{
|
|
|
|
|
_clientStates[client] = ImapClientState.Failed;
|
|
|
|
|
DisposeClient(client);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!_disposedValue)
|
|
|
|
|
{
|
|
|
|
|
_clientStates[client] = ImapClientState.Available;
|
|
|
|
|
_availableClients.Writer.TryWrite(client);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
DisposeClient(client);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Releases a client (legacy compatibility method).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void Release(IImapClient item, bool destroyClient = false)
|
|
|
|
|
{
|
|
|
|
|
if (item is WinoImapClient winoClient)
|
|
|
|
|
{
|
|
|
|
|
Return(winoClient, destroyClient);
|
|
|
|
|
}
|
|
|
|
|
else if (item != null)
|
|
|
|
|
{
|
|
|
|
|
DisposeClient(item);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the dedicated IDLE client. Creates one if not available.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<WinoImapClient> GetIdleClientAsync(CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
lock (_idleClientLock)
|
|
|
|
|
{
|
|
|
|
|
if (_dedicatedIdleClient != null && _dedicatedIdleClient.IsConnected)
|
|
|
|
|
{
|
|
|
|
|
return _dedicatedIdleClient;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Need to create or reconnect IDLE client
|
|
|
|
|
var idleClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
lock (_idleClientLock)
|
|
|
|
|
{
|
|
|
|
|
if (_dedicatedIdleClient != null)
|
|
|
|
|
{
|
|
|
|
|
DisposeClient(_dedicatedIdleClient);
|
|
|
|
|
}
|
|
|
|
|
_dedicatedIdleClient = idleClient;
|
|
|
|
|
if (idleClient != null)
|
|
|
|
|
{
|
|
|
|
|
_clientStates[idleClient] = ImapClientState.Idle;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return idleClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Releases the IDLE client for reconnection.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void ReleaseIdleClient(bool isFaulted = false)
|
|
|
|
|
{
|
|
|
|
|
lock (_idleClientLock)
|
|
|
|
|
{
|
|
|
|
|
if (_dedicatedIdleClient != null)
|
|
|
|
|
{
|
|
|
|
|
if (isFaulted)
|
|
|
|
|
{
|
|
|
|
|
await client.EnableQuickResyncAsync().ConfigureAwait(false);
|
|
|
|
|
if (client is WinoImapClient winoImapClient) winoImapClient.IsQResyncEnabled = true;
|
|
|
|
|
_clientStates[_dedicatedIdleClient] = ImapClientState.Failed;
|
|
|
|
|
DisposeClient(_dedicatedIdleClient);
|
|
|
|
|
_dedicatedIdleClient = null;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
_clientStates[_dedicatedIdleClient] = ImapClientState.Idle;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private ConnectionPoolHealth GetHealthInternal()
|
|
|
|
|
{
|
|
|
|
|
var health = new ConnectionPoolHealth
|
|
|
|
|
{
|
|
|
|
|
LastHealthCheck = DateTime.UtcNow,
|
|
|
|
|
IdleConnectionActive = _dedicatedIdleClient?.IsConnected ?? false
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
foreach (var kvp in _clientStates)
|
|
|
|
|
{
|
|
|
|
|
health.TotalConnections++;
|
|
|
|
|
switch (kvp.Value)
|
|
|
|
|
{
|
|
|
|
|
case ImapClientState.Available:
|
|
|
|
|
health.AvailableConnections++;
|
|
|
|
|
break;
|
|
|
|
|
case ImapClientState.InUse:
|
|
|
|
|
health.InUseConnections++;
|
|
|
|
|
break;
|
|
|
|
|
case ImapClientState.Failed:
|
|
|
|
|
health.FailedConnections++;
|
|
|
|
|
break;
|
|
|
|
|
case ImapClientState.Reconnecting:
|
|
|
|
|
health.ReconnectingConnections++;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return health;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task MaintenanceLoopAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await Task.Delay(MaintenanceIntervalMs, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
// Send NOOP to keep connections alive
|
|
|
|
|
await SendNoOpToAvailableClientsAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
// Ensure minimum connections
|
|
|
|
|
await EnsureMinimumConnectionsAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
// Clean up failed connections
|
|
|
|
|
await CleanupFailedConnectionsAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Warning(ex, "Error in pool maintenance loop");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task SendNoOpToAvailableClientsAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
foreach (var kvp in _clientStates)
|
|
|
|
|
{
|
|
|
|
|
if (kvp.Value == ImapClientState.Available && kvp.Key.IsConnected && !kvp.Key.IsBusy())
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await kvp.Key.NoOpAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Debug(ex, "NOOP failed for client, marking as failed");
|
|
|
|
|
_clientStates[kvp.Key] = ImapClientState.Failed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task EnsureMinimumConnectionsAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var health = Health;
|
|
|
|
|
var neededConnections = MinActiveConnections - health.AvailableConnections;
|
|
|
|
|
|
|
|
|
|
if (neededConnections > 0)
|
|
|
|
|
{
|
|
|
|
|
_logger.Debug("Creating {Count} connections to maintain minimum pool size", neededConnections);
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < neededConnections; i++)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var client = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (client != null)
|
|
|
|
|
{
|
|
|
|
|
_clientStates[client] = ImapClientState.Available;
|
|
|
|
|
await _availableClients.Writer.WriteAsync(client, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Warning(ex, "Failed to create new connection during maintenance");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task CleanupFailedConnectionsAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
foreach (var kvp in _clientStates)
|
|
|
|
|
{
|
|
|
|
|
if (kvp.Value == ImapClientState.Failed)
|
|
|
|
|
{
|
|
|
|
|
DisposeClient(kvp.Key);
|
|
|
|
|
_clientStates.TryRemove(kvp.Key, out _);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<WinoImapClient> CreateAndConnectClientAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var client = CreateNewClient();
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await EnsureClientReadyAsync(client, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
return client;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
if (ex.InnerException is ImapTestSSLCertificateException imapTestSSLCertificateException)
|
|
|
|
|
throw imapTestSSLCertificateException;
|
|
|
|
|
_logger.Warning(ex, "Failed to create and connect new client");
|
|
|
|
|
DisposeClient(client);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new ImapClientPoolException(ex, GetProtocolLogContent());
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
private async Task EnsureClientReadyAsync(WinoImapClient client, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
// Connect if needed
|
|
|
|
|
if (!client.IsConnected)
|
|
|
|
|
{
|
|
|
|
|
// Release it even if it fails.
|
|
|
|
|
_semaphore.Release();
|
|
|
|
|
client.ServerCertificateValidationCallback = MyServerCertificateValidationCallback;
|
|
|
|
|
|
|
|
|
|
await client.ConnectAsync(
|
|
|
|
|
_customServerInformation.IncomingServer,
|
|
|
|
|
int.Parse(_customServerInformation.IncomingServerPort),
|
|
|
|
|
GetSocketOptions(_customServerInformation.IncomingServerSocketOption),
|
|
|
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
// Enable compression if supported
|
|
|
|
|
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
|
|
|
|
|
{
|
|
|
|
|
await client.CompressAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle ID extension
|
|
|
|
|
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await client.IdentifyAsync(_implementation, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
catch (ImapCommandException)
|
|
|
|
|
{
|
|
|
|
|
// Some servers require post-auth identification
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Authenticate if needed
|
|
|
|
|
if (!client.IsAuthenticated)
|
|
|
|
|
{
|
|
|
|
|
var cred = new NetworkCredential(
|
|
|
|
|
_customServerInformation.IncomingServerUsername,
|
|
|
|
|
_customServerInformation.IncomingServerPassword);
|
|
|
|
|
|
|
|
|
|
var authMethod = _customServerInformation.IncomingAuthenticationMethod;
|
|
|
|
|
|
|
|
|
|
if (authMethod != ImapAuthenticationMethod.Auto)
|
|
|
|
|
{
|
|
|
|
|
client.AuthenticationMechanisms.Clear();
|
|
|
|
|
var saslMechanism = GetSASLAuthenticationMethodName(authMethod);
|
|
|
|
|
client.AuthenticationMechanisms.Add(saslMechanism);
|
|
|
|
|
await client.AuthenticateAsync(SaslMechanism.Create(saslMechanism, cred), cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
await client.AuthenticateAsync(cred, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try post-auth ID if needed
|
|
|
|
|
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await client.IdentifyAsync(_implementation, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
catch { /* Ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enable QRESYNC if supported
|
|
|
|
|
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
|
|
|
|
|
{
|
|
|
|
|
await client.EnableQuickResyncAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
client.IsQResyncEnabled = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private WinoImapClient CreateNewClient()
|
|
|
|
|
{
|
|
|
|
|
var client = new WinoImapClient();
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrEmpty(_customServerInformation.ProxyServer))
|
|
|
|
|
{
|
|
|
|
|
client.ProxyClient = new HttpProxyClient(
|
|
|
|
|
_customServerInformation.ProxyServer,
|
|
|
|
|
int.Parse(_customServerInformation.ProxyServerPort));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_logger.Debug("Created new ImapClient. Current pool size: {Count}", _clientStates.Count);
|
|
|
|
|
return client;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void DisposeClient(IImapClient client)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (client.IsConnected)
|
|
|
|
|
{
|
|
|
|
|
lock (client.SyncRoot)
|
|
|
|
|
{
|
|
|
|
|
client.Disconnect(quit: true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
client.Dispose();
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.Debug(ex, "Error disposing 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
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private string GetSASLAuthenticationMethodName(ImapAuthenticationMethod method) => method switch
|
|
|
|
|
{
|
|
|
|
|
ImapAuthenticationMethod.NormalPassword => "PLAIN",
|
|
|
|
|
ImapAuthenticationMethod.EncryptedPassword => "LOGIN",
|
|
|
|
|
ImapAuthenticationMethod.Ntlm => "NTLM",
|
|
|
|
|
ImapAuthenticationMethod.CramMd5 => "CRAM-MD5",
|
|
|
|
|
ImapAuthenticationMethod.DigestMd5 => "DIGEST-MD5",
|
|
|
|
|
_ => "PLAIN"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private bool MyServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
|
|
|
|
|
{
|
|
|
|
|
if (sslPolicyErrors == SslPolicyErrors.None) return true;
|
|
|
|
|
|
|
|
|
|
if (ThrowOnSSLHandshakeCallback)
|
|
|
|
|
{
|
|
|
|
|
throw new ImapTestSSLCertificateException(
|
|
|
|
|
certificate.Issuer,
|
|
|
|
|
certificate.GetExpirationDateString(),
|
|
|
|
|
certificate.GetEffectiveDateString());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
@@ -223,171 +575,12 @@ public class ImapClientPool : IDisposable
|
|
|
|
|
return reader.ReadToEnd();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IImapClient> GetClientAsync()
|
|
|
|
|
{
|
|
|
|
|
await _semaphore.WaitAsync();
|
|
|
|
|
// Legacy compatibility methods
|
|
|
|
|
public Task<bool> EnsureConnectedAsync(IImapClient client) =>
|
|
|
|
|
Task.FromResult(client.IsConnected);
|
|
|
|
|
|
|
|
|
|
if (_clients.TryPop(out IImapClient item))
|
|
|
|
|
{
|
|
|
|
|
await EnsureCapabilitiesAsync(item, false);
|
|
|
|
|
|
|
|
|
|
return item;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var client = CreateNewClient();
|
|
|
|
|
|
|
|
|
|
await EnsureCapabilitiesAsync(client, true);
|
|
|
|
|
|
|
|
|
|
return client;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Release(IImapClient item, bool destroyClient = false)
|
|
|
|
|
{
|
|
|
|
|
if (item != null)
|
|
|
|
|
{
|
|
|
|
|
if (destroyClient)
|
|
|
|
|
{
|
|
|
|
|
if (item.IsConnected)
|
|
|
|
|
{
|
|
|
|
|
lock (item.SyncRoot)
|
|
|
|
|
{
|
|
|
|
|
item.Disconnect(quit: true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
item.Dispose();
|
|
|
|
|
}
|
|
|
|
|
else if (!_disposedValue)
|
|
|
|
|
{
|
|
|
|
|
_clients.Push(item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_semaphore.Release();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private IImapClient CreateNewClient()
|
|
|
|
|
{
|
|
|
|
|
WinoImapClient client = null;
|
|
|
|
|
|
|
|
|
|
client = new WinoImapClient();
|
|
|
|
|
|
|
|
|
|
HttpProxyClient proxyClient = null;
|
|
|
|
|
|
|
|
|
|
// Add proxy client if exists.
|
|
|
|
|
if (!string.IsNullOrEmpty(_customServerInformation.ProxyServer))
|
|
|
|
|
{
|
|
|
|
|
proxyClient = new HttpProxyClient(_customServerInformation.ProxyServer, int.Parse(_customServerInformation.ProxyServerPort));
|
|
|
|
|
client.ProxyClient = proxyClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_logger.Debug("Creating new ImapClient. Current clients: {Count}", _clients.Count);
|
|
|
|
|
|
|
|
|
|
return client;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private SecureSocketOptions GetSocketOptions(ImapConnectionSecurity connectionSecurity)
|
|
|
|
|
=> connectionSecurity switch
|
|
|
|
|
{
|
|
|
|
|
ImapConnectionSecurity.Auto => SecureSocketOptions.Auto,
|
|
|
|
|
ImapConnectionSecurity.None => SecureSocketOptions.None,
|
|
|
|
|
ImapConnectionSecurity.StartTls => SecureSocketOptions.StartTlsWhenAvailable,
|
|
|
|
|
ImapConnectionSecurity.SslTls => SecureSocketOptions.SslOnConnect,
|
|
|
|
|
_ => SecureSocketOptions.None
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <returns>True if the connection is newly established.</returns>
|
|
|
|
|
public async Task<bool> EnsureConnectedAsync(IImapClient client)
|
|
|
|
|
{
|
|
|
|
|
if (client.IsConnected) return false;
|
|
|
|
|
|
|
|
|
|
client.ServerCertificateValidationCallback = MyServerCertificateValidationCallback;
|
|
|
|
|
|
|
|
|
|
await client.ConnectAsync(_customServerInformation.IncomingServer,
|
|
|
|
|
int.Parse(_customServerInformation.IncomingServerPort),
|
|
|
|
|
GetSocketOptions(_customServerInformation.IncomingServerSocketOption));
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task EnsureAuthenticatedAsync(IImapClient client)
|
|
|
|
|
{
|
|
|
|
|
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);
|
|
|
|
|
await client.AuthenticateAsync(SaslMechanism.Create(saslMechanism, cred));
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
await client.AuthenticateAsync(cred);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string GetSASLAuthenticationMethodName(ImapAuthenticationMethod method)
|
|
|
|
|
=> method switch
|
|
|
|
|
{
|
|
|
|
|
ImapAuthenticationMethod.NormalPassword => "PLAIN",
|
|
|
|
|
ImapAuthenticationMethod.EncryptedPassword => "LOGIN",
|
|
|
|
|
ImapAuthenticationMethod.Ntlm => "NTLM",
|
|
|
|
|
ImapAuthenticationMethod.CramMd5 => "CRAM-MD5",
|
|
|
|
|
ImapAuthenticationMethod.DigestMd5 => "DIGEST-MD5",
|
|
|
|
|
_ => "PLAIN"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private void WriteToProtocolLog(string message)
|
|
|
|
|
{
|
|
|
|
|
if (_protocolLogStream == null) return;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
public Task EnsureAuthenticatedAsync(IImapClient client) =>
|
|
|
|
|
Task.CompletedTask;
|
|
|
|
|
|
|
|
|
|
protected virtual void Dispose(bool disposing)
|
|
|
|
|
{
|
|
|
|
@@ -395,22 +588,26 @@ public class ImapClientPool : IDisposable
|
|
|
|
|
{
|
|
|
|
|
if (disposing)
|
|
|
|
|
{
|
|
|
|
|
_keepAliveTimer.Stop();
|
|
|
|
|
_connectionMonitorTimer.Stop();
|
|
|
|
|
_maintenanceCts.Cancel();
|
|
|
|
|
_maintenanceTask?.Wait(TimeSpan.FromSeconds(5));
|
|
|
|
|
_maintenanceCts.Dispose();
|
|
|
|
|
|
|
|
|
|
_keepAliveTimer.Dispose();
|
|
|
|
|
_connectionMonitorTimer.Dispose();
|
|
|
|
|
_availableClients.Writer.Complete();
|
|
|
|
|
|
|
|
|
|
_clients.ForEach(client =>
|
|
|
|
|
foreach (var kvp in _clientStates)
|
|
|
|
|
{
|
|
|
|
|
lock (client.SyncRoot)
|
|
|
|
|
{
|
|
|
|
|
client.Disconnect(true);
|
|
|
|
|
}
|
|
|
|
|
client.Dispose();
|
|
|
|
|
});
|
|
|
|
|
DisposeClient(kvp.Key);
|
|
|
|
|
}
|
|
|
|
|
_clientStates.Clear();
|
|
|
|
|
|
|
|
|
|
_clients.Clear();
|
|
|
|
|
lock (_idleClientLock)
|
|
|
|
|
{
|
|
|
|
|
if (_dedicatedIdleClient != null)
|
|
|
|
|
{
|
|
|
|
|
DisposeClient(_dedicatedIdleClient);
|
|
|
|
|
_dedicatedIdleClient = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_disposedValue = true;
|
|
|
|
|