From 08957cc9073f92c8c49649c1775af19b1ecae97f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 06:27:19 -0400 Subject: [PATCH] feat(notifications): EWS branch in the email delivery adapter --- .../EmailNotificationDeliveryAdapter.cs | 166 ++++++++- .../ServiceCollectionExtensions.cs | 12 +- ...mailNotificationDeliveryAdapterEwsTests.cs | 315 ++++++++++++++++++ 3 files changed, 483 insertions(+), 10 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs 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(); + } +}