refactor(notification-outbox): share SMTP helpers between NotificationService and the Email adapter

FU1 of the Notification Outbox follow-ups. EmailNotificationDeliveryAdapter
carried verbatim private copies of credential redaction, SMTP error
classification, and address validation because the NotificationService
helpers were internal. This eliminates the divergence risk by promoting the
helpers to public and deleting the adapter's copies.

- CredentialRedactor: internal -> public.
- Extract SmtpErrorClassifier + SmtpErrorClass enum into a new public static
  class; NotificationDeliveryService now routes classification through it
  (behavior unchanged). Adds focused SmtpErrorClassifierTests.
- NotificationDeliveryService.ValidateAddresses: internal -> public; the
  adapter calls it directly.
- EmailNotificationDeliveryAdapter: deleted ScrubCredentials, ClassifySmtpError,
  SmtpErrorClass, IsTransientSmtpError and ValidateAddresses copies.

No InternalsVisibleTo hack — specific helpers promoted to public. Both test
suites green; full solution builds clean.
This commit is contained in:
Joseph Doherty
2026-05-19 03:34:22 -04:00
parent 213b9c7c0a
commit 5e80f64cd8
5 changed files with 250 additions and 188 deletions

View File

@@ -0,0 +1,93 @@
using System.Net.Sockets;
using MailKit;
using MailKit.Net.Smtp;
namespace ScadaLink.NotificationService;
/// <summary>
/// NS-002/NS-003: The classification of an SMTP delivery failure. This decides
/// whether a failure is retried or surfaced to the caller, so it is part of the
/// system's correctness-relevant behaviour.
/// </summary>
public 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 not be retried.</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.
/// <para>
/// Public and shared: both <see cref="NotificationDeliveryService"/> (store-and-forward
/// delivery) and the central Notification Outbox's <c>EmailNotificationDeliveryAdapter</c>
/// route every SMTP failure through this single policy, so a transient/permanent
/// boundary change cannot diverge between the two delivery paths.
/// </para>
/// </summary>
public static class SmtpErrorClassifier
{
/// <summary>
/// Classifies an SMTP failure. A cancellation requested by the caller is never
/// treated as a transient SMTP error.
/// </summary>
/// <param name="ex">The exception thrown by the SMTP send sequence.</param>
/// <param name="cancellationToken">
/// The token governing the send; a requested cancellation classifies as
/// <see cref="SmtpErrorClass.Unknown"/> so the caller can re-throw it.
/// </param>
public static SmtpErrorClass Classify(Exception ex, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(ex);
// 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;
}
/// <summary>
/// Convenience predicate: true when <see cref="Classify"/> returns
/// <see cref="SmtpErrorClass.Transient"/>.
/// </summary>
public static bool IsTransient(Exception ex, CancellationToken cancellationToken)
=> Classify(ex, cancellationToken) == SmtpErrorClass.Transient;
}