329 lines
13 KiB
C#
329 lines
13 KiB
C#
using System.Net.Sockets;
|
|
using MailKit;
|
|
using MailKit.Net.Smtp;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using MimeKit;
|
|
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 = 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 = ScrubCredentials(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 (IsTransientSmtpError(ex, cancellationToken))
|
|
{
|
|
// Transient SMTP failure (4xx, socket/protocol/timeout) — eligible for retry.
|
|
var detail = ScrubCredentials(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 = ScrubCredentials(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 (ClassifySmtpError(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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates the sender and recipient email addresses, returning a human-readable
|
|
/// error string if any is malformed, or null if all parse (mirrors NS-008).
|
|
/// </summary>
|
|
private static string? ValidateAddresses(
|
|
string fromAddress, IReadOnlyList<NotificationRecipient> 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;
|
|
}
|
|
|
|
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 not be retried.</summary>
|
|
Permanent,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Classifies an SMTP failure using MailKit's typed exceptions and the numeric
|
|
/// <see cref="SmtpStatusCode"/> rather than locale-dependent substring matching
|
|
/// (mirrors NS-002/NS-003 in <c>NotificationDeliveryService</c>).
|
|
/// </summary>
|
|
private static SmtpErrorClass ClassifySmtpError(Exception ex, CancellationToken cancellationToken)
|
|
{
|
|
if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
|
|
{
|
|
return SmtpErrorClass.Unknown;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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>
|
|
/// Masks SMTP credential secrets out of free text (typically an SMTP server's
|
|
/// exception message) before it is logged or stored. Mirrors
|
|
/// <c>NotificationService.CredentialRedactor</c>, which is internal to that
|
|
/// project and so cannot be referenced here.
|
|
/// </summary>
|
|
private static string ScrubCredentials(string? text, string? credentials)
|
|
{
|
|
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials))
|
|
{
|
|
return text ?? string.Empty;
|
|
}
|
|
|
|
var result = text;
|
|
|
|
// Mask each colon-delimited component (user, password, tenant, clientId,
|
|
// clientSecret) and the whole packed string. Longest first so a component
|
|
// that is a substring of another is still fully masked.
|
|
var parts = credentials.Split(':')
|
|
.Where(p => p.Length >= 4)
|
|
.Append(credentials)
|
|
.Distinct()
|
|
.OrderByDescending(p => p.Length);
|
|
|
|
foreach (var part in parts)
|
|
{
|
|
result = result.Replace(part, "***REDACTED***", StringComparison.Ordinal);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|