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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
using MailKit;
|
||||||
|
using MailKit.Net.Smtp;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationService;
|
||||||
|
|
||||||
|
namespace ScadaLink.NotificationOutbox.Tests.Delivery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 12: Tests for the Email outbox delivery adapter — list/recipient/SMTP-config
|
||||||
|
/// resolution, SMTP send, and the three-way (Success / Permanent / Transient) outcome
|
||||||
|
/// classification.
|
||||||
|
/// </summary>
|
||||||
|
public class EmailNotificationDeliveryAdapterTests
|
||||||
|
{
|
||||||
|
private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
|
||||||
|
private readonly ISmtpClientWrapper _smtpClient = Substitute.For<ISmtpClientWrapper>();
|
||||||
|
|
||||||
|
private EmailNotificationDeliveryAdapter CreateAdapter()
|
||||||
|
{
|
||||||
|
return new EmailNotificationDeliveryAdapter(
|
||||||
|
_repository,
|
||||||
|
() => _smtpClient,
|
||||||
|
NullLogger<EmailNotificationDeliveryAdapter>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Notification MakeNotification(string listName = "ops-team")
|
||||||
|
{
|
||||||
|
return new Notification(
|
||||||
|
Guid.NewGuid().ToString(),
|
||||||
|
NotificationType.Email,
|
||||||
|
listName,
|
||||||
|
"Subject",
|
||||||
|
"Body",
|
||||||
|
"site-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupHappyPath()
|
||||||
|
{
|
||||||
|
var list = new NotificationList("ops-team") { Id = 1 };
|
||||||
|
var recipients = new List<NotificationRecipient>
|
||||||
|
{
|
||||||
|
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
|
||||||
|
new("Bob", "bob@example.com") { Id = 2, NotificationListId = 1 }
|
||||||
|
};
|
||||||
|
var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
|
||||||
|
};
|
||||||
|
|
||||||
|
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||||
|
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
|
||||||
|
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Type_IsEmail()
|
||||||
|
{
|
||||||
|
Assert.Equal(NotificationType.Email, CreateAdapter().Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_HappyPath_ReturnsSuccessWithResolvedTargets()
|
||||||
|
{
|
||||||
|
SetupHappyPath();
|
||||||
|
var adapter = CreateAdapter();
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||||
|
Assert.NotNull(outcome.ResolvedTargets);
|
||||||
|
Assert.Contains("alice@example.com", outcome.ResolvedTargets);
|
||||||
|
Assert.Contains("bob@example.com", outcome.ResolvedTargets);
|
||||||
|
Assert.Null(outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_ListNotFound_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
_repository.GetListByNameAsync("missing").Returns((NotificationList?)null);
|
||||||
|
var adapter = CreateAdapter();
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification("missing"));
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
Assert.Contains("missing", outcome.Error);
|
||||||
|
Assert.Contains("not found", outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_NoRecipients_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
var list = new NotificationList("ops-team") { Id = 1 };
|
||||||
|
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||||
|
_repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>());
|
||||||
|
var adapter = CreateAdapter();
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
Assert.Contains("recipient", outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_NoSmtpConfig_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
var list = new NotificationList("ops-team") { Id = 1 };
|
||||||
|
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||||
|
_repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>
|
||||||
|
{
|
||||||
|
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
|
||||||
|
});
|
||||||
|
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration>());
|
||||||
|
var adapter = CreateAdapter();
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
Assert.Contains("SMTP", outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_SmtpPermanentException_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
SetupHappyPath();
|
||||||
|
_smtpClient
|
||||||
|
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new SmtpPermanentException("550 mailbox unavailable"));
|
||||||
|
var adapter = CreateAdapter();
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
Assert.Contains("mailbox unavailable", outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_Smtp5xxCommandException_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
SetupHappyPath();
|
||||||
|
_smtpClient
|
||||||
|
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new SmtpCommandException(
|
||||||
|
SmtpErrorCode.RecipientNotAccepted, SmtpStatusCode.MailboxUnavailable, "rejected"));
|
||||||
|
var adapter = CreateAdapter();
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_SocketException_ReturnsTransient()
|
||||||
|
{
|
||||||
|
SetupHappyPath();
|
||||||
|
_smtpClient
|
||||||
|
.ConnectAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
|
||||||
|
Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new SocketException((int)SocketError.ConnectionRefused));
|
||||||
|
var adapter = CreateAdapter();
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||||
|
Assert.NotNull(outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_Smtp4xxCommandException_ReturnsTransient()
|
||||||
|
{
|
||||||
|
SetupHappyPath();
|
||||||
|
_smtpClient
|
||||||
|
.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new SmtpCommandException(
|
||||||
|
SmtpErrorCode.MessageNotAccepted, SmtpStatusCode.MailboxBusy, "try later"));
|
||||||
|
var adapter = CreateAdapter();
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user