using System.Net.Sockets; using System.Text.Json; using MailKit; using MailKit.Net.Smtp; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Enums; using ScadaLink.StoreAndForward; namespace ScadaLink.NotificationService; /// /// WP-11: Notification delivery via SMTP. /// WP-12: Error classification and S&F integration. /// Transient: connection refused, timeout, SMTP 4xx → hand to S&F. /// Permanent: SMTP 5xx → returned to script. /// public class NotificationDeliveryService : INotificationDeliveryService { private readonly INotificationRepository _repository; private readonly Func _smtpClientFactory; private readonly OAuth2TokenService? _tokenService; private readonly StoreAndForwardService? _storeAndForward; private readonly ILogger _logger; public NotificationDeliveryService( INotificationRepository repository, Func smtpClientFactory, ILogger logger, OAuth2TokenService? tokenService = null, StoreAndForwardService? storeAndForward = null) { _repository = repository; _smtpClientFactory = smtpClientFactory; _logger = logger; _tokenService = tokenService; _storeAndForward = storeAndForward; } /// /// Sends a notification to a named list. BCC delivery, plain text. /// public async Task SendAsync( string listName, string subject, string message, string? originInstanceName = null, CancellationToken cancellationToken = default) { var list = await _repository.GetListByNameAsync(listName, cancellationToken); if (list == null) { return new NotificationResult(false, $"Notification list '{listName}' not found"); } var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken); if (recipients.Count == 0) { return new NotificationResult(false, $"Notification list '{listName}' has no recipients"); } var smtpConfigs = await _repository.GetAllSmtpConfigurationsAsync(cancellationToken); var smtpConfig = smtpConfigs.FirstOrDefault(); if (smtpConfig == null) { return new NotificationResult(false, "No SMTP configuration available"); } try { await DeliverAsync(smtpConfig, recipients, subject, message, cancellationToken); return new NotificationResult(true, null); } catch (SmtpPermanentException ex) { // WP-12: Permanent SMTP failure — returned to script _logger.LogError(ex, "Permanent SMTP failure sending to list {List}", listName); return new NotificationResult(false, $"Permanent SMTP error: {ex.Message}"); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // NS-002: a caller-requested cancellation propagates; it is not buffered. throw; } catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken)) { // WP-12: Transient SMTP failure — hand to S&F _logger.LogWarning(ex, "Transient SMTP failure sending to list {List}, buffering for retry", listName); if (_storeAndForward == null) { return new NotificationResult(false, "Transient SMTP error and store-and-forward not available"); } var payload = JsonSerializer.Serialize(new { ListName = listName, Subject = subject, Message = message }); // attemptImmediateDelivery: false — DeliverAsync was already attempted // above; letting EnqueueAsync re-invoke the handler would send twice. await _storeAndForward.EnqueueAsync( StoreAndForwardCategory.Notification, listName, payload, originInstanceName, smtpConfig.MaxRetries > 0 ? smtpConfig.MaxRetries : null, smtpConfig.RetryDelay > TimeSpan.Zero ? smtpConfig.RetryDelay : null, attemptImmediateDelivery: false); return new NotificationResult(true, null, WasBuffered: true); } } /// /// WP-11/12: Delivers a buffered notification during a store-and-forward retry /// sweep — re-resolves the list, recipients and SMTP config and re-attempts /// delivery. Returns true on success, false on permanent failure (the message /// is parked); throws on a transient failure so the engine retries. /// public async Task DeliverBufferedAsync( StoreAndForwardMessage message, CancellationToken cancellationToken = default) { var payload = JsonSerializer.Deserialize(message.PayloadJson); if (payload == null || string.IsNullOrEmpty(payload.ListName)) { _logger.LogError("Buffered notification message {Id} has an unreadable payload; parking.", message.Id); return false; } var list = await _repository.GetListByNameAsync(payload.ListName, cancellationToken); if (list == null) { _logger.LogError( "Buffered notification to list '{List}' cannot be delivered — the list no longer exists; parking.", payload.ListName); return false; } var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken); if (recipients.Count == 0) { _logger.LogError("Buffered notification to list '{List}' has no recipients; parking.", payload.ListName); return false; } var smtpConfig = (await _repository.GetAllSmtpConfigurationsAsync(cancellationToken)).FirstOrDefault(); if (smtpConfig == null) { _logger.LogError("Buffered notification cannot be delivered — no SMTP configuration available; parking."); return false; } try { await DeliverAsync(smtpConfig, recipients, payload.Subject, payload.Message, cancellationToken); return true; } catch (SmtpPermanentException ex) { _logger.LogError(ex, "Buffered notification to list '{List}' failed permanently; parking.", payload.ListName); return false; } // Transient SMTP errors propagate out of DeliverAsync — the S&F engine retries. } private sealed record BufferedNotification(string ListName, string Subject, string Message); /// /// Delivers an email via SMTP. Throws on failure. /// internal async Task DeliverAsync( SmtpConfiguration config, IReadOnlyList recipients, string subject, string body, CancellationToken cancellationToken) { // NS-004: create exactly one client and dispose the one actually used. var smtp = _smtpClientFactory(); using var disposable = smtp as IDisposable; try { var useTls = config.TlsMode?.Equals("starttls", StringComparison.OrdinalIgnoreCase) == true; await smtp.ConnectAsync(config.Host, config.Port, useTls, cancellationToken); // Resolve credentials (OAuth2 token refresh if needed) var credentials = config.Credentials; if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase) && _tokenService != null && credentials != null) { var token = await _tokenService.GetTokenAsync(credentials, cancellationToken); credentials = token; } await smtp.AuthenticateAsync(config.AuthType, credentials, cancellationToken); var bccAddresses = recipients.Select(r => r.EmailAddress).ToList(); await smtp.SendAsync(config.FromAddress, bccAddresses, subject, body, cancellationToken); await smtp.DisconnectAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // NS-002: A deliberately cancelled token must propagate as a cancellation, // not be misclassified as a transient SMTP failure and buffered for retry. throw; } catch (Exception ex) when (ClassifySmtpError(ex, cancellationToken) == SmtpErrorClass.Permanent && ex is not SmtpPermanentException) { // NS-003: Permanent SMTP failure (5xx) — surface a typed permanent exception. throw new SmtpPermanentException(ex.Message, ex); } // Transient and SmtpPermanentException both propagate unchanged: SendAsync's // catch filters (SmtpPermanentException / IsTransientSmtpError) handle them. } 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 be returned to the script. Permanent, } /// /// NS-002/NS-003: Classifies an SMTP failure using MailKit's typed exceptions and /// the numeric rather than locale-dependent substring /// matching on the exception message. A cancellation requested by the caller is /// never treated as a transient SMTP error. /// private static SmtpErrorClass ClassifySmtpError(Exception ex, CancellationToken cancellationToken) { // A deliberate cancellation is not an SMTP error at all. if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) { return SmtpErrorClass.Unknown; } // MailKit reports SMTP command failures with the real status code; the // SmtpStatusCode enum's underlying value is the numeric SMTP reply code. 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; } // Protocol errors, a dropped/unavailable service, socket failures and // timeouts are all retryable — the message has not been rejected. 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; } } /// /// Signals a permanent SMTP failure (5xx) that should not be retried. /// public class SmtpPermanentException : Exception { public SmtpPermanentException(string message, Exception? innerException = null) : base(message, innerException) { } }