using System.Net.Sockets; using MailKit; using MailKit.Net.Smtp; namespace ScadaLink.NotificationService; /// /// 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. /// public enum SmtpErrorClass { /// Cancellation or an unrecognised exception — caller decides. Unknown, /// Retryable failure (4xx, connection/socket/protocol error, timeout). Transient, /// Non-retryable failure (5xx) — must not be retried. Permanent, } /// /// NS-002/NS-003: Classifies an SMTP failure using MailKit's typed exceptions and /// the numeric rather than locale-dependent substring /// matching on the exception message. /// /// Public and shared: both (store-and-forward /// delivery) and the central Notification Outbox's EmailNotificationDeliveryAdapter /// route every SMTP failure through this single policy, so a transient/permanent /// boundary change cannot diverge between the two delivery paths. /// /// public static class SmtpErrorClassifier { /// /// Classifies an SMTP failure. A cancellation requested by the caller is never /// treated as a transient SMTP error. /// /// The exception thrown by the SMTP send sequence. /// /// The token governing the send; a requested cancellation classifies as /// so the caller can re-throw it. /// 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; } /// /// Convenience predicate: true when returns /// . /// public static bool IsTransient(Exception ex, CancellationToken cancellationToken) => Classify(ex, cancellationToken) == SmtpErrorClass.Transient; }