292 lines
12 KiB
C#
292 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class NotificationDeliveryService : INotificationDeliveryService
|
|
{
|
|
private readonly INotificationRepository _repository;
|
|
private readonly Func<ISmtpClientWrapper> _smtpClientFactory;
|
|
private readonly OAuth2TokenService? _tokenService;
|
|
private readonly StoreAndForwardService? _storeAndForward;
|
|
private readonly ILogger<NotificationDeliveryService> _logger;
|
|
|
|
public NotificationDeliveryService(
|
|
INotificationRepository repository,
|
|
Func<ISmtpClientWrapper> smtpClientFactory,
|
|
ILogger<NotificationDeliveryService> logger,
|
|
OAuth2TokenService? tokenService = null,
|
|
StoreAndForwardService? storeAndForward = null)
|
|
{
|
|
_repository = repository;
|
|
_smtpClientFactory = smtpClientFactory;
|
|
_logger = logger;
|
|
_tokenService = tokenService;
|
|
_storeAndForward = storeAndForward;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a notification to a named list. BCC delivery, plain text.
|
|
/// </summary>
|
|
public async Task<NotificationResult> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<bool> DeliverBufferedAsync(
|
|
StoreAndForwardMessage message, CancellationToken cancellationToken = default)
|
|
{
|
|
var payload = JsonSerializer.Deserialize<BufferedNotification>(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);
|
|
|
|
/// <summary>
|
|
/// Delivers an email via SMTP. Throws on failure.
|
|
/// </summary>
|
|
internal async Task DeliverAsync(
|
|
SmtpConfiguration config,
|
|
IReadOnlyList<NotificationRecipient> 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
|
|
{
|
|
/// <summary>Cancellation or an unrecognised exception — caller decides.</summary>
|
|
Unknown,
|
|
/// <summary>Retryable failure (4xx, connection/socket/protocol error, timeout).</summary>
|
|
Transient,
|
|
/// <summary>Non-retryable failure (5xx) — must be returned to the script.</summary>
|
|
Permanent,
|
|
}
|
|
|
|
/// <summary>
|
|
/// NS-002/NS-003: Classifies an SMTP failure using MailKit's typed exceptions and
|
|
/// the numeric <see cref="SmtpStatusCode"/> rather than locale-dependent substring
|
|
/// matching on the exception message. A cancellation requested by the caller is
|
|
/// never treated as a transient SMTP error.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Signals a permanent SMTP failure (5xx) that should not be retried.
|
|
/// </summary>
|
|
public class SmtpPermanentException : Exception
|
|
{
|
|
public SmtpPermanentException(string message, Exception? innerException = null)
|
|
: base(message, innerException) { }
|
|
}
|