411 lines
16 KiB
C#
411 lines
16 KiB
C#
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using MailKit;
|
|
using MailKit.Net.Smtp;
|
|
using Microsoft.Extensions.Logging;
|
|
using MimeKit;
|
|
using ScadaLink.Commons.Entities.Notifications;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.StoreAndForward;
|
|
|
|
namespace ScadaLink.NotificationService;
|
|
|
|
/// <summary>
|
|
/// WP-11: Notification delivery via SMTP.
|
|
/// WP-12: Error classification and S&F integration.
|
|
/// Transient: connection refused, timeout, SMTP 4xx → hand to S&F.
|
|
/// Permanent: SMTP 5xx → returned to script.
|
|
/// </summary>
|
|
public class NotificationDeliveryService : INotificationDeliveryService
|
|
{
|
|
private readonly INotificationRepository _repository;
|
|
private readonly Func<ISmtpClientWrapper> _smtpClientFactory;
|
|
private readonly OAuth2TokenService? _tokenService;
|
|
private readonly StoreAndForwardService? _storeAndForward;
|
|
private readonly ILogger<NotificationDeliveryService> _logger;
|
|
|
|
public NotificationDeliveryService(
|
|
INotificationRepository repository,
|
|
Func<ISmtpClientWrapper> smtpClientFactory,
|
|
ILogger<NotificationDeliveryService> logger,
|
|
OAuth2TokenService? tokenService = null,
|
|
StoreAndForwardService? storeAndForward = null)
|
|
{
|
|
_repository = repository;
|
|
_smtpClientFactory = smtpClientFactory;
|
|
_logger = logger;
|
|
_tokenService = tokenService;
|
|
_storeAndForward = storeAndForward;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a notification to a named list. BCC delivery, plain text.
|
|
/// </summary>
|
|
public async Task<NotificationResult> SendAsync(
|
|
string listName,
|
|
string subject,
|
|
string message,
|
|
string? originInstanceName = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var list = await _repository.GetListByNameAsync(listName, cancellationToken);
|
|
if (list == null)
|
|
{
|
|
return new NotificationResult(false, $"Notification list '{listName}' not found");
|
|
}
|
|
|
|
var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken);
|
|
if (recipients.Count == 0)
|
|
{
|
|
return new NotificationResult(false, $"Notification list '{listName}' has no recipients");
|
|
}
|
|
|
|
var smtpConfigs = await _repository.GetAllSmtpConfigurationsAsync(cancellationToken);
|
|
var smtpConfig = smtpConfigs.FirstOrDefault();
|
|
if (smtpConfig == null)
|
|
{
|
|
return new NotificationResult(false, "No SMTP configuration available");
|
|
}
|
|
|
|
// NS-005: validate the configured TLS mode up front — an unknown value is a
|
|
// configuration error and must surface as a clean result, not a silent
|
|
// fallback to opportunistic TLS negotiation.
|
|
try
|
|
{
|
|
SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
_logger.LogError("Invalid SMTP TLS mode for list {List}: {Reason}", listName, ex.Message);
|
|
return new NotificationResult(false, ex.Message);
|
|
}
|
|
|
|
// NS-008: validate every email address before attempting delivery. A single
|
|
// malformed address previously caused MailboxAddress.Parse to throw a
|
|
// ParseException that escaped SendAsync unhandled; it must instead produce a
|
|
// clean NotificationResult the calling script can handle.
|
|
var addressError = ValidateAddresses(smtpConfig.FromAddress, recipients);
|
|
if (addressError != null)
|
|
{
|
|
_logger.LogWarning("Notification to list {List} has invalid addresses: {Reason}", listName, addressError);
|
|
return new NotificationResult(false, addressError);
|
|
}
|
|
|
|
try
|
|
{
|
|
await DeliverAsync(smtpConfig, recipients, subject, message, cancellationToken);
|
|
return new NotificationResult(true, null);
|
|
}
|
|
catch (SmtpPermanentException ex)
|
|
{
|
|
// WP-12: Permanent SMTP failure — returned to script.
|
|
// NS-009: scrub credential fragments out of the server-supplied message
|
|
// before logging or returning it.
|
|
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
|
|
_logger.LogError(
|
|
"Permanent SMTP failure sending to list {List}: {Detail}", listName, detail);
|
|
return new NotificationResult(false, $"Permanent SMTP error: {detail}");
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// NS-002: a caller-requested cancellation propagates; it is not buffered.
|
|
throw;
|
|
}
|
|
catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken))
|
|
{
|
|
// WP-12: Transient SMTP failure — hand to S&F.
|
|
// NS-009: scrub credential fragments before logging.
|
|
_logger.LogWarning(
|
|
"Transient SMTP failure sending to list {List} ({ExceptionType}): {Detail}; buffering for retry",
|
|
listName, ex.GetType().Name, CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials));
|
|
|
|
if (_storeAndForward == null)
|
|
{
|
|
return new NotificationResult(false, "Transient SMTP error and store-and-forward not available");
|
|
}
|
|
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
ListName = listName,
|
|
Subject = subject,
|
|
Message = message
|
|
});
|
|
|
|
// attemptImmediateDelivery: false — DeliverAsync was already attempted
|
|
// above; letting EnqueueAsync re-invoke the handler would send twice.
|
|
await _storeAndForward.EnqueueAsync(
|
|
StoreAndForwardCategory.Notification,
|
|
listName,
|
|
payload,
|
|
originInstanceName,
|
|
smtpConfig.MaxRetries > 0 ? smtpConfig.MaxRetries : null,
|
|
smtpConfig.RetryDelay > TimeSpan.Zero ? smtpConfig.RetryDelay : null,
|
|
attemptImmediateDelivery: false);
|
|
|
|
return new NotificationResult(true, null, WasBuffered: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// WP-11/12: Delivers a buffered notification during a store-and-forward retry
|
|
/// sweep — re-resolves the list, recipients and SMTP config and re-attempts
|
|
/// delivery. Returns true on success, false on permanent failure (the message
|
|
/// is parked); throws on a transient failure so the engine retries.
|
|
/// </summary>
|
|
public async Task<bool> DeliverBufferedAsync(
|
|
StoreAndForwardMessage message, CancellationToken cancellationToken = default)
|
|
{
|
|
var payload = JsonSerializer.Deserialize<BufferedNotification>(message.PayloadJson);
|
|
if (payload == null || string.IsNullOrEmpty(payload.ListName))
|
|
{
|
|
_logger.LogError("Buffered notification message {Id} has an unreadable payload; parking.", message.Id);
|
|
return false;
|
|
}
|
|
|
|
var list = await _repository.GetListByNameAsync(payload.ListName, cancellationToken);
|
|
if (list == null)
|
|
{
|
|
_logger.LogError(
|
|
"Buffered notification to list '{List}' cannot be delivered — the list no longer exists; parking.",
|
|
payload.ListName);
|
|
return false;
|
|
}
|
|
|
|
var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken);
|
|
if (recipients.Count == 0)
|
|
{
|
|
_logger.LogError("Buffered notification to list '{List}' has no recipients; parking.", payload.ListName);
|
|
return false;
|
|
}
|
|
|
|
var smtpConfig = (await _repository.GetAllSmtpConfigurationsAsync(cancellationToken)).FirstOrDefault();
|
|
if (smtpConfig == null)
|
|
{
|
|
_logger.LogError("Buffered notification cannot be delivered — no SMTP configuration available; parking.");
|
|
return false;
|
|
}
|
|
|
|
// NS-005: an unknown TLS mode is a configuration error that retrying cannot
|
|
// fix — park the buffered message rather than throwing on every sweep.
|
|
try
|
|
{
|
|
SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
_logger.LogError(
|
|
"Buffered notification to list '{List}' cannot be delivered — {Reason}; parking.",
|
|
payload.ListName, ex.Message);
|
|
return false;
|
|
}
|
|
|
|
// NS-008: a malformed address cannot be fixed by retrying — park it.
|
|
var addressError = ValidateAddresses(smtpConfig.FromAddress, recipients);
|
|
if (addressError != null)
|
|
{
|
|
_logger.LogError(
|
|
"Buffered notification to list '{List}' has invalid addresses ({Reason}); parking.",
|
|
payload.ListName, addressError);
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
await DeliverAsync(smtpConfig, recipients, payload.Subject, payload.Message, cancellationToken);
|
|
return true;
|
|
}
|
|
catch (SmtpPermanentException ex)
|
|
{
|
|
// NS-009: scrub credential fragments out of the message before logging.
|
|
_logger.LogError(
|
|
"Buffered notification to list '{List}' failed permanently ({Detail}); parking.",
|
|
payload.ListName, CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials));
|
|
return false;
|
|
}
|
|
// Transient SMTP errors propagate out of DeliverAsync — the S&F engine retries.
|
|
}
|
|
|
|
private sealed record BufferedNotification(string ListName, string Subject, string Message);
|
|
|
|
/// <summary>
|
|
/// NS-007: throttles concurrent SMTP deliveries to the configured
|
|
/// <c>MaxConcurrentConnections</c>. Created lazily from the first SMTP config
|
|
/// seen (one SMTP config is deployed per site, so the limit is stable).
|
|
/// </summary>
|
|
private SemaphoreSlim? _concurrencyLimiter;
|
|
private readonly object _limiterLock = new();
|
|
|
|
private SemaphoreSlim GetConcurrencyLimiter(SmtpConfiguration config)
|
|
{
|
|
if (_concurrencyLimiter != null)
|
|
{
|
|
return _concurrencyLimiter;
|
|
}
|
|
|
|
lock (_limiterLock)
|
|
{
|
|
// NS-007: a non-positive configured value would make SemaphoreSlim
|
|
// throw; fall back to the design-doc default of 5.
|
|
var max = config.MaxConcurrentConnections > 0 ? config.MaxConcurrentConnections : 5;
|
|
_concurrencyLimiter ??= new SemaphoreSlim(max, max);
|
|
return _concurrencyLimiter;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// NS-008: Validates the sender and recipient email addresses, returning a
|
|
/// human-readable error string if any is malformed, or null if all parse.
|
|
/// </summary>
|
|
internal static string? ValidateAddresses(
|
|
string fromAddress, IReadOnlyList<NotificationRecipient> recipients)
|
|
{
|
|
if (!MailboxAddress.TryParse(fromAddress, out _))
|
|
{
|
|
return $"Invalid sender (from) email address: '{fromAddress}'";
|
|
}
|
|
|
|
var invalid = recipients
|
|
.Where(r => !MailboxAddress.TryParse(r.EmailAddress, out _))
|
|
.Select(r => r.EmailAddress)
|
|
.ToList();
|
|
|
|
return invalid.Count > 0
|
|
? $"Invalid recipient email address(es): {string.Join(", ", invalid)}"
|
|
: null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delivers an email via SMTP. Throws on failure (transient errors and
|
|
/// <see cref="SmtpPermanentException"/> propagate; the caller classifies them).
|
|
/// </summary>
|
|
internal async Task DeliverAsync(
|
|
SmtpConfiguration config,
|
|
IReadOnlyList<NotificationRecipient> recipients,
|
|
string subject,
|
|
string body,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tlsMode = SmtpTlsModeParser.Parse(config.TlsMode);
|
|
|
|
// NS-007: bound the number of concurrent SMTP connections per site.
|
|
var limiter = GetConcurrencyLimiter(config);
|
|
await limiter.WaitAsync(cancellationToken);
|
|
|
|
// NS-004: create exactly one client and dispose the one actually used.
|
|
var smtp = _smtpClientFactory();
|
|
using var disposable = smtp as IDisposable;
|
|
|
|
try
|
|
{
|
|
// NS-005/NS-007: explicit TLS mode and the configured connection timeout.
|
|
await smtp.ConnectAsync(
|
|
config.Host, config.Port, tlsMode, config.ConnectionTimeoutSeconds, cancellationToken);
|
|
|
|
// Resolve credentials (OAuth2 token fetched/cached by the token service).
|
|
var credentials = config.Credentials;
|
|
if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase) && _tokenService != null && credentials != null)
|
|
{
|
|
var token = await _tokenService.GetTokenAsync(credentials, cancellationToken);
|
|
credentials = token;
|
|
}
|
|
|
|
await smtp.AuthenticateAsync(config.AuthType, credentials, cancellationToken);
|
|
|
|
var bccAddresses = recipients.Select(r => r.EmailAddress).ToList();
|
|
await smtp.SendAsync(config.FromAddress, bccAddresses, subject, body, cancellationToken);
|
|
|
|
await smtp.DisconnectAsync(cancellationToken);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// NS-002: A deliberately cancelled token must propagate as a cancellation,
|
|
// not be misclassified as a transient SMTP failure and buffered for retry.
|
|
throw;
|
|
}
|
|
catch (Exception ex) when (ClassifySmtpError(ex, cancellationToken) == SmtpErrorClass.Permanent
|
|
&& ex is not SmtpPermanentException)
|
|
{
|
|
// NS-003: Permanent SMTP failure (5xx) — surface a typed permanent exception.
|
|
throw new SmtpPermanentException(ex.Message, ex);
|
|
}
|
|
// Transient and SmtpPermanentException both propagate unchanged: SendAsync's
|
|
// catch filters (SmtpPermanentException / IsTransientSmtpError) handle them.
|
|
finally
|
|
{
|
|
// NS-007: always release the concurrency slot, even on failure.
|
|
limiter.Release();
|
|
}
|
|
}
|
|
|
|
private enum SmtpErrorClass
|
|
{
|
|
/// <summary>Cancellation or an unrecognised exception — caller decides.</summary>
|
|
Unknown,
|
|
/// <summary>Retryable failure (4xx, connection/socket/protocol error, timeout).</summary>
|
|
Transient,
|
|
/// <summary>Non-retryable failure (5xx) — must be returned to the script.</summary>
|
|
Permanent,
|
|
}
|
|
|
|
/// <summary>
|
|
/// NS-002/NS-003: Classifies an SMTP failure using MailKit's typed exceptions and
|
|
/// the numeric <see cref="SmtpStatusCode"/> rather than locale-dependent substring
|
|
/// matching on the exception message. A cancellation requested by the caller is
|
|
/// never treated as a transient SMTP error.
|
|
/// </summary>
|
|
private static SmtpErrorClass ClassifySmtpError(Exception ex, CancellationToken cancellationToken)
|
|
{
|
|
// A deliberate cancellation is not an SMTP error at all.
|
|
if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
|
|
{
|
|
return SmtpErrorClass.Unknown;
|
|
}
|
|
|
|
// MailKit reports SMTP command failures with the real status code; the
|
|
// SmtpStatusCode enum's underlying value is the numeric SMTP reply code.
|
|
if (ex is SmtpCommandException command)
|
|
{
|
|
var code = (int)command.StatusCode;
|
|
if (code >= 400 && code < 500)
|
|
{
|
|
return SmtpErrorClass.Transient;
|
|
}
|
|
|
|
if (code >= 500 && code < 600)
|
|
{
|
|
return SmtpErrorClass.Permanent;
|
|
}
|
|
|
|
return SmtpErrorClass.Unknown;
|
|
}
|
|
|
|
// Protocol errors, a dropped/unavailable service, socket failures and
|
|
// timeouts are all retryable — the message has not been rejected.
|
|
if (ex is SmtpProtocolException
|
|
or ServiceNotConnectedException
|
|
or SocketException
|
|
or TimeoutException)
|
|
{
|
|
return SmtpErrorClass.Transient;
|
|
}
|
|
|
|
return SmtpErrorClass.Unknown;
|
|
}
|
|
|
|
private static bool IsTransientSmtpError(Exception ex, CancellationToken cancellationToken)
|
|
{
|
|
return ClassifySmtpError(ex, cancellationToken) == SmtpErrorClass.Transient;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Signals a permanent SMTP failure (5xx) that should not be retried.
|
|
/// </summary>
|
|
public class SmtpPermanentException : Exception
|
|
{
|
|
public SmtpPermanentException(string message, Exception? innerException = null)
|
|
: base(message, innerException) { }
|
|
}
|