Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -1,52 +1,378 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for email delivery. Requires SMTP configuration.
|
||||
/// </summary>
|
||||
public sealed class EmailChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly ILogger<EmailChannelAdapter> _logger;
|
||||
|
||||
public EmailChannelAdapter(ILogger<EmailChannelAdapter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var target = channel.Config?.Target ?? rendered.Target;
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return Task.FromResult(ChannelDispatchResult.Fail(
|
||||
"Email recipient not configured",
|
||||
shouldRetry: false));
|
||||
}
|
||||
|
||||
// Email delivery requires SMTP integration which depends on environment config.
|
||||
// For now, log the intent and return success for dev/test scenarios.
|
||||
// Production deployments should integrate with an SMTP relay or email service.
|
||||
_logger.LogInformation(
|
||||
"Email delivery queued: to={Recipient}, subject={Subject}, format={Format}",
|
||||
target,
|
||||
rendered.Title,
|
||||
rendered.Format);
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Resolve SMTP settings from channel.Config.SecretRef
|
||||
// 2. Build and send the email via SmtpClient or a service like SendGrid
|
||||
// 3. Return actual success/failure based on delivery
|
||||
|
||||
return Task.FromResult(ChannelDispatchResult.Ok());
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
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 bool _disposed;
|
||||
|
||||
public EmailChannelAdapter(
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EmailChannelAdapter> logger)
|
||||
{
|
||||
_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));
|
||||
}
|
||||
|
||||
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 = Random.Shared.NextDouble() * 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user