feat(notifications): EWS branch in the email delivery adapter

This commit is contained in:
Joseph Doherty
2026-08-10 06:27:19 -04:00
parent 2ee5586406
commit 08957cc907
3 changed files with 483 additions and 10 deletions
@@ -4,6 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationService;
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
@@ -18,6 +19,14 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
/// Transient) — the canonical central-side email delivery path. The prior
/// site-shaped <c>NotificationDeliveryService</c> was deleted with sites no longer
/// delivering notifications.
/// <para>
/// The configured <c>SmtpConfiguration.Transport</c> selects the submission mechanism:
/// <c>Smtp</c> (the default for every row that stores no transport) takes the SMTP path
/// above; <c>Ews</c> hands the message to <see cref="IEwsMailSender"/> instead — one
/// <c>CreateItem</c> SOAP call to an on-prem Exchange endpoint. List resolution, recipient
/// resolution, configuration selection and address validation are shared by both
/// transports; only TLS mode is SMTP-specific.
/// </para>
/// </summary>
public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdapter
{
@@ -26,6 +35,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
private readonly OAuth2TokenService? _tokenService;
private readonly ILogger<EmailNotificationDeliveryAdapter> _logger;
private readonly NotificationOptions _options;
private readonly IEwsMailSender? _ewsMailSender;
/// <summary>Initializes a new instance of <see cref="EmailNotificationDeliveryAdapter"/>.</summary>
/// <param name="repository">Repository for resolving notification list recipients.</param>
@@ -33,12 +43,18 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
/// <param name="logger">Logger instance.</param>
/// <param name="tokenService">Optional OAuth2 token service for Microsoft 365 Client Credentials auth.</param>
/// <param name="options">Optional notification options providing documented fallback values.</param>
/// <param name="ewsMailSender">
/// Optional EWS mail sender used when the selected configuration row sets the Ews transport.
/// Registered by <c>AddNotificationService()</c>; a host that omits that call leaves it null,
/// in which case an Ews row fails permanently rather than silently falling back to SMTP.
/// </param>
public EmailNotificationDeliveryAdapter(
INotificationRepository repository,
Func<ISmtpClientWrapper> smtpClientFactory,
ILogger<EmailNotificationDeliveryAdapter> logger,
OAuth2TokenService? tokenService = null,
IOptions<NotificationOptions>? options = null)
IOptions<NotificationOptions>? options = null,
IEwsMailSender? ewsMailSender = null)
{
_repository = repository;
_smtpClientFactory = smtpClientFactory;
@@ -47,6 +63,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
// NotificationOptions supplies the documented fallback values used when a
// deployed SmtpConfiguration row leaves a field unset (non-positive).
_options = options?.Value ?? new NotificationOptions();
_ewsMailSender = ewsMailSender;
}
/// <inheritdoc />
@@ -89,21 +106,42 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
smtpConfig.Id);
}
// An unknown TLS mode is a configuration error that retrying cannot fix
// surface it as a permanent failure (SMTP TLS validation policy).
SmtpTlsMode tlsMode;
// An unknown transport is a configuration error that retrying cannot fix.
EmailTransport transport;
try
{
tlsMode = SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
transport = EmailTransportParser.Parse(smtpConfig.Transport);
}
catch (ArgumentException ex)
{
_logger.LogError(
"Email notification to list '{List}' has an invalid SMTP TLS mode: {Reason}",
"Email notification to list '{List}' has an invalid transport: {Reason}",
notification.ListName, ex.Message);
return DeliveryOutcome.Permanent(ex.Message);
}
// TLS applies to the SMTP transport only — an EWS row's Port/TlsMode are unused.
// Parsed here, ahead of address validation, so the SMTP path's check order (and
// hence which reason surfaces for a row that is invalid in more than one way) is
// exactly what it was before the transport branch existed.
var tlsMode = SmtpTlsMode.None;
if (transport == EmailTransport.Smtp)
{
// An unknown TLS mode is a configuration error that retrying cannot fix —
// surface it as a permanent failure (SMTP TLS validation policy).
try
{
tlsMode = SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
}
catch (ArgumentException ex)
{
_logger.LogError(
"Email notification to list '{List}' has an invalid SMTP TLS mode: {Reason}",
notification.ListName, ex.Message);
return DeliveryOutcome.Permanent(ex.Message);
}
}
// A malformed sender or recipient address cannot be fixed by retrying —
// surface it as a permanent failure.
var addressError = EmailAddressValidator.ValidateAddresses(
@@ -121,6 +159,13 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
.Select(r => r.EmailAddress!)
.ToList();
// Everything above is transport-agnostic; the EWS branch takes over here.
if (transport == EmailTransport.Ews)
{
return await DeliverViaEwsAsync(
smtpConfig, recipientAddresses, notification, cancellationToken);
}
try
{
await SendAsync(smtpConfig, tlsMode, recipientAddresses,
@@ -165,6 +210,115 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
}
}
/// <summary>
/// Delivers the plain-text BCC email through Exchange Web Services.
/// <para>
/// Every EWS-specific configuration defect (a non-HTTPS or relative endpoint, a non-Basic
/// auth type, a credential that is not <c>username:password</c>, or a missing sender
/// registration) is a permanent failure decided before the sender is touched — retrying
/// cannot fix a misconfigured row, and no half-formed request should reach Exchange.
/// </para>
/// </summary>
private async Task<DeliveryOutcome> DeliverViaEwsAsync(
SmtpConfiguration config,
IReadOnlyList<string> recipientAddresses,
Notification notification,
CancellationToken cancellationToken)
{
if (!Uri.TryCreate(config.Host, UriKind.Absolute, out var endpoint)
|| endpoint.Scheme != Uri.UriSchemeHttps)
{
return PermanentConfigError(
"EWS transport requires an absolute https:// endpoint URL in Host; "
+ $"got '{config.Host}'");
}
// Basic is the only credential shape the sender builds an Authorization header for;
// OAuth2/NTLM are documented follow-ons, not silent fallbacks.
if (!config.AuthType.Equals("basic", StringComparison.OrdinalIgnoreCase))
{
return PermanentConfigError(
$"EWS transport supports only Basic authentication; got '{config.AuthType}'.");
}
// Split on the FIRST colon only: a domain-qualified user name has none, but a
// password legitimately may contain one.
var parts = config.Credentials?.Split(':', 2) ?? [];
if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0)
{
return PermanentConfigError("EWS credentials must be in 'username:password' form.");
}
if (_ewsMailSender == null)
{
return PermanentConfigError(
"EWS transport configured but no EWS sender is registered");
}
var timeoutSeconds = config.ConnectionTimeoutSeconds > 0
? config.ConnectionTimeoutSeconds
: _options.ConnectionTimeoutSeconds;
var request = new EwsSendRequest(
endpoint,
parts[0],
parts[1],
config.FromAddress,
recipientAddresses,
notification.Subject,
notification.Body,
timeoutSeconds);
try
{
await _ewsMailSender.SendAsync(request, cancellationToken);
return DeliveryOutcome.Success(string.Join(", ", recipientAddresses));
}
catch (EwsPermanentException ex)
{
// Auth, wrong URL, schema fault or recipient rejection — not retried.
var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
_logger.LogError(
"Permanent EWS failure delivering email to list '{List}': {Detail}",
notification.ListName, detail);
return DeliveryOutcome.Permanent(detail);
}
catch (EwsTransientException ex)
{
// Availability-shaped failure (network/timeout/5xx/server-busy) — retried.
var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
_logger.LogWarning(
"Transient EWS failure delivering email to list '{List}': {Detail}",
notification.ListName, detail);
return DeliveryOutcome.Transient(detail);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// A caller-requested cancellation propagates; it is neither a success
// nor a delivery failure.
throw;
}
catch (Exception ex)
{
// Anything the sender did not classify is treated as permanent, mirroring the
// SMTP path's default-to-permanent stance.
var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
_logger.LogError(
"Unclassified failure delivering email via EWS to list '{List}' ({ExceptionType}): {Detail}",
notification.ListName, ex.GetType().Name, detail);
return DeliveryOutcome.Permanent($"Email delivery failed: {detail}");
}
// Local helper: one log-and-park shape for every EWS configuration defect.
DeliveryOutcome PermanentConfigError(string reason)
{
_logger.LogError(
"Email notification to list '{List}' has an invalid EWS configuration: {Reason}",
notification.ListName, reason);
return DeliveryOutcome.Permanent(reason);
}
}
/// <summary>
/// Delivers the plain-text BCC email via SMTP. A permanent failure surfaces as
/// <see cref="SmtpPermanentException"/>; transient failures propagate for the
@@ -26,10 +26,14 @@ public static class ServiceCollectionExtensions
/// This extension covers only the outbox-specific registrations. The
/// <see cref="EmailNotificationDeliveryAdapter"/> reuses the
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationService"/> SMTP machinery —
/// <c>Func&lt;ISmtpClientWrapper&gt;</c>, <c>OAuth2TokenService</c> and
/// <c>NotificationOptions</c> — so the caller (the Host on the central node) must also
/// call <c>AddNotificationService()</c>. Re-registering those services here would
/// duplicate them; relying on <c>AddNotificationService</c> keeps a single source of truth.
/// <c>Func&lt;ISmtpClientWrapper&gt;</c>, <c>OAuth2TokenService</c>,
/// <c>NotificationOptions</c> and — for configuration rows selecting the EWS transport —
/// <c>IEwsMailSender</c> with its named HTTP client — so the caller (the Host on the
/// central node) must also call <c>AddNotificationService()</c>. Re-registering those
/// services here would duplicate them; relying on <c>AddNotificationService</c> keeps a
/// single source of truth. Each is an optional constructor parameter on the adapter, so a
/// host that skips <c>AddNotificationService()</c> still constructs — an EWS row then
/// fails permanently ("no EWS sender is registered") rather than falling back to SMTP.
///
/// <see cref="EmailNotificationDeliveryAdapter"/> is registered <em>scoped</em> because it
/// takes a scoped <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.INotificationRepository"/>