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);
+ }
+ }
+}