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