feat(notification-outbox): add Email notification delivery adapter

This commit is contained in:
Joseph Doherty
2026-05-19 01:26:33 -04:00
parent 8d52890245
commit b8dece0e70
3 changed files with 523 additions and 0 deletions

View File

@@ -0,0 +1,328 @@
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;
using ScadaLink.NotificationService;
namespace ScadaLink.NotificationOutbox.Delivery;
/// <summary>
/// Task 12: Email channel delivery adapter for the central notification outbox.
///
/// Reuses the <see cref="ScadaLink.NotificationService"/> SMTP machinery —
/// <see cref="ISmtpClientWrapper"/>, <see cref="SmtpTlsModeParser"/>,
/// <see cref="OAuth2TokenService"/> and the typed <see cref="SmtpPermanentException"/>.
/// The connect/auth/send/disconnect sequence and error classification mirror
/// <c>NotificationDeliveryService.DeliverAsync</c>; this adapter, however, maps the
/// result to the outbox's three-way <see cref="DeliveryOutcome"/> (Success / Permanent
/// / Transient) rather than the S&amp;F-coupled <c>NotificationResult</c>, which cannot
/// distinguish a permanent failure from a buffered transient one.
/// </summary>
public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdapter
{
private readonly INotificationRepository _repository;
private readonly Func<ISmtpClientWrapper> _smtpClientFactory;
private readonly OAuth2TokenService? _tokenService;
private readonly ILogger<EmailNotificationDeliveryAdapter> _logger;
private readonly NotificationOptions _options;
public EmailNotificationDeliveryAdapter(
INotificationRepository repository,
Func<ISmtpClientWrapper> smtpClientFactory,
ILogger<EmailNotificationDeliveryAdapter> logger,
OAuth2TokenService? tokenService = null,
IOptions<NotificationOptions>? options = null)
{
_repository = repository;
_smtpClientFactory = smtpClientFactory;
_logger = logger;
_tokenService = tokenService;
// Mirrors NotificationDeliveryService: NotificationOptions supplies the
// documented fallback values used when a deployed SmtpConfiguration row
// leaves a field unset (non-positive).
_options = options?.Value ?? new NotificationOptions();
}
/// <inheritdoc />
public NotificationType Type => NotificationType.Email;
/// <inheritdoc />
public async Task<DeliveryOutcome> DeliverAsync(
Notification notification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
var list = await _repository.GetListByNameAsync(notification.ListName, cancellationToken);
if (list == null)
{
return DeliveryOutcome.Permanent(
$"notification list '{notification.ListName}' not found");
}
var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken);
if (recipients.Count == 0)
{
return DeliveryOutcome.Permanent(
$"notification list '{notification.ListName}' has no recipients");
}
var smtpConfigs = await _repository.GetAllSmtpConfigurationsAsync(cancellationToken);
var smtpConfig = smtpConfigs.FirstOrDefault();
if (smtpConfig == null)
{
return DeliveryOutcome.Permanent("no SMTP configuration available");
}
// An unknown TLS mode is a configuration error that retrying cannot fix —
// surface it as a permanent failure (mirrors NS-005 in NotificationDeliveryService).
SmtpTlsMode tlsMode;
try
{
tlsMode = SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
}
catch (ArgumentException ex)
{
_logger.LogError(
"Email notification to list '{List}' has an invalid SMTP TLS mode: {Reason}",
notification.ListName, ex.Message);
return DeliveryOutcome.Permanent(ex.Message);
}
// 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);
if (addressError != null)
{
_logger.LogWarning(
"Email notification to list '{List}' has invalid addresses: {Reason}",
notification.ListName, addressError);
return DeliveryOutcome.Permanent(addressError);
}
var recipientAddresses = recipients.Select(r => r.EmailAddress).ToList();
try
{
await SendAsync(smtpConfig, tlsMode, recipientAddresses,
notification.Subject, notification.Body, cancellationToken);
return DeliveryOutcome.Success(string.Join(", ", recipientAddresses));
}
catch (SmtpPermanentException ex)
{
// Permanent SMTP failure (5xx) — not retried.
var detail = ScrubCredentials(ex.Message, smtpConfig.Credentials);
_logger.LogError(
"Permanent SMTP failure delivering email to list '{List}': {Detail}",
notification.ListName, detail);
return DeliveryOutcome.Permanent(detail);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// A caller-requested cancellation propagates; it is neither a success
// nor a delivery failure.
throw;
}
catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken))
{
// Transient SMTP failure (4xx, socket/protocol/timeout) — eligible for retry.
var detail = ScrubCredentials(ex.Message, smtpConfig.Credentials);
_logger.LogWarning(
"Transient SMTP failure delivering email to list '{List}' ({ExceptionType}): {Detail}",
notification.ListName, ex.GetType().Name, detail);
return DeliveryOutcome.Transient(detail);
}
catch (Exception ex)
{
// 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);
_logger.LogError(
"Unclassified failure delivering email to list '{List}' ({ExceptionType}): {Detail}",
notification.ListName, ex.GetType().Name, detail);
return DeliveryOutcome.Permanent($"email delivery failed: {detail}");
}
}
/// <summary>
/// Delivers the plain-text BCC email via SMTP. Mirrors the connect/auth/send/
/// disconnect sequence of <c>NotificationDeliveryService.DeliverAsync</c>: a
/// permanent failure surfaces as <see cref="SmtpPermanentException"/>; transient
/// failures propagate for the caller's classifier; the connection is always torn
/// down in the finally block.
/// </summary>
private async Task SendAsync(
SmtpConfiguration config,
SmtpTlsMode tlsMode,
IReadOnlyList<string> bccAddresses,
string subject,
string body,
CancellationToken cancellationToken)
{
// Create exactly one client and dispose the one actually used (NS-004).
var smtp = _smtpClientFactory();
using var disposable = smtp as IDisposable;
try
{
var timeoutSeconds = config.ConnectionTimeoutSeconds > 0
? config.ConnectionTimeoutSeconds
: _options.ConnectionTimeoutSeconds;
await smtp.ConnectAsync(
config.Host, config.Port, tlsMode, timeoutSeconds, cancellationToken);
// Resolve credentials (OAuth2 token fetched/cached by the token service).
var credentials = config.Credentials;
if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase)
&& _tokenService != null && credentials != null)
{
credentials = await _tokenService.GetTokenAsync(credentials, cancellationToken);
}
await smtp.AuthenticateAsync(config.AuthType, credentials, cancellationToken);
await smtp.SendAsync(config.FromAddress, bccAddresses, subject, body, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// A deliberate cancellation must propagate, not be misclassified as transient.
throw;
}
catch (Exception ex) when (ClassifySmtpError(ex, cancellationToken) == SmtpErrorClass.Permanent
&& ex is not SmtpPermanentException)
{
// Permanent SMTP failure (5xx) — surface a typed permanent exception.
throw new SmtpPermanentException(ex.Message, ex);
}
// Transient and SmtpPermanentException propagate unchanged for DeliverAsync's
// catch filters to classify.
finally
{
// Always tear the connection down, regardless of outcome (NS-010).
// Disconnect is best-effort: a disconnect failure 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);
}
}
}
/// <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;
}
}

