fix(notification-service): resolve NotificationService-002/003/004 — error classification by SMTP status code, single SMTP client

This commit is contained in:
Joseph Doherty
2026-05-16 19:47:17 -04:00
parent b249ca3bf7
commit 393172f169
4 changed files with 288 additions and 29 deletions

View File

@@ -1,4 +1,7 @@
using System.Net.Sockets;
using System.Text.Json;
using MailKit;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
@@ -76,7 +79,12 @@ public class NotificationDeliveryService : INotificationDeliveryService
_logger.LogError(ex, "Permanent SMTP failure sending to list {List}", listName);
return new NotificationResult(false, $"Permanent SMTP error: {ex.Message}");
}
catch (Exception ex) when (IsTransientSmtpError(ex))
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
_logger.LogWarning(ex, "Transient SMTP failure sending to list {List}, buffering for retry", listName);
@@ -172,8 +180,9 @@ public class NotificationDeliveryService : INotificationDeliveryService
string body,
CancellationToken cancellationToken)
{
using var client = _smtpClientFactory() as IDisposable;
// NS-004: create exactly one client and dispose the one actually used.
var smtp = _smtpClientFactory();
using var disposable = smtp as IDisposable;
try
{
@@ -195,32 +204,80 @@ public class NotificationDeliveryService : INotificationDeliveryService
await smtp.DisconnectAsync(cancellationToken);
}
catch (Exception ex) when (ex is not SmtpPermanentException && !IsTransientSmtpError(ex))
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Classify unrecognized SMTP exceptions
if (ex.Message.Contains("5.", StringComparison.Ordinal) ||
ex.Message.Contains("550", StringComparison.Ordinal) ||
ex.Message.Contains("553", StringComparison.Ordinal) ||
ex.Message.Contains("554", StringComparison.Ordinal))
{
throw new SmtpPermanentException(ex.Message, ex);
}
// Default: treat as transient
// 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.
}
private static bool IsTransientSmtpError(Exception ex)
private enum SmtpErrorClass
{
return ex is TimeoutException
or OperationCanceledException
or System.Net.Sockets.SocketException
or IOException
|| ex.Message.Contains("4.", StringComparison.Ordinal)
|| ex.Message.Contains("421", StringComparison.Ordinal)
|| ex.Message.Contains("450", StringComparison.Ordinal)
|| ex.Message.Contains("451", StringComparison.Ordinal);
/// <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;
}
}