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);
// Defense in depth: a Basic header is a replayable cleartext credential, so it never
// leaves this process over anything but https — not even if a misconfigured
// SmtpConfiguration row supplies an http:// URL. Config defects do not improve on
// retry, so this is permanent. Only the scheme and host appear in the message.
if (!string.Equals(request.Endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
var schemeError =
$"EWS endpoint '{request.Endpoint.Scheme}://{request.Endpoint.Host}' is not https; " +
"Basic credentials are only ever sent over https.";
_logger.LogError("Permanent EWS failure: {Detail}", schemeError);
throw new EwsPermanentException(schemeError);
}
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. Logged with
// the host and exception type only: the exception message is untrusted text and the
// recipient addresses are notification content.
_logger.LogWarning(
"Transient EWS failure contacting {EwsHost} ({ExceptionType}).",
request.Endpoint.Host,
ex.GetType().Name);
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);
}