From abc58e6394fcec9e610784a97616b31cc8a17704 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 06:18:08 -0400 Subject: [PATCH] feat(notifications): no-SDK EWS SOAP mail sender with typed transient/permanent classification --- .../Ews/EwsSoapMailSender.cs | 203 +++++++++++++ .../Ews/IEwsMailSender.cs | 80 ++++++ .../ServiceCollectionExtensions.cs | 11 +- .../Ews/EwsSoapMailSenderTests.cs | 269 ++++++++++++++++++ 4 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapMailSender.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/IEwsMailSender.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapMailSender.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapMailSender.cs new file mode 100644 index 00000000..5b2fe853 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapMailSender.cs @@ -0,0 +1,203 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews; + +/// +/// Sends notification mail through Exchange Web Services over plain HTTPS + SOAP — no EWS SDK, +/// honouring the project's no-new-NuGet-package rule. +/// +/// Authentication is an explicit Authorization: Basic header set on each +/// , never on the shared : the factory's +/// handler is pooled and shared across every configuration, so a client-level credential would +/// leak between callers. Negotiate/NTLM is the documented follow-on if Basic is ever disabled on +/// the EWS virtual directory. +/// +/// +/// Classification (see the design's §4.3) prefers what Exchange said over what HTTP +/// reported: a parsed response code decides the outcome even on a 500, and only an unparseable +/// body falls back to the status code. Unclassified failures default to permanent, matching the +/// SMTP adapter's stance. +/// +/// +/// The password and the base64 Basic-auth value never appear in a thrown message or a log line: +/// attempts are logged at Debug with the endpoint host and a recipient count only, and +/// every surfaced message runs through . +/// +/// +public sealed class EwsSoapMailSender : IEwsMailSender +{ + /// The named registered for EWS in ServiceCollectionExtensions. + public const string HttpClientName = "EwsMail"; + + /// Mask applied to the base64 credential, matching 's. + private const string Mask = "***REDACTED***"; + + /// + /// EWS response codes that describe load or availability rather than a defect in the request. + /// Everything else — schema faults, recipient rejections, authorization — is permanent. + /// + private static readonly HashSet TransientResponseCodes = new(StringComparer.OrdinalIgnoreCase) + { + "ErrorServerBusy", + "ErrorInternalServerTransientError", + "ErrorTimeoutExpired", + "ErrorMailboxStoreUnavailable", + "ErrorInsufficientResources", + }; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + /// Initializes a new instance of . + /// Factory creating the named "EwsMail" HTTP client per send. + /// Logger instance. + public EwsSoapMailSender(IHttpClientFactory httpClientFactory, ILogger logger) + { + ArgumentNullException.ThrowIfNull(httpClientFactory); + ArgumentNullException.ThrowIfNull(logger); + + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// + public async Task SendAsync(EwsSendRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var envelope = EwsSoapEnvelope.BuildCreateItem( + request.FromAddress, request.BccRecipients, request.Subject, request.Body); + + var packedCredential = $"{request.Username}:{request.Password}"; + var base64Credential = Convert.ToBase64String(Encoding.UTF8.GetBytes(packedCredential)); + + // Recipient COUNT only — addresses are notification content and do not belong in the log. + _logger.LogDebug( + "Submitting EWS CreateItem to {EwsHost} for {RecipientCount} recipient(s).", + request.Endpoint.Host, + request.BccRecipients.Count); + + // Per-request timeout layered over the caller's token, so a stalled Exchange surfaces as + // a TaskCanceledException with no caller cancel — classified transient below — while a + // genuine caller cancel still propagates unwrapped. HttpClient.Timeout is left alone: the + // client is shared and its timeout is not per-configuration. + using var linkedCts = request.TimeoutSeconds > 0 + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : null; + linkedCts?.CancelAfter(TimeSpan.FromSeconds(request.TimeoutSeconds)); + var sendToken = linkedCts?.Token ?? cancellationToken; + + var httpClient = _httpClientFactory.CreateClient(HttpClientName); + + try + { + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, request.Endpoint) + { + Content = new StringContent(envelope, Encoding.UTF8, "text/xml"), + }; + httpRequest.Headers.Authorization = + new AuthenticationHeaderValue("Basic", base64Credential); + + using var response = await httpClient.SendAsync(httpRequest, sendToken); + var responseBody = await response.Content.ReadAsStringAsync(sendToken); + + ThrowOnFailure(response.StatusCode, responseBody, packedCredential, base64Credential); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The caller cancelled: neither a success nor a delivery failure, so it propagates + // unchanged rather than being recorded as a transient send error. + throw; + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) + { + // Transport failure or our own timeout — availability-shaped, so retry. + throw new EwsTransientException( + Scrub( + $"EWS request to {request.Endpoint.Host} failed: {ex.Message}", + packedCredential, + base64Credential), + ex); + } + } + + /// + /// Classifies one EWS response, returning quietly on success and throwing the matching typed + /// exception otherwise. The parsed response body wins over the HTTP status whenever it is + /// intelligible; only an unparseable body falls back to the status code. + /// + private void ThrowOnFailure( + HttpStatusCode statusCode, + string responseBody, + string packedCredential, + string base64Credential) + { + var parsed = EwsResponseParser.Parse(responseBody); + + // Exchange accepted the message. A non-2xx status alongside a Success response class is + // not a thing Exchange does, but if a proxy rewrites the status the message still went. + if (parsed.Kind == EwsResponseKind.Success + || string.Equals(parsed.ResponseCode, "NoError", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var status = (int)statusCode; + + if (parsed.Kind is EwsResponseKind.Error or EwsResponseKind.Fault) + { + var code = parsed.ResponseCode ?? "(no response code)"; + var detail = Scrub( + $"EWS returned {code} (HTTP {status}): {parsed.MessageText ?? "(no message text)"}", + packedCredential, + base64Credential); + + if (parsed.ResponseCode is not null && TransientResponseCodes.Contains(parsed.ResponseCode)) + { + _logger.LogWarning("Transient EWS failure: {Detail}", detail); + throw new EwsTransientException(detail); + } + + // Includes a fault carrying no response code: the default-permanent stance keeps an + // unrecognised failure from retrying forever. + _logger.LogError("Permanent EWS failure: {Detail}", detail); + throw new EwsPermanentException(detail); + } + + // Unparseable body — nothing but the HTTP status is left to judge on. + var message = $"EWS endpoint returned {status} ({statusCode}) with an unrecognised body"; + + // 401/403 credential or authorization, 404/410 wrong URL, 405 wrong verb/endpoint: all + // configuration defects, and retrying 401 burns a domain account's lockout budget. + if (status is 401 or 403 or 404 or 405 or 410) + { + _logger.LogError("Permanent EWS failure: {Detail}", message); + throw new EwsPermanentException(message); + } + + if (status is 408 or 429 || status >= 500) + { + _logger.LogWarning("Transient EWS failure: {Detail}", message); + throw new EwsTransientException(message); + } + + // A 2xx whose body is not an EWS response is a protocol violation from something in the + // path (proxy error page, captive portal); anything else non-success is unclassified. + // Neither is fixed by replaying the same request. + _logger.LogError("Permanent EWS failure: {Detail}", message); + throw new EwsPermanentException(message); + } + + /// + /// Masks both credential shapes — the packed user:password (via the shared + /// , which also covers the bare password) and the base64 + /// Basic-auth value — out of text bound for an exception message or a log line. + /// + private static string Scrub(string text, string packedCredential, string base64Credential) + => CredentialRedactor + .Scrub(text, packedCredential) + .Replace(base64Credential, Mask, StringComparison.Ordinal); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/IEwsMailSender.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/IEwsMailSender.cs new file mode 100644 index 00000000..3065530c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/IEwsMailSender.cs @@ -0,0 +1,80 @@ +namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews; + +/// +/// One-shot EWS mail submission (CreateItem, SendOnly, BCC-only). +/// +/// The seam the central Notification Outbox's EmailNotificationDeliveryAdapter calls when a +/// SmtpConfiguration selects the EWS transport, mirroring the role +/// ISmtpClientWrapper plays for the SMTP transport. +/// +/// +public interface IEwsMailSender +{ + /// + /// Sends one message through Exchange Web Services. + /// + /// The endpoint, credential, sender, recipients and content to submit. + /// Token cancelling the send; propagates unwrapped when the caller cancels. + /// A task completing when Exchange has accepted the message. + /// + /// The send failed for a reason retrying cannot fix — authentication (401/403), a wrong URL + /// (404), a schema fault, or a recipient rejection. + /// + /// + /// The send failed for an availability-shaped reason — network/DNS/timeout, HTTP 5xx/408/429, + /// or an ErrorServerBusy-class response code — and is worth retrying. + /// + Task SendAsync(EwsSendRequest request, CancellationToken cancellationToken = default); +} + +/// +/// Everything one EWS submission needs. Carried per call rather than held on the sender so the +/// sender stays a stateless singleton and the credential never outlives the request. +/// +/// The absolute HTTPS EWS URL (for example https://host/EWS/Exchange.asmx). +/// The service account, which may be domain-qualified (domain\user). +/// The service account password. +/// The sending mailbox address. +/// The recipients; all addressed BCC so they cannot see one another. +/// The message subject. +/// The plain-text message body. +/// +/// The per-request timeout; a non-positive value leaves the default in force. +/// +public sealed record EwsSendRequest( + Uri Endpoint, + string Username, + string Password, + string FromAddress, + IReadOnlyList BccRecipients, + string Subject, + string Body, + int TimeoutSeconds); + +/// +/// Signals an availability-shaped EWS failure that is worth retrying. +/// +public class EwsTransientException : Exception +{ + /// + /// Initializes the exception with a message and optional inner exception. + /// + /// Message describing the transient EWS failure; never carries the credential. + /// Optional underlying transport exception. + public EwsTransientException(string message, Exception? innerException = null) + : base(message, innerException) { } +} + +/// +/// Signals an EWS failure that retrying cannot fix, so the notification parks immediately. +/// +public class EwsPermanentException : Exception +{ + /// + /// Initializes the exception with a message and optional inner exception. + /// + /// Message describing the permanent EWS failure; never carries the credential. + /// Optional underlying transport exception. + public EwsPermanentException(string message, Exception? innerException = null) + : base(message, innerException) { } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs index bdc1ce25..92aed580 100644 --- a/src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs @@ -1,15 +1,17 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.NotificationService.Ews; namespace ZB.MOM.WW.ScadaBridge.NotificationService; public static class ServiceCollectionExtensions { /// - /// Registers the shared SMTP delivery primitives consumed by the central Notification + /// Registers the shared email delivery primitives consumed by the central Notification /// Outbox's EmailNotificationDeliveryAdapter: , - /// , and the factory. + /// , the factory, and — + /// for the EWS transport — with its named HTTP client. /// Central-only — sites no longer deliver notifications (see /// Component-NotificationService.md), and the orphaned site-shaped /// NotificationDeliveryService + INotificationDeliveryService contract @@ -30,6 +32,11 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton>(_ => () => new MailKitSmtpClientWrapper()); + // EWS transport: a named client so the EWS calls get their own handler pool, and a + // stateless singleton sender (credentials travel per request, never on the client). + services.AddHttpClient(EwsSoapMailSender.HttpClientName); + services.TryAddSingleton(); + return services; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs new file mode 100644 index 00000000..87810846 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs @@ -0,0 +1,269 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.NotificationService.Ews; + +namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Ews; + +/// +/// Tests for the EWS SOAP mail sender: request shape (Basic header, content type, BCC-only +/// envelope) and the transient-versus-permanent classification of every failure shape. +/// +/// Every credential here is fake and every endpoint points at the reserved .test TLD — +/// no test in this file touches a network. +/// +/// +public class EwsSoapMailSenderTests +{ + private const string Username = @"dom\svc"; + private const string Password = "not-a-real-password-1234"; + private const string Recipient = "operator@example.test"; + private static readonly Uri Endpoint = new("https://ews.example.test/EWS/Exchange.asmx"); + + /// The Basic-auth value the sender is expected to produce for the fake credential. + private static readonly string ExpectedBase64 = + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}")); + + private const string SuccessBody = """ + + + + + + + NoError + + + + + + + """; + + private const string ServerBusyBody = """ + + + + + + + The server is busy. + ErrorServerBusy + + + + + + """; + + private const string SchemaValidationFaultBody = """ + + + + + a:ErrorSchemaValidation + The request failed schema validation. + + ErrorSchemaValidation + + + + + """; + + [Fact] + public async Task SendAsync_SuccessResponse_CompletesAndSendsExpectedRequest() + { + var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody); + var sender = CreateSender(handler); + + await sender.SendAsync(CreateRequest()); + + Assert.Equal("Basic", handler.CapturedScheme); + Assert.Equal(ExpectedBase64, handler.CapturedParameter); + Assert.Equal("text/xml", handler.CapturedMediaType); + Assert.Contains(Recipient, handler.CapturedContent); + } + + [Fact] + public async Task SendAsync_ServerBusyResponseBody_ThrowsTransient() + { + var handler = StubHandler.Responding(HttpStatusCode.OK, ServerBusyBody); + var sender = CreateSender(handler); + + var ex = await Assert.ThrowsAsync(() => sender.SendAsync(CreateRequest())); + + Assert.Contains("ErrorServerBusy", ex.Message); + AssertNoCredentialLeak(ex); + } + + [Fact] + public async Task SendAsync_Unauthorized_ThrowsPermanent() + { + var handler = StubHandler.Responding(HttpStatusCode.Unauthorized, string.Empty); + var sender = CreateSender(handler); + + var ex = await Assert.ThrowsAsync(() => sender.SendAsync(CreateRequest())); + + Assert.Contains("401", ex.Message); + AssertNoCredentialLeak(ex); + } + + [Fact] + public async Task SendAsync_SchemaFaultOnHttp500_ThrowsPermanent() + { + // The parsed fault beats the HTTP status: a 500 would otherwise look transient, but + // a schema-invalid request is never fixed by retrying it unchanged. + var handler = StubHandler.Responding(HttpStatusCode.InternalServerError, SchemaValidationFaultBody); + var sender = CreateSender(handler); + + var ex = await Assert.ThrowsAsync(() => sender.SendAsync(CreateRequest())); + + Assert.Contains("ErrorSchemaValidation", ex.Message); + AssertNoCredentialLeak(ex); + } + + [Fact] + public async Task SendAsync_BareServiceUnavailable_ThrowsTransient() + { + var handler = StubHandler.Responding(HttpStatusCode.ServiceUnavailable, "proxy down"); + var sender = CreateSender(handler); + + var ex = await Assert.ThrowsAsync(() => sender.SendAsync(CreateRequest())); + + Assert.Contains("503", ex.Message); + AssertNoCredentialLeak(ex); + } + + [Fact] + public async Task SendAsync_TransportFailure_ThrowsTransient() + { + var handler = StubHandler.Throwing(new HttpRequestException("No such host is known.")); + var sender = CreateSender(handler); + + var ex = await Assert.ThrowsAsync(() => sender.SendAsync(CreateRequest())); + + Assert.IsType(ex.InnerException); + AssertNoCredentialLeak(ex); + } + + [Fact] + public async Task SendAsync_RequestExceedsTimeout_ThrowsTransientNotCancellation() + { + var handler = StubHandler.Delaying(TimeSpan.FromSeconds(30)); + var sender = CreateSender(handler); + + var ex = await Assert.ThrowsAsync( + () => sender.SendAsync(CreateRequest(timeoutSeconds: 1))); + + AssertNoCredentialLeak(ex); + } + + [Fact] + public async Task SendAsync_CallerCancelled_PropagatesCancellationUnwrapped() + { + var handler = StubHandler.Delaying(TimeSpan.FromSeconds(30)); + var sender = CreateSender(handler); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var ex = await Assert.ThrowsAnyAsync( + () => sender.SendAsync(CreateRequest(timeoutSeconds: 30), cts.Token)); + + Assert.IsNotType(ex); + Assert.IsNotType(ex); + } + + [Fact] + public async Task SendAsync_SuccessStatusWithUnparseableBody_ThrowsPermanent() + { + // A 200 whose body is not an EWS response is a protocol violation from something in + // the path (captive portal / proxy error page); replaying it changes nothing. + var handler = StubHandler.Responding(HttpStatusCode.OK, "not xml at all"); + var sender = CreateSender(handler); + + var ex = await Assert.ThrowsAsync(() => sender.SendAsync(CreateRequest())); + + AssertNoCredentialLeak(ex); + } + + /// + /// Neither the password nor the base64 Basic-auth value may reach an exception message — + /// those messages land in the operational log and in the notification's stored error. + /// + private static void AssertNoCredentialLeak(Exception ex) + { + Assert.DoesNotContain(Password, ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain(ExpectedBase64, ex.Message, StringComparison.Ordinal); + } + + private static EwsSendRequest CreateRequest(int timeoutSeconds = 0) + => new( + Endpoint, + Username, + Password, + "alerts@example.test", + new[] { Recipient }, + "Alarm raised", + "Tank 4 level high.", + timeoutSeconds); + + private static EwsSoapMailSender CreateSender(HttpMessageHandler handler) + => new(new FakeHttpClientFactory(handler), NullLogger.Instance); + + /// Hands every named client the one stub handler under test. + private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } + + /// + /// Captures the outgoing request (auth header, content type, serialized body) and answers + /// with a canned response, a thrown exception, or a delay long enough to trip a timeout. + /// + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func> _responder; + + private StubHandler(Func> responder) + => _responder = responder; + + public string? CapturedScheme { get; private set; } + + public string? CapturedParameter { get; private set; } + + public string? CapturedMediaType { get; private set; } + + public string CapturedContent { get; private set; } = string.Empty; + + public static StubHandler Responding(HttpStatusCode status, string body) + => new(_ => Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "text/xml"), + })); + + public static StubHandler Throwing(Exception exception) + => new(_ => Task.FromException(exception)); + + public static StubHandler Delaying(TimeSpan delay) + => new(async token => + { + await Task.Delay(delay, token); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + CapturedScheme = request.Headers.Authorization?.Scheme; + CapturedParameter = request.Headers.Authorization?.Parameter; + CapturedMediaType = request.Content?.Headers.ContentType?.MediaType; + CapturedContent = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + + return await _responder(cancellationToken); + } + } +}