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