using System.Net.Sockets; using System.Text.Json; using MailKit; using MailKit.Net.Smtp; using Microsoft.Extensions.Logging; using MimeKit; 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"); } // NS-005: validate the configured TLS mode up front — an unknown value is a // configuration error and must surface as a clean result, not a silent // fallback to opportunistic TLS negotiation. try { SmtpTlsModeParser.Parse(smtpConfig.TlsMode); } catch (ArgumentException ex) { _logger.LogError("Invalid SMTP TLS mode for list {List}: {Reason}", listName, ex.Message); return new NotificationResult(false, ex.Message); } // NS-008: validate every email address before attempting delivery. A single // malformed address previously caused MailboxAddress.Parse to throw a // ParseException that escaped SendAsync unhandled; it must instead produce a // clean NotificationResult the calling script can handle. var addressError = ValidateAddresses(smtpConfig.FromAddress, recipients); if (addressError != null) { _logger.LogWarning("Notification to list {List} has invalid addresses: {Reason}", listName, addressError); return new NotificationResult(false, addressError); } try { await DeliverAsync(smtpConfig, recipients, subject, message, cancellationToken); return new NotificationResult(true, null); } catch (SmtpPermanentException ex) { // WP-12: Permanent SMTP failure — returned to script. // NS-009: scrub credential fragments out of the server-supplied message // before logging or returning it. var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials); _logger.LogError( "Permanent SMTP failure sending to list {List}: {Detail}", listName, detail); return new NotificationResult(false, $"Permanent SMTP error: {detail}"); } 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. // NS-009: scrub credential fragments before logging. _logger.LogWarning( "Transient SMTP failure sending to list {List} ({ExceptionType}): {Detail}; buffering for retry", listName, ex.GetType().Name, CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials)); 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; } // NS-005: an unknown TLS mode is a configuration error that retrying cannot // fix — park the buffered message rather than throwing on every sweep. try { SmtpTlsModeParser.Parse(smtpConfig.TlsMode); } catch (ArgumentException ex) { _logger.LogError( "Buffered notification to list '{List}' cannot be delivered — {Reason}; parking.", payload.ListName, ex.Message); return false; } // NS-008: a malformed address cannot be fixed by retrying — park it. var addressError = ValidateAddresses(smtpConfig.FromAddress, recipients); if (addressError != null) { _logger.LogError( "Buffered notification to list '{List}' has invalid addresses ({Reason}); parking.", payload.ListName, addressError); return false; } try { await DeliverAsync(smtpConfig, recipients, payload.Subject, payload.Message, cancellationToken); return true; } catch (SmtpPermanentException ex) { // NS-009: scrub credential fragments out of the message before logging. _logger.LogError( "Buffered notification to list '{List}' failed permanently ({Detail}); parking.", payload.ListName, CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials)); return false; } // Transient SMTP errors propagate out of DeliverAsync — the S&F engine retries. } private sealed record BufferedNotification(string ListName, string Subject, string Message); /// /// NS-007: throttles concurrent SMTP deliveries to the configured /// MaxConcurrentConnections. Created lazily from the first SMTP config /// seen (one SMTP config is deployed per site, so the limit is stable). /// private SemaphoreSlim? _concurrencyLimiter; private readonly object _limiterLock = new(); private SemaphoreSlim GetConcurrencyLimiter(SmtpConfiguration config) { if (_concurrencyLimiter != null) { return _concurrencyLimiter; } lock (_limiterLock) { // NS-007: a non-positive configured value would make SemaphoreSlim // throw; fall back to the design-doc default of 5. var max = config.MaxConcurrentConnections > 0 ? config.MaxConcurrentConnections : 5; _concurrencyLimiter ??= new SemaphoreSlim(max, max); return _concurrencyLimiter; } } /// /// NS-008: Validates the sender and recipient email addresses, returning a /// human-readable error string if any is malformed, or null if all parse. /// internal 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; } /// /// Delivers an email via SMTP. Throws on failure (transient errors and /// propagate; the caller classifies them). /// internal async Task DeliverAsync( SmtpConfiguration config, IReadOnlyList recipients, string subject, string body, CancellationToken cancellationToken) { var tlsMode = SmtpTlsModeParser.Parse(config.TlsMode); // NS-007: bound the number of concurrent SMTP connections per site. var limiter = GetConcurrencyLimiter(config); await limiter.WaitAsync(cancellationToken); // NS-004: create exactly one client and dispose the one actually used. var smtp = _smtpClientFactory(); using var disposable = smtp as IDisposable; try { // NS-005/NS-007: explicit TLS mode and the configured connection timeout. await smtp.ConnectAsync( config.Host, config.Port, tlsMode, config.ConnectionTimeoutSeconds, 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) { 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. finally { // NS-007: always release the concurrency slot, even on failure. limiter.Release(); } } 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) { } }