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