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);
+ }
+}