Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Channels/EmailChannelAdapter.cs
2026-01-07 09:43:12 +02:00

383 lines
13 KiB
C#

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;
/// <summary>
/// Channel adapter for SMTP email dispatch with retry policies.
/// </summary>
public sealed class EmailChannelAdapter : IChannelAdapter, IDisposable
{
private readonly INotifyAuditRepository _auditRepository;
private readonly ChannelAdapterOptions _options;
private readonly ILogger<EmailChannelAdapter> _logger;
private readonly TimeProvider _timeProvider;
private readonly Func<double> _jitterSource;
private bool _disposed;
public EmailChannelAdapter(
INotifyAuditRepository auditRepository,
IOptions<ChannelAdapterOptions> options,
TimeProvider timeProvider,
ILogger<EmailChannelAdapter> logger,
Func<double>? 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<ChannelDispatchResult> 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<ChannelHealthCheckResult> 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<string> ParseRecipients(ChannelDispatchContext context)
{
var recipients = new List<string>();
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<string> 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("<html", StringComparison.OrdinalIgnoreCase) ||
context.RenderedBody.Contains("<body", StringComparison.OrdinalIgnoreCase)
};
foreach (var recipient in recipients)
{
message.To.Add(recipient);
}
message.Headers.Add("X-StellaOps-Delivery-Id", context.DeliveryId);
message.Headers.Add("X-StellaOps-Trace-Id", context.TraceId);
return message;
}
private static bool IsRetryable(SmtpException ex)
{
return ex.StatusCode switch
{
SmtpStatusCode.ServiceNotAvailable => 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<string, string> BuildSuccessMetadata(
ChannelDispatchContext context,
SmtpConfig config,
List<string> recipients,
int attempt)
{
return new Dictionary<string, string>
{
["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<string, string>? metadata,
CancellationToken cancellationToken)
{
try
{
var auditMetadata = new Dictionary<string, string>
{
["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);
}