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