feat(notification-outbox): add Email notification delivery adapter
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<!-- Email delivery adapter reuses the NotificationService SMTP machinery
|
||||
(ISmtpClientWrapper, SmtpPermanentException, SmtpTlsModeParser,
|
||||
OAuth2TokenService). -->
|
||||
<ProjectReference Include="../ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user