From abb1581d4587d2b819c7f60cc6f6e7bfd96b7992 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 06:12:46 -0400 Subject: [PATCH] feat(notifications): EWS CreateItem envelope builder + response parser --- .../Ews/EwsResponseParser.cs | 105 ++++++++++++++ .../Ews/EwsSoapEnvelope.cs | 93 +++++++++++++ .../Ews/EwsResponseParserTests.cs | 128 ++++++++++++++++++ .../Ews/EwsSoapEnvelopeTests.cs | 99 ++++++++++++++ 4 files changed, 425 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsResponseParser.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapEnvelope.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsResponseParser.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsResponseParser.cs new file mode 100644 index 00000000..c4bf7750 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsResponseParser.cs @@ -0,0 +1,105 @@ +using System.Xml.Linq; + +namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews; + +/// +/// The shape of an EWS response body. This says what Exchange answered, not +/// whether the failure is worth retrying — that judgement belongs to the sender's classifier. +/// +public enum EwsResponseKind +{ + /// The CreateItemResponseMessage reported ResponseClass="Success". + Success, + + /// The CreateItemResponseMessage reported a non-success response class. + Error, + + /// The body carried a SOAP Fault instead of a response message. + Fault, + + /// The body was not XML, was empty, or was XML of no recognised EWS shape. + Unparseable, +} + +/// +/// The outcome of parsing an EWS response body. +/// +/// The response shape. +/// +/// The EWS response code (for example NoError, ErrorServerBusy), or null +/// when the body carried none. +/// +/// The human-readable message or SOAP fault string, when present. +public sealed record EwsParseResult(EwsResponseKind Kind, string? ResponseCode, string? MessageText); + +/// +/// Parses an EWS CreateItem response body into a . +/// +/// Pure and judgement-free by design: it reports the response shape and code and never decides +/// transient versus permanent, so the retry policy stays in one place in the sender. +/// +/// +public static class EwsResponseParser +{ + private static readonly XNamespace Soap = "http://schemas.xmlsoap.org/soap/envelope/"; + private static readonly XNamespace Messages = "http://schemas.microsoft.com/exchange/services/2006/messages"; + + private static readonly EwsParseResult UnparseableResult = + new(EwsResponseKind.Unparseable, ResponseCode: null, MessageText: null); + + /// + /// Parses a response body. A malformed or unrecognised payload is reported as + /// rather than throwing — a broken response must not + /// take out the delivery path with an unhandled exception. + /// + /// The raw HTTP response body returned by the EWS endpoint. + /// The parsed shape, response code and message text. + public static EwsParseResult Parse(string responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + { + return UnparseableResult; + } + + XDocument document; + try + { + document = XDocument.Parse(responseBody); + } + catch (System.Xml.XmlException) + { + return UnparseableResult; + } + + var responseMessage = document.Descendants(Messages + "CreateItemResponseMessage").FirstOrDefault(); + if (responseMessage is not null) + { + var responseClass = (string?)responseMessage.Attribute("ResponseClass"); + var kind = string.Equals(responseClass, "Success", StringComparison.Ordinal) + ? EwsResponseKind.Success + : EwsResponseKind.Error; + + return new EwsParseResult( + kind, + responseMessage.Element(Messages + "ResponseCode")?.Value, + responseMessage.Element(Messages + "MessageText")?.Value); + } + + var fault = document.Descendants(Soap + "Fault").FirstOrDefault(); + if (fault is not null) + { + // faultstring is unqualified in SOAP 1.1, and the detail's ResponseCode appears under + // several vendor namespaces (errors/types) depending on the fault — match on local name. + var faultString = fault.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "faultstring")?.Value; + + var detail = fault.Descendants().FirstOrDefault(e => e.Name.LocalName == "detail"); + var responseCode = detail?.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "ResponseCode")?.Value; + + return new EwsParseResult(EwsResponseKind.Fault, responseCode, faultString); + } + + return UnparseableResult; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapEnvelope.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapEnvelope.cs new file mode 100644 index 00000000..e8484279 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationService/Ews/EwsSoapEnvelope.cs @@ -0,0 +1,93 @@ +using System.Text; +using System.Xml.Linq; + +namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews; + +/// +/// Builds the EWS CreateItem SOAP envelope used to send a notification email +/// through Exchange Web Services. +/// +/// Pure and I/O-free: the HTTP send lives in the sender that consumes this. The envelope is +/// composed with LINQ-to-XML rather than string concatenation so operator-authored subject and +/// body content is XML-escaped by construction. +/// +/// +public static class EwsSoapEnvelope +{ + private static readonly XNamespace Soap = "http://schemas.xmlsoap.org/soap/envelope/"; + private static readonly XNamespace Types = "http://schemas.microsoft.com/exchange/services/2006/types"; + private static readonly XNamespace Messages = "http://schemas.microsoft.com/exchange/services/2006/messages"; + + /// + /// Builds a CreateItem request that sends one plain-text message. + /// + /// MessageDisposition="SendOnly" sends without keeping a Sent Items copy, and every + /// recipient is addressed via BccRecipients so recipients cannot see one another — + /// both mirror the SMTP delivery path's semantics. + /// + /// + /// The sending mailbox address stamped into t:From. + /// + /// The recipient addresses; each becomes one t:Mailbox under t:BccRecipients. + /// Callers are expected to supply at least one — an empty list yields an envelope Exchange + /// rejects as schema-invalid. + /// + /// The message subject; escaped, not interpreted. + /// The plain-text message body (BodyType="Text"); escaped, not interpreted. + /// The complete SOAP envelope as XML text, prefixed with a UTF-8 declaration. + public static string BuildCreateItem( + string fromAddress, + IReadOnlyList bccRecipients, + string subject, + string body) + { + ArgumentNullException.ThrowIfNull(fromAddress); + ArgumentNullException.ThrowIfNull(bccRecipients); + ArgumentNullException.ThrowIfNull(subject); + ArgumentNullException.ThrowIfNull(body); + + // EWS MessageType is an xs:sequence: Subject, Body, BccRecipients, From must appear in + // this order or the server answers ErrorSchemaValidation. + var message = new XElement( + Types + "Message", + new XElement(Types + "Subject", subject), + new XElement(Types + "Body", new XAttribute("BodyType", "Text"), body), + new XElement(Types + "BccRecipients", bccRecipients.Select(Mailbox)), + new XElement(Types + "From", Mailbox(fromAddress))); + + var document = new XDocument( + new XDeclaration("1.0", "utf-8", null), + new XElement( + Soap + "Envelope", + new XAttribute(XNamespace.Xmlns + "soap", Soap.NamespaceName), + new XAttribute(XNamespace.Xmlns + "t", Types.NamespaceName), + new XAttribute(XNamespace.Xmlns + "m", Messages.NamespaceName), + new XElement( + Soap + "Header", + new XElement(Types + "RequestServerVersion", new XAttribute("Version", "Exchange2013"))), + new XElement( + Soap + "Body", + new XElement( + Messages + "CreateItem", + new XAttribute("MessageDisposition", "SendOnly"), + new XElement(Messages + "Items", message))))); + + // XDocument.ToString() drops the declaration; saving through a writer that reports UTF-8 + // keeps it, and keeps the declared encoding honest. + using var writer = new Utf8StringWriter(); + document.Save(writer); + return writer.ToString(); + } + + private static XElement Mailbox(string address) + => new(Types + "Mailbox", new XElement(Types + "EmailAddress", address)); + + /// + /// A that reports UTF-8 so + /// emits encoding="utf-8" instead of the default UTF-16. + /// + private sealed class Utf8StringWriter : StringWriter + { + public override Encoding Encoding => Encoding.UTF8; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs new file mode 100644 index 00000000..4272fd2a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsResponseParserTests.cs @@ -0,0 +1,128 @@ +using ZB.MOM.WW.ScadaBridge.NotificationService.Ews; + +namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Ews; + +/// +/// Tests for the pure EWS response parser. The parser reports shape only +/// (success / error / SOAP fault / unparseable) plus the response code — the +/// transient-vs-permanent judgement belongs to the sender's classifier. +/// +public class EwsResponseParserTests +{ + private const string SuccessBody = """ + + + + + + + NoError + + + + + + + """; + + private const string ErrorBody = """ + + + + + + + server busy + ErrorServerBusy + 0 + + + + + + """; + + private const string FaultWithResponseCodeBody = """ + + + + + a:ErrorSchemaValidation + The request failed schema validation. + + ErrorSchemaValidation + The request failed schema validation. + + + + + """; + + private const string FaultWithoutResponseCodeBody = """ + + + + + s:Server + An internal server error occurred. + boom + + + + """; + + [Fact] + public void Parse_SuccessResponse_ReturnsSuccessWithNoError() + { + var result = EwsResponseParser.Parse(SuccessBody); + + Assert.Equal(EwsResponseKind.Success, result.Kind); + Assert.Equal("NoError", result.ResponseCode); + } + + [Fact] + public void Parse_ErrorResponse_ReturnsErrorWithCodeAndMessage() + { + var result = EwsResponseParser.Parse(ErrorBody); + + Assert.Equal(EwsResponseKind.Error, result.Kind); + Assert.Equal("ErrorServerBusy", result.ResponseCode); + Assert.Equal("server busy", result.MessageText); + } + + [Fact] + public void Parse_SoapFaultWithDetailResponseCode_ReturnsFaultWithCode() + { + var result = EwsResponseParser.Parse(FaultWithResponseCodeBody); + + Assert.Equal(EwsResponseKind.Fault, result.Kind); + Assert.Equal("ErrorSchemaValidation", result.ResponseCode); + Assert.Equal("The request failed schema validation.", result.MessageText); + } + + [Fact] + public void Parse_SoapFaultWithoutResponseCode_ReturnsFaultWithNullCode() + { + var result = EwsResponseParser.Parse(FaultWithoutResponseCodeBody); + + Assert.Equal(EwsResponseKind.Fault, result.Kind); + Assert.Null(result.ResponseCode); + Assert.Equal("An internal server error occurred.", result.MessageText); + } + + [Theory] + [InlineData("not xml")] + [InlineData("")] + [InlineData(" ")] + [InlineData("")] + public void Parse_GarbageOrUnrelatedPayload_ReturnsUnparseable(string body) + { + var result = EwsResponseParser.Parse(body); + + Assert.Equal(EwsResponseKind.Unparseable, result.Kind); + Assert.Null(result.ResponseCode); + Assert.Null(result.MessageText); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs new file mode 100644 index 00000000..37df23ab --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapEnvelopeTests.cs @@ -0,0 +1,99 @@ +using System.Xml.Linq; +using ZB.MOM.WW.ScadaBridge.NotificationService.Ews; + +namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Ews; + +/// +/// Tests for the pure EWS CreateItem SOAP envelope builder: the fixed schema +/// sequence Exchange validates against, BCC-only recipients, and XML escaping of +/// operator-authored subject/body content. +/// +public class EwsSoapEnvelopeTests +{ + private static readonly XNamespace Soap = "http://schemas.xmlsoap.org/soap/envelope/"; + private static readonly XNamespace Types = "http://schemas.microsoft.com/exchange/services/2006/types"; + private static readonly XNamespace Messages = "http://schemas.microsoft.com/exchange/services/2006/messages"; + + private static XDocument Build( + string from = "scada@example.com", + IReadOnlyList? bcc = null, + string subject = "Subject", + string body = "Body") + => XDocument.Parse(EwsSoapEnvelope.BuildCreateItem(from, bcc ?? ["one@example.com"], subject, body)); + + [Fact] + public void BuildCreateItem_SetsSendOnlyDispositionAndExchange2013RequestVersion() + { + var doc = Build(); + + var createItem = doc.Descendants(Messages + "CreateItem").Single(); + Assert.Equal("SendOnly", (string?)createItem.Attribute("MessageDisposition")); + + var version = doc.Descendants(Types + "RequestServerVersion").Single(); + Assert.Equal("Exchange2013", (string?)version.Attribute("Version")); + Assert.Single(doc.Descendants(Soap + "Header").Single().Elements()); + } + + [Fact] + public void BuildCreateItem_EscapesMarkupInSubjectAndBody() + { + const string subject = "Alarm & \"critical\""; + const string body = "value < 5 & tag=\"A\" > threshold"; + + var doc = Build(subject: subject, body: body); + + // Round-tripping through the parser proves the content was escaped, not concatenated. + Assert.Equal(subject, doc.Descendants(Types + "Subject").Single().Value); + Assert.Equal(body, doc.Descendants(Types + "Body").Single().Value); + Assert.Equal("Text", (string?)doc.Descendants(Types + "Body").Single().Attribute("BodyType")); + } + + [Fact] + public void BuildCreateItem_PutsEveryRecipientInBccAndNeverEmitsToRecipients() + { + string[] recipients = ["a@example.com", "b@example.com", "c@example.com"]; + + var doc = Build(bcc: recipients); + + var bcc = doc.Descendants(Types + "BccRecipients").Single(); + var addresses = bcc.Elements(Types + "Mailbox") + .Select(m => m.Element(Types + "EmailAddress")!.Value) + .ToArray(); + Assert.Equal(recipients, addresses); + + // Recipient privacy is the whole reason for BCC-only: no To/Cc may leak the list. + Assert.DoesNotContain(doc.Descendants(), e => e.Name.LocalName is "ToRecipients" or "CcRecipients"); + } + + [Fact] + public void BuildCreateItem_EmitsFromMailbox() + { + var doc = Build(from: "scada-alerts@example.com"); + + var from = doc.Descendants(Types + "From").Single(); + var mailbox = from.Elements(Types + "Mailbox").Single(); + Assert.Equal("scada-alerts@example.com", mailbox.Element(Types + "EmailAddress")!.Value); + } + + [Fact] + public void BuildCreateItem_OrdersMessageChildrenPerEwsSchemaSequence() + { + var doc = Build(); + + // MessageType is an xs:sequence — any other order is ErrorSchemaValidation at the server. + var message = doc.Descendants(Types + "Message").Single(); + Assert.Equal( + new[] { "Subject", "Body", "BccRecipients", "From" }, + message.Elements().Select(e => e.Name.LocalName).ToArray()); + } + + [Fact] + public void BuildCreateItem_StartsWithUtf8XmlDeclaration() + { + var xml = EwsSoapEnvelope.BuildCreateItem("a@example.com", ["b@example.com"], "s", "b"); + + Assert.StartsWith("", StringComparison.Ordinal)]; + Assert.Contains("utf-8", declaration, StringComparison.OrdinalIgnoreCase); + } +}