fix(notification-service): resolve NotificationService-010,011,012 — disconnect SMTP on failure, relocate exception type, OAuth2/token-cache test coverage

This commit is contained in:
Joseph Doherty
2026-05-16 22:24:03 -04:00
parent dab0056d1b
commit a9bd017c88
5 changed files with 328 additions and 18 deletions

View File

@@ -315,8 +315,6 @@ public class NotificationDeliveryService : INotificationDeliveryService
var bccAddresses = recipients.Select(r => r.EmailAddress).ToList();
await smtp.SendAsync(config.FromAddress, bccAddresses, subject, body, cancellationToken);
await smtp.DisconnectAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -334,6 +332,23 @@ public class NotificationDeliveryService : INotificationDeliveryService
// catch filters (SmtpPermanentException / IsTransientSmtpError) handle them.
finally
{
// NS-010: always tear the connection down, regardless of outcome. The
// SMTP QUIT used to run only on the success path inside the try block,
// so a failed Connect/Authenticate/Send left an open, authenticated
// connection until finalization reclaimed the socket — exhausting the
// server's connection slots under sustained transient failures.
// Disconnect is best-effort: a disconnect failure (e.g. the connection
// is already dead) must not mask the original delivery exception.
try
{
await smtp.DisconnectAsync(cancellationToken);
}
catch (Exception disconnectEx)
{
_logger.LogDebug(
"Ignoring SMTP disconnect failure during cleanup: {Reason}", disconnectEx.Message);
}
// NS-007: always release the concurrency slot, even on failure.
limiter.Release();
}
@@ -399,12 +414,3 @@ public class NotificationDeliveryService : INotificationDeliveryService
return ClassifySmtpError(ex, cancellationToken) == SmtpErrorClass.Transient;
}
}
/// <summary>
/// Signals a permanent SMTP failure (5xx) that should not be retried.
/// </summary>
public class SmtpPermanentException : Exception
{
public SmtpPermanentException(string message, Exception? innerException = null)
: base(message, innerException) { }
}