using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; 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 = EmailAddressValidator.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 = CredentialRedactor.Scrub(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 (SmtpErrorClassifier.IsTransient(ex, cancellationToken)) { // Transient SMTP failure (4xx, socket/protocol/timeout) — eligible for retry. var detail = CredentialRedactor.Scrub(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 = CredentialRedactor.Scrub(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 (SmtpErrorClassifier.Classify(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); } } } }