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:
@@ -1,9 +1,5 @@
|
||||
using System.Net.Sockets;
|
||||
using MailKit;
|
||||
using MailKit.Net.Smtp;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
@@ -95,7 +91,8 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
|
||||
// A malformed sender or recipient address cannot be fixed by retrying —
|
||||
// surface it as a permanent failure (mirrors NS-008).
|
||||
var addressError = ValidateAddresses(smtpConfig.FromAddress, recipients);
|
||||
var addressError = NotificationDeliveryService.ValidateAddresses(
|
||||
smtpConfig.FromAddress, recipients);
|
||||
if (addressError != null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -116,7 +113,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
catch (SmtpPermanentException ex)
|
||||
{
|
||||
// Permanent SMTP failure (5xx) — not retried.
|
||||
var detail = ScrubCredentials(ex.Message, smtpConfig.Credentials);
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
|
||||
_logger.LogError(
|
||||
"Permanent SMTP failure delivering email to list '{List}': {Detail}",
|
||||
notification.ListName, detail);
|
||||
@@ -128,10 +125,10 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
// nor a delivery failure.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken))
|
||||
catch (Exception ex) when (SmtpErrorClassifier.IsTransient(ex, cancellationToken))
|
||||
{
|
||||
// Transient SMTP failure (4xx, socket/protocol/timeout) — eligible for retry.
|
||||
var detail = ScrubCredentials(ex.Message, smtpConfig.Credentials);
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
|
||||
_logger.LogWarning(
|
||||
"Transient SMTP failure delivering email to list '{List}' ({ExceptionType}): {Detail}",
|
||||
notification.ListName, ex.GetType().Name, detail);
|
||||
@@ -142,7 +139,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
// An unclassified failure — chiefly an OAuth2 token-fetch failure. The
|
||||
// outbox treats it as permanent: retrying a broken credential burns
|
||||
// token-endpoint calls. (Mirrors the NS-015 default-to-permanent stance.)
|
||||
var detail = ScrubCredentials(ex.Message, smtpConfig.Credentials);
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
|
||||
_logger.LogError(
|
||||
"Unclassified failure delivering email to list '{List}' ({ExceptionType}): {Detail}",
|
||||
notification.ListName, ex.GetType().Name, detail);
|
||||
@@ -193,7 +190,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
// A deliberate cancellation must propagate, not be misclassified as transient.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ClassifySmtpError(ex, cancellationToken) == SmtpErrorClass.Permanent
|
||||
catch (Exception ex) when (SmtpErrorClassifier.Classify(ex, cancellationToken) == SmtpErrorClass.Permanent
|
||||
&& ex is not SmtpPermanentException)
|
||||
{
|
||||
// Permanent SMTP failure (5xx) — surface a typed permanent exception.
|
||||
@@ -218,111 +215,4 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the sender and recipient email addresses, returning a human-readable
|
||||
/// error string if any is malformed, or null if all parse (mirrors NS-008).
|
||||
/// </summary>
|
||||
private 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;
|
||||
}
|
||||
|
||||
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 not be retried.</summary>
|
||||
Permanent,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies an SMTP failure using MailKit's typed exceptions and the numeric
|
||||
/// <see cref="SmtpStatusCode"/> rather than locale-dependent substring matching
|
||||
/// (mirrors NS-002/NS-003 in <c>NotificationDeliveryService</c>).
|
||||
/// </summary>
|
||||
private static SmtpErrorClass ClassifySmtpError(Exception ex, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return SmtpErrorClass.Unknown;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
/// Masks SMTP credential secrets out of free text (typically an SMTP server's
|
||||
/// exception message) before it is logged or stored. Mirrors
|
||||
/// <c>NotificationService.CredentialRedactor</c>, which is internal to that
|
||||
/// project and so cannot be referenced here.
|
||||
/// </summary>
|
||||
private static string ScrubCredentials(string? text, string? credentials)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials))
|
||||
{
|
||||
return text ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = text;
|
||||
|
||||
// Mask each colon-delimited component (user, password, tenant, clientId,
|
||||
// clientSecret) and the whole packed string. Longest first so a component
|
||||
// that is a substring of another is still fully masked.
|
||||
var parts = credentials.Split(':')
|
||||
.Where(p => p.Length >= 4)
|
||||
.Append(credentials)
|
||||
.Distinct()
|
||||
.OrderByDescending(p => p.Length);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
result = result.Replace(part, "***REDACTED***", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user