using System.Diagnostics; using System.Net; using System.Net.Mail; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Notify.Models; using StellaOps.Notifier.Worker.Storage; using StellaOps.Notifier.Worker.Options; namespace StellaOps.Notifier.Worker.Channels; /// /// Channel adapter for SMTP email dispatch with retry policies. /// public sealed class EmailChannelAdapter : IChannelAdapter, IDisposable { private readonly INotifyAuditRepository _auditRepository; private readonly ChannelAdapterOptions _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly Func _jitterSource; private bool _disposed; public EmailChannelAdapter( INotifyAuditRepository auditRepository, IOptions options, TimeProvider timeProvider, ILogger logger, Func? jitterSource = null) { _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _jitterSource = jitterSource ?? Random.Shared.NextDouble; } public NotifyChannelType ChannelType => NotifyChannelType.Email; public async Task DispatchAsync( ChannelDispatchContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); ObjectDisposedException.ThrowIf(_disposed, this); if (!TryParseSmtpConfig(context.Channel, out var smtpConfig, out var configError)) { await AuditDispatchAsync(context, false, configError, null, cancellationToken); return ChannelDispatchResult.Failed(configError, ChannelDispatchStatus.InvalidConfiguration); } var recipients = ParseRecipients(context); if (recipients.Count == 0) { var error = "No valid recipients configured."; await AuditDispatchAsync(context, false, error, null, cancellationToken); return ChannelDispatchResult.Failed(error, ChannelDispatchStatus.InvalidConfiguration); } var stopwatch = Stopwatch.StartNew(); var attempt = 0; var maxRetries = _options.MaxRetries; Exception? lastException = null; while (attempt <= maxRetries) { attempt++; cancellationToken.ThrowIfCancellationRequested(); try { using var client = CreateSmtpClient(smtpConfig); using var message = BuildMessage(context, smtpConfig, recipients); await client.SendMailAsync(message, cancellationToken).ConfigureAwait(false); stopwatch.Stop(); var metadata = BuildSuccessMetadata(context, smtpConfig, recipients, attempt); await AuditDispatchAsync(context, true, null, metadata, cancellationToken); _logger.LogInformation( "Email delivery {DeliveryId} succeeded to {RecipientCount} recipients via {SmtpHost} on attempt {Attempt}.", context.DeliveryId, recipients.Count, smtpConfig.Host, attempt); return ChannelDispatchResult.Succeeded( message: $"Sent to {recipients.Count} recipient(s) via {smtpConfig.Host}.", duration: stopwatch.Elapsed, metadata: metadata); } catch (SmtpException ex) when (IsRetryable(ex)) { lastException = ex; _logger.LogDebug( ex, "Email delivery {DeliveryId} attempt {Attempt} failed with retryable SMTP error.", context.DeliveryId, attempt); } catch (SmtpException ex) { stopwatch.Stop(); var errorMessage = $"SMTP error: {ex.StatusCode} - {ex.Message}"; await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken); _logger.LogWarning( ex, "Email delivery {DeliveryId} failed with non-retryable SMTP error: {StatusCode}.", context.DeliveryId, ex.StatusCode); return ChannelDispatchResult.Failed( errorMessage, ChannelDispatchStatus.Failed, exception: ex, duration: stopwatch.Elapsed); } catch (Exception ex) when (ex is System.Net.Sockets.SocketException or TimeoutException) { lastException = ex; _logger.LogDebug( ex, "Email delivery {DeliveryId} attempt {Attempt} failed with network error.", context.DeliveryId, attempt); } if (attempt <= maxRetries) { var delay = CalculateBackoff(attempt); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } } stopwatch.Stop(); var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts."; await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken); _logger.LogError( lastException, "Email delivery {DeliveryId} exhausted all {MaxRetries} retries to {SmtpHost}.", context.DeliveryId, maxRetries + 1, smtpConfig.Host); return ChannelDispatchResult.Failed( finalMessage, ChannelDispatchStatus.NetworkError, exception: lastException, duration: stopwatch.Elapsed); } public Task CheckHealthAsync( NotifyChannel channel, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(channel); ObjectDisposedException.ThrowIf(_disposed, this); if (!TryParseSmtpConfig(channel, out var smtpConfig, out var error)) { return Task.FromResult(ChannelHealthCheckResult.Unhealthy(error)); } if (!channel.Enabled) { return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled.")); } return Task.FromResult(ChannelHealthCheckResult.Ok( $"SMTP configuration validated for {smtpConfig.Host}:{smtpConfig.Port}.")); } private static bool TryParseSmtpConfig(NotifyChannel channel, out SmtpConfig config, out string error) { config = default; error = string.Empty; var props = channel.Config.Properties; if (props is null) { error = "Channel properties are not configured."; return false; } if (!props.TryGetValue("smtpHost", out var host) || string.IsNullOrWhiteSpace(host)) { error = "SMTP host is not configured."; return false; } if (!props.TryGetValue("smtpPort", out var portStr) || !int.TryParse(portStr, out var port)) { port = 587; } if (!props.TryGetValue("fromAddress", out var fromAddress) || string.IsNullOrWhiteSpace(fromAddress)) { error = "From address is not configured."; return false; } props.TryGetValue("fromName", out var fromName); props.TryGetValue("username", out var username); var enableSsl = !props.TryGetValue("enableSsl", out var sslStr) || !bool.TryParse(sslStr, out var ssl) || ssl; config = new SmtpConfig(host, port, fromAddress, fromName, username, channel.Config.SecretRef, enableSsl); return true; } private static List ParseRecipients(ChannelDispatchContext context) { var recipients = new List(); if (!string.IsNullOrWhiteSpace(context.Channel.Config.Target)) { recipients.AddRange(context.Channel.Config.Target .Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(IsValidEmail)); } if (context.Metadata.TryGetValue("recipients", out var metaRecipients) && !string.IsNullOrWhiteSpace(metaRecipients)) { recipients.AddRange(metaRecipients .Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(IsValidEmail)); } return recipients.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } private static bool IsValidEmail(string email) { try { var addr = new MailAddress(email); return addr.Address == email.Trim(); } catch { return false; } } private SmtpClient CreateSmtpClient(SmtpConfig config) { var client = new SmtpClient(config.Host, config.Port) { EnableSsl = config.EnableSsl, Timeout = (int)_options.DispatchTimeout.TotalMilliseconds, DeliveryMethod = SmtpDeliveryMethod.Network }; if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password)) { client.Credentials = new NetworkCredential(config.Username, config.Password); } return client; } private static MailMessage BuildMessage( ChannelDispatchContext context, SmtpConfig config, List recipients) { var from = string.IsNullOrWhiteSpace(config.FromName) ? new MailAddress(config.FromAddress) : new MailAddress(config.FromAddress, config.FromName); var message = new MailMessage { From = from, Subject = context.Subject ?? "StellaOps Notification", Body = context.RenderedBody, IsBodyHtml = context.RenderedBody.Contains(" true, SmtpStatusCode.MailboxBusy => true, SmtpStatusCode.LocalErrorInProcessing => true, SmtpStatusCode.InsufficientStorage => true, SmtpStatusCode.ServiceClosingTransmissionChannel => true, _ => false }; } private TimeSpan CalculateBackoff(int attempt) { var baseDelay = _options.RetryBaseDelay; var maxDelay = _options.RetryMaxDelay; var jitter = _jitterSource() * 0.3 + 0.85; var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter); return delay > maxDelay ? maxDelay : delay; } private static Dictionary BuildSuccessMetadata( ChannelDispatchContext context, SmtpConfig config, List recipients, int attempt) { return new Dictionary { ["smtpHost"] = config.Host, ["smtpPort"] = config.Port.ToString(), ["recipientCount"] = recipients.Count.ToString(), ["attempt"] = attempt.ToString() }; } private async Task AuditDispatchAsync( ChannelDispatchContext context, bool success, string? errorMessage, IReadOnlyDictionary? metadata, CancellationToken cancellationToken) { try { var auditMetadata = new Dictionary { ["deliveryId"] = context.DeliveryId, ["channelId"] = context.Channel.ChannelId, ["channelType"] = context.Channel.Type.ToString(), ["success"] = success.ToString().ToLowerInvariant(), ["traceId"] = context.TraceId }; if (!string.IsNullOrWhiteSpace(errorMessage)) { auditMetadata["error"] = errorMessage; } if (metadata is not null) { foreach (var (key, value) in metadata) { auditMetadata[$"dispatch.{key}"] = value; } } await _auditRepository.AppendAsync( context.TenantId, success ? "channel.dispatch.success" : "channel.dispatch.failure", "notifier-worker", auditMetadata, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId); } } public void Dispose() { _disposed = true; } private readonly record struct SmtpConfig( string Host, int Port, string FromAddress, string? FromName, string? Username, string? Password, bool EnableSsl); }