feat(notifications): EWS CreateItem envelope builder + response parser

This commit is contained in:
Joseph Doherty
2026-08-10 06:12:46 -04:00
parent 3da84825fc
commit abb1581d45
4 changed files with 425 additions and 0 deletions
@@ -0,0 +1,105 @@
using System.Xml.Linq;
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
/// <summary>
/// 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.
/// </summary>
public enum EwsResponseKind
{
/// <summary>The <c>CreateItemResponseMessage</c> reported <c>ResponseClass="Success"</c>.</summary>
Success,
/// <summary>The <c>CreateItemResponseMessage</c> reported a non-success response class.</summary>
Error,
/// <summary>The body carried a SOAP <c>Fault</c> instead of a response message.</summary>
Fault,
/// <summary>The body was not XML, was empty, or was XML of no recognised EWS shape.</summary>
Unparseable,
}
/// <summary>
/// The outcome of parsing an EWS response body.
/// </summary>
/// <param name="Kind">The response shape.</param>
/// <param name="ResponseCode">
/// The EWS response code (for example <c>NoError</c>, <c>ErrorServerBusy</c>), or <c>null</c>
/// when the body carried none.
/// </param>
/// <param name="MessageText">The human-readable message or SOAP fault string, when present.</param>
public sealed record EwsParseResult(EwsResponseKind Kind, string? ResponseCode, string? MessageText);
/// <summary>
/// Parses an EWS <c>CreateItem</c> response body into a <see cref="EwsParseResult"/>.
/// <para>
/// 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.
/// </para>
/// </summary>
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);
/// <summary>
/// Parses a response body. A malformed or unrecognised payload is reported as
/// <see cref="EwsResponseKind.Unparseable"/> rather than throwing — a broken response must not
/// take out the delivery path with an unhandled exception.
/// </summary>
/// <param name="responseBody">The raw HTTP response body returned by the EWS endpoint.</param>
/// <returns>The parsed shape, response code and message text.</returns>
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;
}
}
@@ -0,0 +1,93 @@
using System.Text;
using System.Xml.Linq;
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
/// <summary>
/// Builds the EWS <c>CreateItem</c> SOAP envelope used to send a notification email
/// through Exchange Web Services.
/// <para>
/// 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.
/// </para>
/// </summary>
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";
/// <summary>
/// Builds a <c>CreateItem</c> request that sends one plain-text message.
/// <para>
/// <c>MessageDisposition="SendOnly"</c> sends without keeping a Sent Items copy, and every
/// recipient is addressed via <c>BccRecipients</c> so recipients cannot see one another —
/// both mirror the SMTP delivery path's semantics.
/// </para>
/// </summary>
/// <param name="fromAddress">The sending mailbox address stamped into <c>t:From</c>.</param>
/// <param name="bccRecipients">
/// The recipient addresses; each becomes one <c>t:Mailbox</c> under <c>t:BccRecipients</c>.
/// Callers are expected to supply at least one — an empty list yields an envelope Exchange
/// rejects as schema-invalid.
/// </param>
/// <param name="subject">The message subject; escaped, not interpreted.</param>
/// <param name="body">The plain-text message body (<c>BodyType="Text"</c>); escaped, not interpreted.</param>
/// <returns>The complete SOAP envelope as XML text, prefixed with a UTF-8 declaration.</returns>
public static string BuildCreateItem(
string fromAddress,
IReadOnlyList<string> 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));
/// <summary>
/// A <see cref="StringWriter"/> that reports UTF-8 so <see cref="XDocument.Save(TextWriter)"/>
/// emits <c>encoding="utf-8"</c> instead of the default UTF-16.
/// </summary>
private sealed class Utf8StringWriter : StringWriter
{
public override Encoding Encoding => Encoding.UTF8;
}
}