219 lines
9.5 KiB
C#
219 lines
9.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Task 12: Email channel delivery adapter for the central notification outbox.
|
|
///
|
|
/// Reuses the <see cref="ScadaLink.NotificationService"/> SMTP machinery —
|
|
/// <see cref="ISmtpClientWrapper"/>, <see cref="SmtpTlsModeParser"/>,
|
|
/// <see cref="OAuth2TokenService"/> and the typed <see cref="SmtpPermanentException"/>.
|
|
/// The connect/auth/send/disconnect sequence and error classification mirror
|
|
/// <c>NotificationDeliveryService.DeliverAsync</c>; this adapter, however, maps the
|
|
/// result to the outbox's three-way <see cref="DeliveryOutcome"/> (Success / Permanent
|
|
/// / Transient) rather than the S&F-coupled <c>NotificationResult</c>, which cannot
|
|
/// distinguish a permanent failure from a buffered transient one.
|
|
/// </summary>
|
|
public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdapter
|
|
{
|
|
private readonly INotificationRepository _repository;
|
|
private readonly Func<ISmtpClientWrapper> _smtpClientFactory;
|
|
private readonly OAuth2TokenService? _tokenService;
|
|
private readonly ILogger<EmailNotificationDeliveryAdapter> _logger;
|
|
private readonly NotificationOptions _options;
|
|
|
|
public EmailNotificationDeliveryAdapter(
|
|
INotificationRepository repository,
|
|
Func<ISmtpClientWrapper> smtpClientFactory,
|
|
ILogger<EmailNotificationDeliveryAdapter> logger,
|
|
OAuth2TokenService? tokenService = null,
|
|
IOptions<NotificationOptions>? 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();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public NotificationType Type => NotificationType.Email;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DeliveryOutcome> 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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delivers the plain-text BCC email via SMTP. Mirrors the connect/auth/send/
|
|
/// disconnect sequence of <c>NotificationDeliveryService.DeliverAsync</c>: a
|
|
/// permanent failure surfaces as <see cref="SmtpPermanentException"/>; transient
|
|
/// failures propagate for the caller's classifier; the connection is always torn
|
|
/// down in the finally block.
|
|
/// </summary>
|
|
private async Task SendAsync(
|
|
SmtpConfiguration config,
|
|
SmtpTlsMode tlsMode,
|
|
IReadOnlyList<string> 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|