From b8dece0e70ee55a51585f01afb8ac386c01f1e0f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 19 May 2026 01:26:33 -0400 Subject: [PATCH] feat(notification-outbox): add Email notification delivery adapter --- .../EmailNotificationDeliveryAdapter.cs | 328 ++++++++++++++++++ .../ScadaLink.NotificationOutbox.csproj | 4 + .../EmailNotificationDeliveryAdapterTests.cs | 191 ++++++++++ 3 files changed, 523 insertions(+) create mode 100644 src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs create mode 100644 tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs diff --git a/src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs b/src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs new file mode 100644 index 0000000..d65d978 --- /dev/null +++ b/src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs @@ -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; + +/// +/// Task 12: Email channel delivery adapter for the central notification outbox. +/// +/// Reuses the SMTP machinery — +/// , , +/// and the typed . +/// The connect/auth/send/disconnect sequence and error classification mirror +/// NotificationDeliveryService.DeliverAsync; this adapter, however, maps the +/// result to the outbox's three-way (Success / Permanent +/// / Transient) rather than the S&F-coupled NotificationResult, which cannot +/// distinguish a permanent failure from a buffered transient one. +/// +public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdapter +{ + private readonly INotificationRepository _repository; + private readonly Func _smtpClientFactory; + private readonly OAuth2TokenService? _tokenService; + private readonly ILogger _logger; + private readonly NotificationOptions _options; + + public EmailNotificationDeliveryAdapter( + INotificationRepository repository, + Func smtpClientFactory, + ILogger logger, + OAuth2TokenService? tokenService = null, + IOptions? 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(); + } + + /// + public NotificationType Type => NotificationType.Email; + + /// + public async Task 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}"); + } + } + + /// + /// Delivers the plain-text BCC email via SMTP. Mirrors the connect/auth/send/ + /// disconnect sequence of NotificationDeliveryService.DeliverAsync: a + /// permanent failure surfaces as ; transient + /// failures propagate for the caller's classifier; the connection is always torn + /// down in the finally block. + /// + private async Task SendAsync( + SmtpConfiguration config, + SmtpTlsMode tlsMode, + IReadOnlyList 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); + } + } + } + + /// + /// 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). + /// + private static string? ValidateAddresses( + string fromAddress, IReadOnlyList 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 + { + /// 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, + } + + /// + /// Classifies an SMTP failure using MailKit's typed exceptions and the numeric + /// rather than locale-dependent substring matching + /// (mirrors NS-002/NS-003 in NotificationDeliveryService). + /// + 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; + } + + /// + /// Masks SMTP credential secrets out of free text (typically an SMTP server's + /// exception message) before it is logged or stored. Mirrors + /// NotificationService.CredentialRedactor, which is internal to that + /// project and so cannot be referenced here. + /// + 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; + } +} diff --git a/src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj b/src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj index 656ae01..64b6b1b 100644 --- a/src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj +++ b/src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj @@ -14,6 +14,10 @@ + + diff --git a/tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs new file mode 100644 index 0000000..2f6a2e3 --- /dev/null +++ b/tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs @@ -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; + +/// +/// 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. +/// +public class EmailNotificationDeliveryAdapterTests +{ + private readonly INotificationRepository _repository = Substitute.For(); + private readonly ISmtpClientWrapper _smtpClient = Substitute.For(); + + private EmailNotificationDeliveryAdapter CreateAdapter() + { + return new EmailNotificationDeliveryAdapter( + _repository, + () => _smtpClient, + NullLogger.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 + { + 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 { 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()); + 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 + { + new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } + }); + _repository.GetAllSmtpConfigurationsAsync().Returns(new List()); + 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(), Arg.Any>(), + Arg.Any(), Arg.Any(), Arg.Any()) + .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(), Arg.Any>(), + Arg.Any(), Arg.Any(), Arg.Any()) + .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(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .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(), Arg.Any>(), + Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new SmtpCommandException( + SmtpErrorCode.MessageNotAccepted, SmtpStatusCode.MailboxBusy, "try later")); + var adapter = CreateAdapter(); + + var outcome = await adapter.DeliverAsync(MakeNotification()); + + Assert.Equal(DeliveryResult.TransientFailure, outcome.Result); + } +}