fix(notification-service): resolve NotificationService-005..009 — explicit TLS modes, per-credential token cache, timeout/throttle, address validation, credential redaction

This commit is contained in:
Joseph Doherty
2026-05-16 21:22:01 -04:00
parent 57679d49f2
commit a702cb96a8
11 changed files with 791 additions and 41 deletions

View File

@@ -3,6 +3,7 @@ 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;
@@ -68,6 +69,30 @@ public class NotificationDeliveryService : INotificationDeliveryService
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);
@@ -75,9 +100,13 @@ public class NotificationDeliveryService : INotificationDeliveryService
}
catch (SmtpPermanentException ex)
{
// WP-12: Permanent SMTP failure — returned to script
_logger.LogError(ex, "Permanent SMTP failure sending to list {List}", listName);
return new NotificationResult(false, $"Permanent SMTP error: {ex.Message}");
// 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)
{
@@ -86,8 +115,11 @@ public class NotificationDeliveryService : INotificationDeliveryService
}
catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken))
{
// WP-12: Transient SMTP failure — hand to S&F
_logger.LogWarning(ex, "Transient SMTP failure sending to list {List}, buffering for retry", listName);
// 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)
{
@@ -155,6 +187,30 @@ public class NotificationDeliveryService : INotificationDeliveryService
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);
@@ -162,7 +218,10 @@ public class NotificationDeliveryService : INotificationDeliveryService
}
catch (SmtpPermanentException ex)
{
_logger.LogError(ex, "Buffered notification to list '{List}' failed permanently; parking.", payload.ListName);
// 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.
@@ -171,7 +230,55 @@ public class NotificationDeliveryService : INotificationDeliveryService
private sealed record BufferedNotification(string ListName, string Subject, string Message);
/// <summary>
/// Delivers an email via SMTP. Throws on failure.
/// 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,
@@ -180,16 +287,23 @@ public class NotificationDeliveryService : INotificationDeliveryService
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
{
var useTls = config.TlsMode?.Equals("starttls", StringComparison.OrdinalIgnoreCase) == true;
await smtp.ConnectAsync(config.Host, config.Port, useTls, cancellationToken);
// 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 refresh if needed)
// 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)
{
@@ -218,6 +332,11 @@ public class NotificationDeliveryService : INotificationDeliveryService
}
// 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