diff --git a/Zetian.slnx b/Zetian.slnx index e7c32ec..ca496d1 100644 --- a/Zetian.slnx +++ b/Zetian.slnx @@ -38,6 +38,7 @@ + diff --git a/examples/Zetian.Relay.Examples/BasicRelayExample.cs b/examples/Zetian.Relay.Examples/BasicRelayExample.cs index f41cf48..360f7a8 100644 --- a/examples/Zetian.Relay.Examples/BasicRelayExample.cs +++ b/examples/Zetian.Relay.Examples/BasicRelayExample.cs @@ -62,6 +62,29 @@ public static async Task RunAsync(ILoggerFactory loggerFactory) Console.WriteLine("[INFO] Starting SMTP server with relay on port 25025..."); RelayService relayService = await server.StartWithRelayAsync(); + // Subscribe to relay delivery lifecycle events. + // These are ideal for persisting delivery outcomes to an external store (e.g. a database). + relayService.MessageDelivered += (sender, e) => + { + Console.WriteLine($"[RELAY] DELIVERED {e.QueueId} from {e.From?.Address} via {e.SmartHost}"); + }; + + relayService.MessageBounced += (sender, e) => + { + Console.WriteLine($"[RELAY] BOUNCED {e.QueueId} from {e.From?.Address}: {e.Error} (attempts: {e.RetryCount})"); + // e.g. persist the failure: await failureStore.SaveAsync(e.QueueId, e.From?.Address, e.Error); + }; + + relayService.MessageDeferred += (sender, e) => + { + Console.WriteLine($"[RELAY] DEFERRED {e.QueueId}: {e.Error} — next attempt at {e.NextRetryTime:HH:mm:ss} UTC"); + }; + + relayService.MessageExpired += (sender, e) => + { + Console.WriteLine($"[RELAY] EXPIRED {e.QueueId} from {e.From?.Address} after {e.RetryCount} attempts"); + }; + // Handle message received event - just for logging // The actual relay queuing is handled by EnableRelay's event handler server.MessageReceived += (sender, e) => diff --git a/src/Zetian.Relay/Models/EventArgs/RelayDeliveryEventArgs.cs b/src/Zetian.Relay/Models/EventArgs/RelayDeliveryEventArgs.cs new file mode 100644 index 0000000..e8d9b96 --- /dev/null +++ b/src/Zetian.Relay/Models/EventArgs/RelayDeliveryEventArgs.cs @@ -0,0 +1,73 @@ +using System; +using System.Net.Mail; +using Zetian.Relay.Abstractions; +using Zetian.Relay.Enums; + +namespace Zetian.Relay.Models.EventArgs +{ + /// + /// Event arguments raised by the relay service for delivery lifecycle + /// events (delivered, bounced, deferred and expired). Useful for logging + /// delivery outcomes to an external store such as a database. + /// + /// + /// Initializes a new instance of . + /// + public class RelayDeliveryEventArgs(IRelayMessage message) : System.EventArgs + { + /// + /// Gets the relay message the event relates to. + /// + public IRelayMessage Message { get; } = message ?? throw new ArgumentNullException(nameof(message)); + + /// + /// Gets the unique queue ID of the message. + /// + public string QueueId => Message.QueueId; + + /// + /// Gets the sender address of the message, if any. + /// + public MailAddress? From => Message.From; + + /// + /// Gets the current status of the message at the time the event was raised. + /// + public RelayStatus Status => Message.Status; + + /// + /// Gets the target smart host the message was routed to, when known. + /// + public string? SmartHost => Message.SmartHost; + + /// + /// Gets the number of delivery attempts made so far. + /// + public int RetryCount => Message.RetryCount; + + /// + /// Gets the result of the SMTP delivery attempt, when available. + /// Populated for the delivered, bounced and deferred events that follow + /// an actual delivery attempt; null when no attempt produced a result + /// (for example the expired event, or a transport-level exception). + /// + public SmtpDeliveryResult? Result { get; init; } + + /// + /// Gets the error or diagnostic message, when available. + /// Populated for the bounced, deferred and expired events. + /// + public string? Error { get; init; } + + /// + /// Gets the scheduled time of the next delivery attempt. + /// Populated only for the deferred event. + /// + public DateTime? NextRetryTime { get; init; } + + /// + /// Gets the UTC timestamp at which the event was raised. + /// + public DateTime Timestamp { get; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/src/Zetian.Relay/README.MD b/src/Zetian.Relay/README.MD index cf6dae1..08acea5 100644 --- a/src/Zetian.Relay/README.MD +++ b/src/Zetian.Relay/README.MD @@ -20,6 +20,7 @@ SMTP relay and proxy extension for Zetian SMTP Server with smart host support, q - ⚖️ **Load Balancing** - Distribute load across multiple relay servers - 🔄 **Queue Management** - Persistent queue with retry mechanisms - 📬 **Smart Host Support** - Route messages through configured relay servers +- 📡 **Delivery Events** - Lifecycle events for delivered, bounced, deferred and expired messages ## 📦 Installation @@ -307,6 +308,67 @@ var server = SmtpServerBuilder }); ``` +## 📡 Delivery Events + +The relay service raises events for every stage of a message's delivery lifecycle. +These are ideal for logging delivery outcomes to an external store (such as a database), +metrics, or alerting. All events share the `RelayDeliveryEventArgs` payload. + +| Event | Raised when | +| --- | --- | +| `MessageDelivered` | The message was successfully delivered to all recipients. | +| `MessageBounced` | Delivery permanently failed (a permanent SMTP error or the retry limit was reached). Fires regardless of whether a bounce/NDR message is generated. | +| `MessageDeferred` | A delivery attempt failed temporarily and the message was rescheduled for retry. | +| `MessageExpired` | The message exceeded its lifetime before being delivered. | + +```csharp +using Zetian.Relay.Services; +using Zetian.Relay.Extensions; + +RelayService relayService = await server.StartWithRelayAsync(); +// (or retrieve it later with: var relayService = server.GetRelayService();) + +relayService.MessageDelivered += (sender, e) => +{ + Console.WriteLine($"Delivered {e.QueueId} from {e.From?.Address} via {e.SmartHost}"); +}; + +relayService.MessageBounced += async (sender, e) => +{ + // Persist the failure to another database + await failureStore.SaveAsync(e.QueueId, e.From?.Address, e.Error, e.RetryCount); +}; + +relayService.MessageDeferred += (sender, e) => +{ + Console.WriteLine($"Deferred {e.QueueId}: {e.Error} — next attempt at {e.NextRetryTime:u}"); +}; + +relayService.MessageExpired += (sender, e) => +{ + Console.WriteLine($"Expired {e.QueueId} after {e.RetryCount} attempts"); +}; +``` + +### RelayDeliveryEventArgs + +| Property | Description | +| --- | --- | +| `Message` | The underlying `IRelayMessage` (queue ID, recipients, status, metadata, …). | +| `QueueId` | Unique queue ID of the message. | +| `From` | The sender address, if any. | +| `Status` | The message status at the time the event was raised. | +| `SmartHost` | The target smart host the message was routed to, when known. | +| `RetryCount` | Number of delivery attempts made so far. | +| `Result` | The `SmtpDeliveryResult` of the attempt, when available (delivered / bounced / deferred). | +| `Error` | The error or diagnostic message (bounced / deferred / expired). | +| `NextRetryTime` | The scheduled time of the next attempt (deferred only). | +| `Timestamp` | UTC time the event was raised. | + +> Event handlers are invoked synchronously from the delivery pipeline and any exception +> they throw is caught and logged, so a faulty handler never interrupts delivery. Keep +> handlers fast; offload heavy work (database writes, network calls) to a background queue. + ## 🔐 Authentication Methods ### AUTH PLAIN diff --git a/src/Zetian.Relay/Services/RelayService.cs b/src/Zetian.Relay/Services/RelayService.cs index 216bb00..dfcf302 100644 --- a/src/Zetian.Relay/Services/RelayService.cs +++ b/src/Zetian.Relay/Services/RelayService.cs @@ -15,6 +15,7 @@ using Zetian.Relay.Configuration; using Zetian.Relay.Enums; using Zetian.Relay.Models; +using Zetian.Relay.Models.EventArgs; using Zetian.Relay.Queue; namespace Zetian.Relay.Services @@ -27,6 +28,7 @@ public class RelayService : IDisposable private readonly ILogger _logger; private readonly ConcurrentDictionary _clientPool; private readonly SemaphoreSlim _deliverySemaphore; + private readonly Func _clientFactory; private CancellationTokenSource? _cancellationTokenSource; private Task? _processingTask; @@ -36,7 +38,8 @@ public class RelayService : IDisposable public RelayService( RelayConfiguration configuration, IRelayQueue? queue = null, - ILogger? logger = null) + ILogger? logger = null, + Func? clientFactory = null) { Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); Configuration.Validate(); @@ -45,6 +48,7 @@ public RelayService( _logger = logger ?? NullLogger.Instance; _clientPool = new ConcurrentDictionary(); _deliverySemaphore = new SemaphoreSlim(Configuration.MaxConcurrentDeliveries); + _clientFactory = clientFactory ?? CreateDefaultClient; } /// @@ -62,6 +66,29 @@ public RelayService( /// public RelayConfiguration Configuration { get; } + /// + /// Occurs when a message has been successfully delivered to all of its recipients. + /// + public event EventHandler? MessageDelivered; + + /// + /// Occurs when a message permanently fails delivery (a permanent SMTP error or the + /// maximum retry count was reached) and is therefore bounced. Raised regardless of + /// whether a bounce (NDR) message is generated. + /// + public event EventHandler? MessageBounced; + + /// + /// Occurs when a delivery attempt fails temporarily and the message is rescheduled + /// for a later retry. + /// + public event EventHandler? MessageDeferred; + + /// + /// Occurs when a message exceeds its lifetime and expires before being delivered. + /// + public event EventHandler? MessageExpired; + /// /// Starts the relay service /// @@ -214,7 +241,7 @@ await Task.Delay(Configuration.QueueProcessingInterval, cancellationToken) _logger.LogInformation("Queue processing stopped"); } - private async Task DeliverMessageAsync(IRelayMessage message, CancellationToken cancellationToken) + internal async Task DeliverMessageAsync(IRelayMessage message, CancellationToken cancellationToken) { try { @@ -226,6 +253,8 @@ private async Task DeliverMessageAsync(IRelayMessage message, CancellationToken { await Queue.UpdateStatusAsync(message.QueueId, RelayStatus.Expired, "Message expired", cancellationToken).ConfigureAwait(false); + + OnMessageExpired(new RelayDeliveryEventArgs(message) { Error = "Message expired" }); return; } @@ -267,6 +296,8 @@ await Queue.MarkDeliveredAsync( _logger.LogInformation("Message {QueueId} delivered successfully to {Count} recipients", message.QueueId, result.DeliveredRecipients.Count); + + OnMessageDelivered(new RelayDeliveryEventArgs(message) { Result = result }); } else if (result.IsTemporaryFailure && message.RetryCount < Configuration.MaxRetryCount) { @@ -277,6 +308,13 @@ await Queue.RescheduleAsync(message.QueueId, delay, cancellationToken) _logger.LogWarning("Message {QueueId} delivery deferred: {Error}", message.QueueId, result.Message); + + OnMessageDeferred(new RelayDeliveryEventArgs(message) + { + Result = result, + Error = result.Message, + NextRetryTime = message.NextDeliveryTime + }); } else { @@ -290,6 +328,12 @@ await Queue.UpdateStatusAsync( _logger.LogError("Message {QueueId} delivery failed: {Error}", message.QueueId, result.Message); + OnMessageBounced(new RelayDeliveryEventArgs(message) + { + Result = result, + Error = result.Message ?? "Delivery failed" + }); + // Send bounce if enabled if (Configuration.EnableBounceMessages && message.From != null) { @@ -308,6 +352,12 @@ await SendBounceMessageAsync(message, result.Message ?? "Delivery failed") TimeSpan delay = CalculateRetryDelay(message.RetryCount); await Queue.RescheduleAsync(message.QueueId, delay, cancellationToken) .ConfigureAwait(false); + + OnMessageDeferred(new RelayDeliveryEventArgs(message) + { + Error = ex.Message, + NextRetryTime = message.NextDeliveryTime + }); } else { @@ -317,6 +367,8 @@ await Queue.UpdateStatusAsync( RelayStatus.Failed, ex.Message, cancellationToken).ConfigureAwait(false); + + OnMessageBounced(new RelayDeliveryEventArgs(message) { Error = ex.Message }); } } } @@ -466,24 +518,24 @@ private ISmtpClient GetOrCreateClient(SmartHostConfiguration config) { string key = $"{config.Host}:{config.Port}"; - return _clientPool.GetOrAdd(key, _ => - { - SmtpRelayClient client = new(_logger as ILogger) - { - Host = config.Host, - Port = config.Port, - EnableSsl = config.UseTls, // implicit TLS (SMTPS) - UseStartTls = config.UseStartTls, // opportunistic STARTTLS - RequireTls = Configuration.RequireTls, // TLS requirement policy - SslProtocols = Configuration.SslProtocols, - ValidateServerCertificate = Configuration.ValidateServerCertificate, - Credentials = config.Credentials, - LocalDomain = Configuration.LocalDomain, - Timeout = config.ConnectionTimeout - }; + return _clientPool.GetOrAdd(key, _ => _clientFactory(config)); + } - return client; - }); + private ISmtpClient CreateDefaultClient(SmartHostConfiguration config) + { + return new SmtpRelayClient(_logger as ILogger) + { + Host = config.Host, + Port = config.Port, + EnableSsl = config.UseTls, // implicit TLS (SMTPS) + UseStartTls = config.UseStartTls, // opportunistic STARTTLS + RequireTls = Configuration.RequireTls, // TLS requirement policy + SslProtocols = Configuration.SslProtocols, + ValidateServerCertificate = Configuration.ValidateServerCertificate, + Credentials = config.Credentials, + LocalDomain = Configuration.LocalDomain, + Timeout = config.ConnectionTimeout + }; } private TimeSpan CalculateRetryDelay(int retryCount) @@ -502,6 +554,54 @@ private TimeSpan CalculateRetryDelay(int retryCount) return delay > maxDelay ? maxDelay : delay; } + private void OnMessageDelivered(RelayDeliveryEventArgs args) + { + try + { + MessageDelivered?.Invoke(this, args); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in MessageDelivered event handler"); + } + } + + private void OnMessageBounced(RelayDeliveryEventArgs args) + { + try + { + MessageBounced?.Invoke(this, args); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in MessageBounced event handler"); + } + } + + private void OnMessageDeferred(RelayDeliveryEventArgs args) + { + try + { + MessageDeferred?.Invoke(this, args); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in MessageDeferred event handler"); + } + } + + private void OnMessageExpired(RelayDeliveryEventArgs args) + { + try + { + MessageExpired?.Invoke(this, args); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in MessageExpired event handler"); + } + } + private async Task SendBounceMessageAsync(IRelayMessage message, string error) { try diff --git a/src/Zetian.Relay/Zetian.Relay.csproj b/src/Zetian.Relay/Zetian.Relay.csproj index af20269..06fd3d4 100644 --- a/src/Zetian.Relay/Zetian.Relay.csproj +++ b/src/Zetian.Relay/Zetian.Relay.csproj @@ -95,4 +95,8 @@ + + + + \ No newline at end of file diff --git a/tests/Zetian.Relay.Tests/RelayServiceEventTests.cs b/tests/Zetian.Relay.Tests/RelayServiceEventTests.cs new file mode 100644 index 0000000..5b4cd61 --- /dev/null +++ b/tests/Zetian.Relay.Tests/RelayServiceEventTests.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Net.Mail; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Zetian.Abstractions; +using Zetian.Relay.Abstractions; +using Zetian.Relay.Configuration; +using Zetian.Relay.Enums; +using Zetian.Relay.Models; +using Zetian.Relay.Models.EventArgs; +using Zetian.Relay.Queue; +using Zetian.Relay.Services; + +namespace Zetian.Relay.Tests +{ + /// + /// Tests for the delivery lifecycle events exposed by : + /// MessageDelivered, MessageBounced, MessageDeferred and MessageExpired. + /// + public class RelayServiceEventTests + { + private const string Recipient = "recipient@example.com"; + private const string SmartHost = "smtp.test:25"; + + private static Mock CreateMessage( + string from = "sender@example.com", + string to = Recipient) + { + Mock message = new(); + message.SetupGet(m => m.Id).Returns(Guid.NewGuid().ToString("N")); + message.SetupGet(m => m.From).Returns(new MailAddress(from)); + message.SetupGet(m => m.Recipients).Returns(new List { new(to) }); + message.SetupGet(m => m.Subject).Returns("Test message"); + message.SetupGet(m => m.Headers).Returns(new Dictionary()); + return message; + } + + private static RelayConfiguration CreateConfiguration( + int maxRetries = 10, + bool enableBounceMessages = false) + { + return new RelayConfiguration + { + DefaultSmartHost = new SmartHostConfiguration { Host = "smtp.test", Port = 25 }, + MaxRetryCount = maxRetries, + EnableBounceMessages = enableBounceMessages, + RequireAuthentication = false + }; + } + + private static Mock CreateClient( + Func? sendResult = null, + Exception? sendThrows = null) + { + Mock client = new(); + client.SetupGet(c => c.IsConnected).Returns(true); + client.Setup(c => c.ConnectAsync(It.IsAny())).Returns(Task.CompletedTask); + client.Setup(c => c.AuthenticateAsync(It.IsAny())).Returns(Task.CompletedTask); + client.Setup(c => c.SendRawAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(SmtpDeliveryResult.CreateSuccess(Array.Empty())); + + var send = client.Setup(c => c.SendAsync(It.IsAny(), It.IsAny())); + if (sendThrows != null) + { + send.ThrowsAsync(sendThrows); + } + else + { + send.ReturnsAsync(() => sendResult!()); + } + + return client; + } + + private static RelayService CreateService( + RelayConfiguration configuration, + InMemoryRelayQueue queue, + Mock client) + { + return new RelayService(configuration, queue, logger: null, clientFactory: _ => client.Object); + } + + [Fact] + public async Task MessageDelivered_IsRaised_OnSuccessfulDelivery() + { + InMemoryRelayQueue queue = new(); + Mock client = CreateClient( + () => SmtpDeliveryResult.CreateSuccess(new[] { Recipient })); + RelayService service = CreateService(CreateConfiguration(), queue, client); + + RelayDeliveryEventArgs? raised = null; + service.MessageDelivered += (_, e) => raised = e; + service.MessageBounced += (_, e) => throw new InvalidOperationException("Bounce should not fire"); + + IRelayMessage message = await queue.EnqueueAsync(CreateMessage().Object, SmartHost); + await service.DeliverMessageAsync(message, CancellationToken.None); + + Assert.NotNull(raised); + Assert.Equal(message.QueueId, raised!.QueueId); + Assert.Equal(RelayStatus.Delivered, raised.Status); + Assert.NotNull(raised.Result); + Assert.True(raised.Result!.Success); + Assert.Null(raised.Error); + } + + [Fact] + public async Task MessageBounced_IsRaised_OnPermanentFailure() + { + InMemoryRelayQueue queue = new(); + Mock client = CreateClient( + () => SmtpDeliveryResult.CreateFailure("Mailbox unavailable", 550)); + RelayService service = CreateService(CreateConfiguration(), queue, client); + + RelayDeliveryEventArgs? raised = null; + service.MessageBounced += (_, e) => raised = e; + + IRelayMessage message = await queue.EnqueueAsync(CreateMessage().Object, SmartHost); + await service.DeliverMessageAsync(message, CancellationToken.None); + + Assert.NotNull(raised); + Assert.Equal(message.QueueId, raised!.QueueId); + Assert.Equal(RelayStatus.Failed, raised.Status); + Assert.Equal("Mailbox unavailable", raised.Error); + Assert.NotNull(raised.Result); + Assert.False(raised.Result!.Success); + } + + [Fact] + public async Task MessageBounced_IsRaised_WhenMaxRetriesReached_OnTemporaryFailure() + { + InMemoryRelayQueue queue = new(); + Mock client = CreateClient( + () => SmtpDeliveryResult.CreateFailure("Try again later", 451, isTemporary: true)); + RelayService service = CreateService(CreateConfiguration(maxRetries: 0), queue, client); + + RelayDeliveryEventArgs? bounced = null; + RelayDeliveryEventArgs? deferred = null; + service.MessageBounced += (_, e) => bounced = e; + service.MessageDeferred += (_, e) => deferred = e; + + IRelayMessage message = await queue.EnqueueAsync(CreateMessage().Object, SmartHost); + await service.DeliverMessageAsync(message, CancellationToken.None); + + Assert.NotNull(bounced); + Assert.Null(deferred); + Assert.Equal(RelayStatus.Failed, bounced!.Status); + } + + [Fact] + public async Task MessageBounced_IsRaised_EvenWhenBounceMessagesDisabled() + { + // EnableBounceMessages defaults to false in CreateConfiguration: the event must + // still fire so callers can log failures regardless of NDR generation. + InMemoryRelayQueue queue = new(); + Mock client = CreateClient( + () => SmtpDeliveryResult.CreateFailure("Rejected", 550)); + RelayService service = CreateService( + CreateConfiguration(enableBounceMessages: false), queue, client); + + bool raised = false; + service.MessageBounced += (_, _) => raised = true; + + IRelayMessage message = await queue.EnqueueAsync(CreateMessage().Object, SmartHost); + await service.DeliverMessageAsync(message, CancellationToken.None); + + Assert.True(raised); + } + + [Fact] + public async Task MessageBounced_IsRaised_WhenBounceMessagesEnabled() + { + InMemoryRelayQueue queue = new(); + Mock client = CreateClient( + () => SmtpDeliveryResult.CreateFailure("Rejected", 550)); + RelayService service = CreateService( + CreateConfiguration(enableBounceMessages: true), queue, client); + + bool raised = false; + service.MessageBounced += (_, _) => raised = true; + + IRelayMessage message = await queue.EnqueueAsync(CreateMessage().Object, SmartHost); + await service.DeliverMessageAsync(message, CancellationToken.None); + + Assert.True(raised); + } + + [Fact] + public async Task MessageDeferred_IsRaised_OnTemporaryFailure_WithRetriesRemaining() + { + InMemoryRelayQueue queue = new(); + Mock client = CreateClient( + () => SmtpDeliveryResult.CreateFailure("Greylisted", 451, isTemporary: true)); + RelayService service = CreateService(CreateConfiguration(maxRetries: 10), queue, client); + + RelayDeliveryEventArgs? raised = null; + service.MessageDeferred += (_, e) => raised = e; + + IRelayMessage message = await queue.EnqueueAsync(CreateMessage().Object, SmartHost); + await service.DeliverMessageAsync(message, CancellationToken.None); + + Assert.NotNull(raised); + Assert.Equal(message.QueueId, raised!.QueueId); + Assert.Equal(RelayStatus.Deferred, raised.Status); + Assert.NotNull(raised.NextRetryTime); + Assert.Equal(1, raised.RetryCount); + } + + [Fact] + public async Task MessageDeferred_IsRaised_OnTransportException_WithRetriesRemaining() + { + InMemoryRelayQueue queue = new(); + Mock client = CreateClient(sendThrows: new TimeoutException("Connection timed out")); + RelayService service = CreateService(CreateConfiguration(maxRetries: 10), queue, client); + + RelayDeliveryEventArgs? raised = null; + service.MessageDeferred += (_, e) => raised = e; + + IRelayMessage message = await queue.EnqueueAsync(CreateMessage().Object, SmartHost); + await service.DeliverMessageAsync(message, CancellationToken.None); + + Assert.NotNull(raised); + Assert.Equal("Connection timed out", raised!.Error); + Assert.NotNull(raised.NextRetryTime); + } + + [Fact] + public async Task MessageBounced_IsRaised_OnTransportException_WhenNoRetriesLeft() + { + InMemoryRelayQueue queue = new(); + Mock client = CreateClient(sendThrows: new InvalidOperationException("Fatal")); + RelayService service = CreateService(CreateConfiguration(maxRetries: 0), queue, client); + + RelayDeliveryEventArgs? raised = null; + service.MessageBounced += (_, e) => raised = e; + + IRelayMessage message = await queue.EnqueueAsync(CreateMessage().Object, SmartHost); + await service.DeliverMessageAsync(message, CancellationToken.None); + + Assert.NotNull(raised); + Assert.Equal("Fatal", raised!.Error); + Assert.Equal(RelayStatus.Failed, raised.Status); + } + + [Fact] + public async Task MessageExpired_IsRaised_WhenMessageHasExpired() + { + InMemoryRelayQueue queue = new(); + Mock client = CreateClient( + () => SmtpDeliveryResult.CreateSuccess(new[] { Recipient })); + RelayService service = CreateService(CreateConfiguration(), queue, client); + + RelayDeliveryEventArgs? raised = null; + service.MessageExpired += (_, e) => raised = e; + service.MessageDelivered += (_, _) => throw new InvalidOperationException("Delivery should not fire"); + + // Build a message whose lifetime has already elapsed so DeliverMessageAsync + // takes the expiry branch before any send attempt. + RelayMessage message = new(CreateMessage().Object, SmartHost, RelayPriority.Normal, TimeSpan.FromMilliseconds(1)); + await Task.Delay(30); + + await service.DeliverMessageAsync(message, CancellationToken.None); + + Assert.NotNull(raised); + Assert.Equal(message.QueueId, raised!.QueueId); + Assert.True(raised.Message.IsExpired); + Assert.Equal("Message expired", raised.Error); + // The client must never be contacted for an already-expired message. + client.Verify(c => c.SendAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EventHandlerException_DoesNotPropagate() + { + InMemoryRelayQueue queue = new(); + Mock client = CreateClient( + () => SmtpDeliveryResult.CreateSuccess(new[] { Recipient })); + RelayService service = CreateService(CreateConfiguration(), queue, client); + + service.MessageDelivered += (_, _) => throw new InvalidOperationException("handler boom"); + + IRelayMessage message = await queue.EnqueueAsync(CreateMessage().Object, SmartHost); + + // A throwing subscriber must be swallowed by the raiser so delivery is unaffected. + Exception? caught = await Record.ExceptionAsync( + () => service.DeliverMessageAsync(message, CancellationToken.None)); + + Assert.Null(caught); + } + } +} \ No newline at end of file diff --git a/tests/Zetian.Relay.Tests/Zetian.Relay.Tests.csproj b/tests/Zetian.Relay.Tests/Zetian.Relay.Tests.csproj new file mode 100644 index 0000000..ec93e61 --- /dev/null +++ b/tests/Zetian.Relay.Tests/Zetian.Relay.Tests.csproj @@ -0,0 +1,33 @@ + + + + enable + false + preview + true + preview + enable + net11.0 + Zetian.Relay.Tests + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + \ No newline at end of file