diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs
index 875ed868..fad7c9be 100644
--- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs
@@ -4,6 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationService;
+using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
@@ -18,6 +19,14 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
/// Transient) — the canonical central-side email delivery path. The prior
/// site-shaped NotificationDeliveryService was deleted with sites no longer
/// delivering notifications.
+///
+/// The configured SmtpConfiguration.Transport selects the submission mechanism:
+/// Smtp (the default for every row that stores no transport) takes the SMTP path
+/// above; Ews hands the message to instead — one
+/// CreateItem SOAP call to an on-prem Exchange endpoint. List resolution, recipient
+/// resolution, configuration selection and address validation are shared by both
+/// transports; only TLS mode is SMTP-specific.
+///
///
public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdapter
{
@@ -26,6 +35,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
private readonly OAuth2TokenService? _tokenService;
private readonly ILogger _logger;
private readonly NotificationOptions _options;
+ private readonly IEwsMailSender? _ewsMailSender;
/// Initializes a new instance of .
/// Repository for resolving notification list recipients.
@@ -33,12 +43,18 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
/// Logger instance.
/// Optional OAuth2 token service for Microsoft 365 Client Credentials auth.
/// Optional notification options providing documented fallback values.
+ ///
+ /// Optional EWS mail sender used when the selected configuration row sets the Ews transport.
+ /// Registered by AddNotificationService(); a host that omits that call leaves it null,
+ /// in which case an Ews row fails permanently rather than silently falling back to SMTP.
+ ///
public EmailNotificationDeliveryAdapter(
INotificationRepository repository,
Func smtpClientFactory,
ILogger logger,
OAuth2TokenService? tokenService = null,
- IOptions? options = null)
+ IOptions? options = null,
+ IEwsMailSender? ewsMailSender = null)
{
_repository = repository;
_smtpClientFactory = smtpClientFactory;
@@ -47,6 +63,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
// NotificationOptions supplies the documented fallback values used when a
// deployed SmtpConfiguration row leaves a field unset (non-positive).
_options = options?.Value ?? new NotificationOptions();
+ _ewsMailSender = ewsMailSender;
}
///
@@ -89,21 +106,42 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
smtpConfig.Id);
}
- // An unknown TLS mode is a configuration error that retrying cannot fix —
- // surface it as a permanent failure (SMTP TLS validation policy).
- SmtpTlsMode tlsMode;
+ // An unknown transport is a configuration error that retrying cannot fix.
+ EmailTransport transport;
try
{
- tlsMode = SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
+ transport = EmailTransportParser.Parse(smtpConfig.Transport);
}
catch (ArgumentException ex)
{
_logger.LogError(
- "Email notification to list '{List}' has an invalid SMTP TLS mode: {Reason}",
+ "Email notification to list '{List}' has an invalid transport: {Reason}",
notification.ListName, ex.Message);
return DeliveryOutcome.Permanent(ex.Message);
}
+ // TLS applies to the SMTP transport only — an EWS row's Port/TlsMode are unused.
+ // Parsed here, ahead of address validation, so the SMTP path's check order (and
+ // hence which reason surfaces for a row that is invalid in more than one way) is
+ // exactly what it was before the transport branch existed.
+ var tlsMode = SmtpTlsMode.None;
+ if (transport == EmailTransport.Smtp)
+ {
+ // An unknown TLS mode is a configuration error that retrying cannot fix —
+ // surface it as a permanent failure (SMTP TLS validation policy).
+ 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.
var addressError = EmailAddressValidator.ValidateAddresses(
@@ -121,6 +159,13 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
.Select(r => r.EmailAddress!)
.ToList();
+ // Everything above is transport-agnostic; the EWS branch takes over here.
+ if (transport == EmailTransport.Ews)
+ {
+ return await DeliverViaEwsAsync(
+ smtpConfig, recipientAddresses, notification, cancellationToken);
+ }
+
try
{
await SendAsync(smtpConfig, tlsMode, recipientAddresses,
@@ -165,6 +210,115 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
}
}
+ ///
+ /// Delivers the plain-text BCC email through Exchange Web Services.
+ ///
+ /// Every EWS-specific configuration defect (a non-HTTPS or relative endpoint, a non-Basic
+ /// auth type, a credential that is not username:password, or a missing sender
+ /// registration) is a permanent failure decided before the sender is touched — retrying
+ /// cannot fix a misconfigured row, and no half-formed request should reach Exchange.
+ ///
+ ///
+ private async Task DeliverViaEwsAsync(
+ SmtpConfiguration config,
+ IReadOnlyList recipientAddresses,
+ Notification notification,
+ CancellationToken cancellationToken)
+ {
+ if (!Uri.TryCreate(config.Host, UriKind.Absolute, out var endpoint)
+ || endpoint.Scheme != Uri.UriSchemeHttps)
+ {
+ return PermanentConfigError(
+ "EWS transport requires an absolute https:// endpoint URL in Host; "
+ + $"got '{config.Host}'");
+ }
+
+ // Basic is the only credential shape the sender builds an Authorization header for;
+ // OAuth2/NTLM are documented follow-ons, not silent fallbacks.
+ if (!config.AuthType.Equals("basic", StringComparison.OrdinalIgnoreCase))
+ {
+ return PermanentConfigError(
+ $"EWS transport supports only Basic authentication; got '{config.AuthType}'.");
+ }
+
+ // Split on the FIRST colon only: a domain-qualified user name has none, but a
+ // password legitimately may contain one.
+ var parts = config.Credentials?.Split(':', 2) ?? [];
+ if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0)
+ {
+ return PermanentConfigError("EWS credentials must be in 'username:password' form.");
+ }
+
+ if (_ewsMailSender == null)
+ {
+ return PermanentConfigError(
+ "EWS transport configured but no EWS sender is registered");
+ }
+
+ var timeoutSeconds = config.ConnectionTimeoutSeconds > 0
+ ? config.ConnectionTimeoutSeconds
+ : _options.ConnectionTimeoutSeconds;
+ var request = new EwsSendRequest(
+ endpoint,
+ parts[0],
+ parts[1],
+ config.FromAddress,
+ recipientAddresses,
+ notification.Subject,
+ notification.Body,
+ timeoutSeconds);
+
+ try
+ {
+ await _ewsMailSender.SendAsync(request, cancellationToken);
+
+ return DeliveryOutcome.Success(string.Join(", ", recipientAddresses));
+ }
+ catch (EwsPermanentException ex)
+ {
+ // Auth, wrong URL, schema fault or recipient rejection — not retried.
+ var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
+ _logger.LogError(
+ "Permanent EWS failure delivering email to list '{List}': {Detail}",
+ notification.ListName, detail);
+ return DeliveryOutcome.Permanent(detail);
+ }
+ catch (EwsTransientException ex)
+ {
+ // Availability-shaped failure (network/timeout/5xx/server-busy) — retried.
+ var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
+ _logger.LogWarning(
+ "Transient EWS failure delivering email to list '{List}': {Detail}",
+ notification.ListName, detail);
+ return DeliveryOutcome.Transient(detail);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // A caller-requested cancellation propagates; it is neither a success
+ // nor a delivery failure.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Anything the sender did not classify is treated as permanent, mirroring the
+ // SMTP path's default-to-permanent stance.
+ var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
+ _logger.LogError(
+ "Unclassified failure delivering email via EWS to list '{List}' ({ExceptionType}): {Detail}",
+ notification.ListName, ex.GetType().Name, detail);
+ return DeliveryOutcome.Permanent($"Email delivery failed: {detail}");
+ }
+
+ // Local helper: one log-and-park shape for every EWS configuration defect.
+ DeliveryOutcome PermanentConfigError(string reason)
+ {
+ _logger.LogError(
+ "Email notification to list '{List}' has an invalid EWS configuration: {Reason}",
+ notification.ListName, reason);
+ return DeliveryOutcome.Permanent(reason);
+ }
+ }
+
///
/// Delivers the plain-text BCC email via SMTP. A permanent failure surfaces as
/// ; transient failures propagate for the
diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs
index c656e6c0..ac594c30 100644
--- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs
@@ -26,10 +26,14 @@ public static class ServiceCollectionExtensions
/// This extension covers only the outbox-specific registrations. The
/// reuses the
/// SMTP machinery —
- /// Func<ISmtpClientWrapper>, OAuth2TokenService and
- /// NotificationOptions — so the caller (the Host on the central node) must also
- /// call AddNotificationService(). Re-registering those services here would
- /// duplicate them; relying on AddNotificationService keeps a single source of truth.
+ /// Func<ISmtpClientWrapper>, OAuth2TokenService,
+ /// NotificationOptions and — for configuration rows selecting the EWS transport —
+ /// IEwsMailSender with its named HTTP client — so the caller (the Host on the
+ /// central node) must also call AddNotificationService(). Re-registering those
+ /// services here would duplicate them; relying on AddNotificationService keeps a
+ /// single source of truth. Each is an optional constructor parameter on the adapter, so a
+ /// host that skips AddNotificationService() still constructs — an EWS row then
+ /// fails permanently ("no EWS sender is registered") rather than falling back to SMTP.
///
/// is registered scoped because it
/// takes a scoped
diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs
new file mode 100644
index 00000000..ac7515d4
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs
@@ -0,0 +1,315 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
+using ZB.MOM.WW.ScadaBridge.NotificationService;
+using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
+
+namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
+
+///
+/// Task 5: Tests for the EWS transport branch of the Email outbox delivery adapter —
+/// the transport parse, the EWS config-shape validation (each failure permanent and
+/// short-circuiting before the sender is touched), the request the sender receives,
+/// the typed transient/permanent outcome mapping, and the proof that an unset or
+/// "Smtp" transport still takes the untouched SMTP path.
+///
+public class EmailNotificationDeliveryAdapterEwsTests
+{
+ private const string Endpoint = "https://ews.example.test/ews/exchange.asmx";
+
+ private readonly INotificationRepository _repository = Substitute.For();
+ private readonly ISmtpClientWrapper _smtpClient = Substitute.For();
+ private readonly IEwsMailSender _ewsSender = Substitute.For();
+
+ private EmailNotificationDeliveryAdapter CreateAdapter(bool withEwsSender = true)
+ {
+ return new EmailNotificationDeliveryAdapter(
+ _repository,
+ () => _smtpClient,
+ NullLogger.Instance,
+ tokenService: null,
+ options: null,
+ ewsMailSender: withEwsSender ? _ewsSender : null);
+ }
+
+ private static Notification MakeNotification(string listName = "ops-team")
+ {
+ return new Notification(
+ Guid.NewGuid().ToString(),
+ NotificationType.Email,
+ listName,
+ "Subject",
+ "Body",
+ "site-1");
+ }
+
+ ///
+ /// Wires a resolvable list with two recipients plus a single configuration row shaped
+ /// by the caller — the one knob every case below turns.
+ ///
+ private void SetupWithConfig(SmtpConfiguration config)
+ {
+ var list = new NotificationList("ops-team") { Id = 1 };
+ var recipients = new List
+ {
+ new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
+ new("Bob", "bob@example.com") { Id = 2, NotificationListId = 1 }
+ };
+
+ _repository.GetListByNameAsync("ops-team").Returns(list);
+ _repository.GetRecipientsByListIdAsync(1).Returns(recipients);
+ _repository.GetAllSmtpConfigurationsAsync().Returns(new List { config });
+ }
+
+ private static SmtpConfiguration EwsConfig(
+ string host = Endpoint,
+ string authType = "basic",
+ string? credentials = @"dom\svc:pw",
+ string transport = "Ews")
+ {
+ return new SmtpConfiguration(host, authType, "noreply@example.com")
+ {
+ Id = 1,
+ Transport = transport,
+ Credentials = credentials,
+ ConnectionTimeoutSeconds = 45
+ };
+ }
+
+ private static SmtpConfiguration SmtpConfig(string? transport)
+ {
+ return new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
+ {
+ Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls", Transport = transport
+ };
+ }
+
+ private EwsSendRequest CapturedRequest()
+ {
+ var call = _ewsSender.ReceivedCalls().Single();
+ return (EwsSendRequest)call.GetArguments()[0]!;
+ }
+
+ private async Task AssertSenderUntouched()
+ {
+ await _ewsSender.DidNotReceive().SendAsync(
+ Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Deliver_EwsTransport_SendsThroughEwsSenderAndReturnsSuccess()
+ {
+ SetupWithConfig(EwsConfig());
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ var request = CapturedRequest();
+ Assert.Equal(new Uri(Endpoint), request.Endpoint);
+ Assert.Equal(@"dom\svc", request.Username);
+ Assert.Equal("pw", request.Password);
+ Assert.Equal("noreply@example.com", request.FromAddress);
+ Assert.Equal(new[] { "alice@example.com", "bob@example.com" }, request.BccRecipients);
+ Assert.Equal("Subject", request.Subject);
+ Assert.Equal("Body", request.Body);
+ Assert.Equal(45, request.TimeoutSeconds);
+
+ Assert.Equal(DeliveryResult.Success, outcome.Result);
+ Assert.Contains("alice@example.com", outcome.ResolvedTargets);
+ Assert.Contains("bob@example.com", outcome.ResolvedTargets);
+ Assert.Null(outcome.Error);
+
+ // The EWS branch must never open an SMTP connection.
+ await _smtpClient.DidNotReceive().ConnectAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Deliver_PasswordContainsColon_SplitsOnFirstColonOnly()
+ {
+ SetupWithConfig(EwsConfig(credentials: @"dom\svc:p:w"));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ var request = CapturedRequest();
+ Assert.Equal(@"dom\svc", request.Username);
+ Assert.Equal("p:w", request.Password);
+ Assert.Equal(DeliveryResult.Success, outcome.Result);
+ }
+
+ [Fact]
+ public async Task Deliver_EwsTransientException_ReturnsTransient()
+ {
+ SetupWithConfig(EwsConfig());
+ _ewsSender.SendAsync(Arg.Any(), Arg.Any())
+ .ThrowsAsync(new EwsTransientException("503 server busy"));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
+ Assert.Contains("server busy", outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_EwsPermanentException_ReturnsPermanent()
+ {
+ SetupWithConfig(EwsConfig());
+ _ewsSender.SendAsync(Arg.Any(), Arg.Any())
+ .ThrowsAsync(new EwsPermanentException("401 unauthorized"));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("unauthorized", outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_UnclassifiedSenderException_ReturnsPermanentAndRedactsCredential()
+ {
+ SetupWithConfig(EwsConfig(credentials: @"dom\svc:super-secret-password"));
+ _ewsSender.SendAsync(Arg.Any(), Arg.Any())
+ .ThrowsAsync(new InvalidOperationException(
+ @"boom for dom\svc:super-secret-password"));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.DoesNotContain("super-secret-password", outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_CancelledToken_ThrowsOperationCanceledException()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var list = new NotificationList("ops-team") { Id = 1 };
+ _repository.GetListByNameAsync("ops-team", Arg.Any()).Returns(list);
+ _repository.GetRecipientsByListIdAsync(1, Arg.Any())
+ .Returns(new List
+ {
+ new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
+ });
+ _repository.GetAllSmtpConfigurationsAsync(Arg.Any())
+ .Returns(new List { EwsConfig() });
+ _ewsSender.SendAsync(Arg.Any(), Arg.Any())
+ .ThrowsAsync(new OperationCanceledException(cts.Token));
+ var adapter = CreateAdapter();
+
+ await Assert.ThrowsAnyAsync(
+ () => adapter.DeliverAsync(MakeNotification(), cts.Token));
+ }
+
+ [Fact]
+ public async Task Deliver_NonHttpsEndpoint_ReturnsPermanentWithoutCallingSender()
+ {
+ SetupWithConfig(EwsConfig(host: "http://ews.example.test/ews/exchange.asmx"));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("https", outcome.Error);
+ await AssertSenderUntouched();
+ }
+
+ [Fact]
+ public async Task Deliver_NonAbsoluteEndpoint_ReturnsPermanentWithoutCallingSender()
+ {
+ SetupWithConfig(EwsConfig(host: "ews.example.test"));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("ews.example.test", outcome.Error);
+ await AssertSenderUntouched();
+ }
+
+ [Fact]
+ public async Task Deliver_NonBasicAuthType_ReturnsPermanentWithoutCallingSender()
+ {
+ SetupWithConfig(EwsConfig(authType: "oauth2"));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("Basic", outcome.Error, StringComparison.OrdinalIgnoreCase);
+ await AssertSenderUntouched();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("no-colon-here")]
+ [InlineData(":pw")]
+ [InlineData(@"dom\svc:")]
+ public async Task Deliver_MalformedCredentials_ReturnsPermanentWithoutCallingSender(
+ string? credentials)
+ {
+ SetupWithConfig(EwsConfig(credentials: credentials));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("username:password", outcome.Error);
+ await AssertSenderUntouched();
+ }
+
+ [Fact]
+ public async Task Deliver_UnknownTransport_ReturnsPermanentWithoutCallingSender()
+ {
+ SetupWithConfig(EwsConfig(transport: "Graph"));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("Graph", outcome.Error);
+ await AssertSenderUntouched();
+ await _smtpClient.DidNotReceive().ConnectAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Deliver_NoEwsSenderRegistered_ReturnsPermanent()
+ {
+ SetupWithConfig(EwsConfig());
+ var adapter = CreateAdapter(withEwsSender: false);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("EWS", outcome.Error);
+ Assert.Contains("registered", outcome.Error);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("Smtp")]
+ public async Task Deliver_SmtpOrUnsetTransport_TakesSmtpPath(string? transport)
+ {
+ SetupWithConfig(SmtpConfig(transport));
+ var adapter = CreateAdapter();
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.Success, outcome.Result);
+ await _smtpClient.Received().ConnectAsync(
+ "smtp.example.com", 587, SmtpTlsMode.StartTls,
+ Arg.Any(), Arg.Any());
+ await AssertSenderUntouched();
+ }
+}