View File

@@ -14,6 +14,10 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" /> <ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<!-- Email delivery adapter reuses the NotificationService SMTP machinery
(ISmtpClientWrapper, SmtpPermanentException, SmtpTlsModeParser,
OAuth2TokenService). -->
<ProjectReference Include="../ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,191 @@
using System.Net.Sockets;
using MailKit;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.NotificationOutbox.Delivery;
using ScadaLink.NotificationService;
namespace ScadaLink.NotificationOutbox.Tests.Delivery;
/// <summary>
/// Task 12: Tests for the Email outbox delivery adapter — list/recipient/SMTP-config
/// resolution, SMTP send, and the three-way (Success / Permanent / Transient) outcome
/// classification.
/// </summary>
public class EmailNotificationDeliveryAdapterTests
{
private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
private readonly ISmtpClientWrapper _smtpClient = Substitute.For<ISmtpClientWrapper>();
private EmailNotificationDeliveryAdapter CreateAdapter()
{
return new EmailNotificationDeliveryAdapter(
_repository,
() => _smtpClient,
NullLogger<EmailNotificationDeliveryAdapter>.Instance);
}
private static Notification MakeNotification(string listName = "ops-team")
{
return new Notification(
Guid.NewGuid().ToString(),
NotificationType.Email,
listName,
"Subject",
"Body",
"site-1");
}
private void SetupHappyPath()
{
var list = new NotificationList("ops-team") { Id = 1 };
var recipients = new List<NotificationRecipient>
{
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
new("Bob", "bob@example.com") { Id = 2, NotificationListId = 1 }
};
var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
{
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
};
_repository.GetListByNameAsync("ops-team").Returns(list);
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
}
[Fact]
public void Type_IsEmail()
{
Assert.Equal(NotificationType.Email, CreateAdapter().Type);
}
[Fact]
public async Task Deliver_HappyPath_ReturnsSuccessWithResolvedTargets()
{
SetupHappyPath();
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.Success, outcome.Result);
Assert.NotNull(outcome.ResolvedTargets);
Assert.Contains("alice@example.com", outcome.ResolvedTargets);
Assert.Contains("bob@example.com", outcome.ResolvedTargets);
Assert.Null(outcome.Error);
}
[Fact]
public async Task Deliver_ListNotFound_ReturnsPermanent()
{
_repository.GetListByNameAsync("missing").Returns((NotificationList?)null);
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification("missing"));
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
Assert.Contains("missing", outcome.Error);
Assert.Contains("not found", outcome.Error);
}
[Fact]
public async Task Deliver_NoRecipients_ReturnsPermanent()
{
var list = new NotificationList("ops-team") { Id = 1 };
_repository.GetListByNameAsync("ops-team").Returns(list);
_repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>());
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
Assert.Contains("recipient", outcome.Error);
}
[Fact]
public async Task Deliver_NoSmtpConfig_ReturnsPermanent()
{
var list = new NotificationList("ops-team") { Id = 1 };
_repository.GetListByNameAsync("ops-team").Returns(list);
_repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>
{
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
});
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration>());
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
Assert.Contains("SMTP", outcome.Error);
}
[Fact]
public async Task Deliver_SmtpPermanentException_ReturnsPermanent()
{
SetupHappyPath();
_smtpClient
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new SmtpPermanentException("550 mailbox unavailable"));
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
Assert.Contains("mailbox unavailable", outcome.Error);
}
[Fact]
public async Task Deliver_Smtp5xxCommandException_ReturnsPermanent()
{
SetupHappyPath();
_smtpClient
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new SmtpCommandException(
SmtpErrorCode.RecipientNotAccepted, SmtpStatusCode.MailboxUnavailable, "rejected"));
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
}
[Fact]
public async Task Deliver_SocketException_ReturnsTransient()
{
SetupHappyPath();
_smtpClient
.ConnectAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
Arg.Any<int>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new SocketException((int)SocketError.ConnectionRefused));
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
Assert.NotNull(outcome.Error);
}
[Fact]
public async Task Deliver_Smtp4xxCommandException_ReturnsTransient()
{
SetupHappyPath();
_smtpClient
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new SmtpCommandException(
SmtpErrorCode.MessageNotAccepted, SmtpStatusCode.MailboxBusy, "try later"));
var adapter = CreateAdapter();
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
}
}