diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsErrorClassifier.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsErrorClassifier.cs
new file mode 100644
index 00000000..70a7eda0
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsErrorClassifier.cs
@@ -0,0 +1,112 @@
+using System.Net;
+
+namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
+
+///
+/// Classification of a Twilio-REST SMS delivery failure. This decides whether a
+/// failure is retried (the whole notification is re-queued) or surfaced as permanent
+/// (the notification is parked), so it is part of the system's correctness-relevant
+/// behaviour.
+///
+public enum SmsErrorClass
+{
+ /// Cancellation or an unrecognised exception — caller decides.
+ Unknown,
+
+ /// Retryable failure (HTTP 5xx / 408 / 429, transport/timeout error).
+ Transient,
+
+ /// Non-retryable failure (other 4xx — bad creds, invalid number) — must not be retried.
+ Permanent,
+}
+
+///
+/// Classifies a Twilio-REST SMS delivery failure. Mirrors
+/// 's
+/// enum + static-method shape so the SMS adapter routes every failure through one
+/// local policy (no cross-project reference to the External System Gateway). The
+/// classification is keyed off the numeric HTTP status code (Twilio returns rich
+/// status codes) and the typed transport exceptions rather than locale-dependent
+/// substring matching.
+///
+/// Transient: HTTP 5xx, 408, 429; ,
+/// and that are
+/// NOT a caller-requested cancellation. Everything else (other 4xx — 400 invalid /
+/// unsubscribed number, 401/403 bad credentials) is permanent.
+///
+///
+public static class SmsErrorClassifier
+{
+ ///
+ /// Classifies an HTTP response status code. Twilio accepts a message with a 2xx;
+ /// 5xx / 408 / 429 are transient (provider-side / rate-limited); any other 4xx is
+ /// permanent (the request itself is wrong and retrying cannot fix it).
+ ///
+ /// The HTTP status code returned by the Twilio Messages endpoint.
+ /// The describing the failure (never for a status code).
+ public static SmsErrorClass Classify(HttpStatusCode statusCode)
+ {
+ var code = (int)statusCode;
+
+ if (code == 408 || code == 429 || (code >= 500 && code < 600))
+ {
+ return SmsErrorClass.Transient;
+ }
+
+ // Any non-2xx status that is not 408/429/5xx is a permanent client error.
+ return SmsErrorClass.Permanent;
+ }
+
+ ///
+ /// Classifies a transport-layer exception thrown while issuing the Twilio request.
+ /// A caller-requested cancellation is never treated as a transient SMS error.
+ ///
+ /// The exception thrown by the HTTP send.
+ ///
+ /// The token governing the send; a requested cancellation classifies as
+ /// so the caller can re-throw it.
+ ///
+ /// The describing whether the failure is transient or unknown.
+ public static SmsErrorClass Classify(Exception ex, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(ex);
+
+ // A deliberate cancellation is not an SMS error at all — TaskCanceledException
+ // derives from OperationCanceledException, so this guard catches both an
+ // HttpClient request-timeout cancel and a caller cancel; only the latter is
+ // honoured here so the caller can re-throw.
+ if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
+ {
+ return SmsErrorClass.Unknown;
+ }
+
+ // A connection failure, a request-level timeout (HttpClient surfaces its own
+ // timeout as a TaskCanceledException with no caller cancel) and an explicit
+ // TimeoutException are all retryable — the message has not been rejected.
+ if (ex is HttpRequestException or TimeoutException or TaskCanceledException)
+ {
+ return SmsErrorClass.Transient;
+ }
+
+ return SmsErrorClass.Unknown;
+ }
+
+ ///
+ /// Convenience predicate: true when the HTTP status code classifies as
+ /// .
+ ///
+ /// The HTTP status code to classify.
+ /// true when returns .
+ public static bool IsTransient(HttpStatusCode statusCode)
+ => Classify(statusCode) == SmsErrorClass.Transient;
+
+ ///
+ /// Convenience predicate: true when the exception classifies as
+ /// .
+ ///
+ /// The exception to classify.
+ /// Cancellation token passed to .
+ /// true when returns .
+ public static bool IsTransient(Exception ex, CancellationToken cancellationToken)
+ => Classify(ex, cancellationToken) == SmsErrorClass.Transient;
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsNotificationDeliveryAdapter.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsNotificationDeliveryAdapter.cs
new file mode 100644
index 00000000..655ba73b
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsNotificationDeliveryAdapter.cs
@@ -0,0 +1,370 @@
+using System.Net.Http.Headers;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+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;
+
+namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
+
+///
+/// SMS channel delivery adapter for the central notification outbox (T9 pivot).
+///
+/// Calls Twilio directly over its REST API — no SDK — honouring the project's
+/// no-new-NuGet-package rule. The adapter resolves the notification's list, keeps
+/// only SMS-eligible recipients (non-empty ),
+/// loads the central , composes a plain-text body, and
+/// issues one POST .../Messages.json per recipient (Twilio has no BCC). Each
+/// response is classified via and rolled up into the
+/// outbox's three-way per design D6.
+///
+/// Mirrors the Email adapter's structure (load list → recipients → config, build
+/// message, classify outcome). "Accepted by Twilio" (HTTP 2xx) is treated as
+/// Delivered, exactly as the Email adapter treats "accepted by the SMTP server";
+/// true delivery confirmation needs a status-callback webhook (out of scope).
+///
+///
+/// The Twilio Auth Token is NEVER allowed to appear in a returned
+/// or in any log: every outbound error string is
+/// passed through with the token before it is
+/// logged or returned.
+///
+///
+public sealed class SmsNotificationDeliveryAdapter : INotificationDeliveryAdapter
+{
+ /// Provider default Twilio REST base URL, used when the config row leaves ApiBaseUrl unset.
+ private const string DefaultApiBaseUrl = "https://api.twilio.com";
+
+ /// The named registered for Twilio in ServiceCollectionExtensions.
+ public const string HttpClientName = "Twilio";
+
+ private readonly INotificationRepository _repository;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
+ private readonly SmsOptions _options;
+
+ /// Initializes a new instance of .
+ /// Repository for resolving notification list recipients and the SMS configuration.
+ /// Factory creating the named "Twilio" HTTP client per delivery attempt.
+ /// Logger instance.
+ /// SMS options providing the documented message-length cap and timeout fallback.
+ public SmsNotificationDeliveryAdapter(
+ INotificationRepository repository,
+ IHttpClientFactory httpClientFactory,
+ ILogger logger,
+ IOptions options)
+ {
+ _repository = repository;
+ _httpClientFactory = httpClientFactory;
+ _logger = logger;
+ // SmsOptions supplies the documented body cap and the timeout fallback used
+ // when a deployed SmsConfiguration row leaves ConnectionTimeoutSeconds unset.
+ _options = options?.Value ?? new SmsOptions();
+ }
+
+ ///
+ public NotificationType Type => NotificationType.Sms;
+
+ ///
+ public async Task DeliverAsync(
+ Notification notification, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(notification);
+
+ var list = await _repository.GetListByNameAsync(notification.ListName, cancellationToken);
+ if (list == null)
+ {
+ return DeliveryOutcome.Permanent(
+ $"Notification list '{notification.ListName}' not found");
+ }
+
+ var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken);
+ var phoneNumbers = recipients
+ .Where(r => !string.IsNullOrWhiteSpace(r.PhoneNumber))
+ .Select(r => r.PhoneNumber!.Trim())
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ if (phoneNumbers.Count == 0)
+ {
+ return DeliveryOutcome.Permanent(
+ $"Notification list '{notification.ListName}' has no SMS recipients");
+ }
+
+ var smsConfig = await _repository.GetSmsConfigurationAsync(cancellationToken);
+ if (smsConfig == null)
+ {
+ return DeliveryOutcome.Permanent("No SMS configuration available");
+ }
+
+ // The Account SID + Auth Token are mandatory (they form the Basic-auth header
+ // and the SID is on the URL path). A From number is required UNLESS a
+ // Messaging Service SID is supplied, in which case From is optional. None of
+ // these can be fixed by retrying, so a missing field is a permanent failure.
+ if (string.IsNullOrWhiteSpace(smsConfig.AccountSid)
+ || string.IsNullOrWhiteSpace(smsConfig.AuthToken))
+ {
+ return DeliveryOutcome.Permanent(
+ "SMS configuration is incomplete: AccountSid and AuthToken are required");
+ }
+
+ var hasMessagingService = !string.IsNullOrWhiteSpace(smsConfig.MessagingServiceSid);
+ if (!hasMessagingService && string.IsNullOrWhiteSpace(smsConfig.FromNumber))
+ {
+ return DeliveryOutcome.Permanent(
+ "SMS configuration is incomplete: a FromNumber or MessagingServiceSid is required");
+ }
+
+ var authToken = smsConfig.AuthToken!;
+ var body = ComposeBody(notification.Subject, notification.Body);
+
+ var baseUrl = string.IsNullOrWhiteSpace(smsConfig.ApiBaseUrl)
+ ? DefaultApiBaseUrl
+ : smsConfig.ApiBaseUrl!.TrimEnd('/');
+ var requestUri = $"{baseUrl}/2010-04-01/Accounts/{smsConfig.AccountSid}/Messages.json";
+
+ // Config-row value overrides the SmsOptions fallback for the per-request timeout.
+ var timeoutSeconds = smsConfig.ConnectionTimeoutSeconds > 0
+ ? smsConfig.ConnectionTimeoutSeconds
+ : _options.ConnectionTimeoutSeconds;
+
+ var authHeader = new AuthenticationHeaderValue(
+ "Basic",
+ Convert.ToBase64String(Encoding.ASCII.GetBytes($"{smsConfig.AccountSid}:{authToken}")));
+
+ var httpClient = _httpClientFactory.CreateClient(HttpClientName);
+
+ var accepted = new List();
+ var permanentlyFailed = new List();
+ string? firstTransientError = null;
+
+ foreach (var phone in phoneNumbers)
+ {
+ // A caller-requested cancellation propagates; it is neither a success nor
+ // a delivery failure (mirrors the Email adapter).
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var attempt = await SendOneAsync(
+ httpClient, requestUri, authHeader, smsConfig, hasMessagingService,
+ phone, body, timeoutSeconds, authToken, cancellationToken);
+
+ switch (attempt.Class)
+ {
+ case SmsErrorClass.Transient:
+ firstTransientError ??= attempt.Detail;
+ break;
+ case SmsErrorClass.Permanent:
+ permanentlyFailed.Add(phone);
+ break;
+ default:
+ accepted.Add(phone);
+ break;
+ }
+ }
+
+ return RollUp(notification.ListName, accepted, permanentlyFailed, firstTransientError);
+ }
+
+ ///
+ /// Issues one Twilio POST for a single recipient and classifies the result.
+ /// Transport exceptions are caught and classified here; a caller-requested
+ /// cancellation is re-thrown so it propagates out of .
+ ///
+ private async Task SendOneAsync(
+ HttpClient httpClient,
+ string requestUri,
+ AuthenticationHeaderValue authHeader,
+ SmsConfiguration smsConfig,
+ bool hasMessagingService,
+ string toNumber,
+ string body,
+ int timeoutSeconds,
+ string authToken,
+ CancellationToken cancellationToken)
+ {
+ // Per-request timeout layered over the caller's token via a linked CTS, so a
+ // slow Twilio response is classified transient (HttpClient surfaces the cancel
+ // as a TaskCanceledException with no caller-cancel) while a genuine caller
+ // cancel still propagates.
+ using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, timeoutCts.Token);
+
+ try
+ {
+ var form = new List>
+ {
+ new("To", toNumber),
+ new("Body", body),
+ };
+ if (hasMessagingService)
+ {
+ form.Add(new KeyValuePair(
+ "MessagingServiceSid", smsConfig.MessagingServiceSid!));
+ }
+ else
+ {
+ form.Add(new KeyValuePair("From", smsConfig.FromNumber));
+ }
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
+ {
+ Content = new FormUrlEncodedContent(form),
+ };
+ request.Headers.Authorization = authHeader;
+
+ using var response = await httpClient.SendAsync(request, linkedCts.Token);
+
+ if (response.IsSuccessStatusCode)
+ {
+ return new SmsAttempt(SmsErrorClass.Unknown, null);
+ }
+
+ var cls = SmsErrorClassifier.Classify(response.StatusCode);
+ var detail = CredentialRedactor.Scrub(
+ $"Twilio returned HTTP {(int)response.StatusCode} ({response.StatusCode}) for {toNumber}",
+ authToken);
+
+ if (cls == SmsErrorClass.Transient)
+ {
+ _logger.LogWarning(
+ "Transient SMS failure delivering to {Recipient}: {Detail}", toNumber, detail);
+ }
+ else
+ {
+ _logger.LogWarning(
+ "Permanent SMS failure delivering to {Recipient}: {Detail}", toNumber, detail);
+ }
+
+ return new SmsAttempt(cls, detail);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // A caller-requested cancellation propagates; the per-request timeout
+ // cancel (timeoutCts) is NOT a caller cancel and falls through to the
+ // transient classifier below.
+ throw;
+ }
+ catch (Exception ex) when (SmsErrorClassifier.IsTransient(ex, cancellationToken))
+ {
+ var detail = CredentialRedactor.Scrub(
+ $"SMS transport failure for {toNumber} ({ex.GetType().Name}): {ex.Message}",
+ authToken);
+ _logger.LogWarning(
+ "Transient SMS failure delivering to {Recipient} ({ExceptionType}): {Detail}",
+ toNumber, ex.GetType().Name, detail);
+ return new SmsAttempt(SmsErrorClass.Transient, detail);
+ }
+ catch (Exception ex)
+ {
+ // An unclassified failure is treated as permanent: retrying a malformed
+ // request burns Twilio calls (mirrors the Email adapter's default-permanent
+ // stance for unclassified failures).
+ var detail = CredentialRedactor.Scrub(
+ $"SMS delivery failed for {toNumber} ({ex.GetType().Name}): {ex.Message}",
+ authToken);
+ _logger.LogError(
+ "Unclassified SMS failure delivering to {Recipient} ({ExceptionType}): {Detail}",
+ toNumber, ex.GetType().Name, detail);
+ return new SmsAttempt(SmsErrorClass.Permanent, detail);
+ }
+ }
+
+ ///
+ /// Rolls the per-recipient outcomes up into a single notification-level outcome
+ /// per design D6: any transient → Transient (whole notification retries); else any
+ /// accepted → Success (the permanently-failed numbers are noted, NOT parked); else
+ /// (all permanent / zero accepted) → Permanent.
+ ///
+ private DeliveryOutcome RollUp(
+ string listName,
+ IReadOnlyList accepted,
+ IReadOnlyList permanentlyFailed,
+ string? firstTransientError)
+ {
+ // Any transient failure parks the WHOLE notification for retry at the fixed
+ // interval; numbers already accepted on this attempt get re-texted on retry
+ // (the same "re-send to all" characteristic the Email/BCC path already has).
+ if (firstTransientError != null)
+ {
+ _logger.LogWarning(
+ "SMS notification to list '{List}' has a transient failure; the notification will retry. {Detail}",
+ listName, firstTransientError);
+ return DeliveryOutcome.Transient(firstTransientError);
+ }
+
+ // No transient failures. If anything was accepted, do NOT park even when some
+ // numbers permanently failed — record the bad numbers and report success.
+ if (accepted.Count > 0)
+ {
+ var targets = string.Join(", ", accepted);
+ if (permanentlyFailed.Count > 0)
+ {
+ var note =
+ $"Delivered to {accepted.Count} recipient(s); " +
+ $"{permanentlyFailed.Count} permanently failed: {string.Join(", ", permanentlyFailed)}";
+ _logger.LogWarning(
+ "SMS notification to list '{List}' partially delivered: {Note}", listName, note);
+ // ResolvedTargets carries the accepted numbers AND the failed-number note
+ // so the audit row preserves the permanently-failed recipients.
+ return DeliveryOutcome.Success($"{targets} [{note}]");
+ }
+
+ return DeliveryOutcome.Success(targets);
+ }
+
+ // Nothing accepted and no transient failures — every recipient permanently
+ // failed. Park the notification.
+ var error = permanentlyFailed.Count > 0
+ ? $"All SMS recipients permanently failed: {string.Join(", ", permanentlyFailed)}"
+ : "SMS delivery produced no accepted recipients";
+ _logger.LogError(
+ "SMS notification to list '{List}' failed permanently: {Error}", listName, error);
+ return DeliveryOutcome.Permanent(error);
+ }
+
+ ///
+ /// Composes the plain-text SMS body: Subject + newline + Body
+ /// (skipping whichever is empty), truncated to
+ /// with a trailing ellipsis when over the cap. SMS has no subject line.
+ ///
+ private string ComposeBody(string? subject, string? body)
+ {
+ var parts = new List(2);
+ if (!string.IsNullOrWhiteSpace(subject))
+ {
+ parts.Add(subject);
+ }
+
+ if (!string.IsNullOrWhiteSpace(body))
+ {
+ parts.Add(body);
+ }
+
+ var composed = string.Join("\n", parts);
+
+ var max = _options.MaxMessageLength;
+ if (composed.Length <= max)
+ {
+ return composed;
+ }
+
+ // Truncate to the cap and append an ellipsis (counted inside the cap so the
+ // result never exceeds MaxMessageLength). The ellipsis is a single char.
+ const string ellipsis = "…";
+ if (max <= ellipsis.Length)
+ {
+ return composed[..max];
+ }
+
+ return string.Concat(composed.AsSpan(0, max - ellipsis.Length), ellipsis);
+ }
+
+ /// The classified outcome of a single per-recipient Twilio attempt.
+ ///
+ /// = accepted (HTTP 2xx); otherwise the failure class.
+ ///
+ /// A redacted, human-readable failure description, or null on accept.
+ private readonly record struct SmsAttempt(SmsErrorClass Class, string? Detail);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs
index 7aedcb95..5e817e30 100644
--- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
@@ -15,6 +16,9 @@ public static class ServiceCollectionExtensions
/// Configuration section bound to .
public const string OptionsSection = "ScadaBridge:NotificationOutbox";
+ /// Configuration section bound to .
+ public const string SmsOptionsSection = "ScadaBridge:Sms";
+
///
/// Registers the Notification Outbox services: the
/// binding and the channel delivery adapters.
@@ -32,6 +36,13 @@ public static class ServiceCollectionExtensions
/// directly. The resolves the adapters from a fresh
/// scope per dispatch sweep rather than holding them, so no scoped adapter is captured by
/// the singleton actor.
+ ///
+ /// The (Twilio-REST) is registered the same
+ /// way alongside the Email adapter, so both resolve from
+ /// GetServices<INotificationDeliveryAdapter>(). It also binds + validates
+ /// (ScadaBridge:Sms) and the named "Twilio"
+ /// ; unlike the Email adapter it needs no NotificationService
+ /// machinery (a single outbound HTTPS POST per recipient).
///
/// The DI service collection to register notification outbox services into.
/// The same instance for chaining.
@@ -42,13 +53,33 @@ public static class ServiceCollectionExtensions
services.AddOptions()
.BindConfiguration(OptionsSection);
- // Scoped: the adapter holds a scoped INotificationRepository. Registered both under
- // the interface (so the dispatch sweep can enumerate every channel adapter) and as
- // the concrete type (so callers and tests can resolve it directly).
+ // Bind + validate SmsOptions (ScadaBridge:Sms): the SMS adapter's body cap and
+ // per-request timeout fallback. ValidateOnStart surfaces a bad option at host
+ // startup rather than at first delivery; the validator is registered via
+ // TryAddEnumerable so a re-call of AddNotificationOutbox does not double-register it.
+ services.AddOptions()
+ .BindConfiguration(SmsOptionsSection)
+ .ValidateOnStart();
+ services.TryAddEnumerable(
+ ServiceDescriptor.Singleton, SmsOptionsValidator>());
+
+ // Named HTTP client backing the Twilio-REST SMS adapter. Pooled handler lifetime
+ // is managed by IHttpClientFactory; the adapter applies the per-request timeout
+ // via a linked CTS (so the client's own Timeout stays at its default).
+ services.AddHttpClient(SmsNotificationDeliveryAdapter.HttpClientName);
+
+ // Scoped: the adapters hold a scoped INotificationRepository. Each is registered
+ // both under the interface (so the dispatch sweep can enumerate every channel
+ // adapter — both Email and SMS must resolve from GetServices())
+ // and as the concrete type (so callers and tests can resolve it directly).
services.AddScoped();
services.AddScoped(
sp => sp.GetRequiredService());
+ services.AddScoped();
+ services.AddScoped(
+ sp => sp.GetRequiredService());
+
// KPI history (M6): the recorder singleton enumerates every IKpiSampleSource each
// sampling pass to snapshot the outbox delivery KPIs into the central history store.
// TryAddEnumerable is idempotent — no double-registration if AddNotificationOutbox
diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/SmsOptions.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/SmsOptions.cs
new file mode 100644
index 00000000..d91749c8
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/SmsOptions.cs
@@ -0,0 +1,68 @@
+using Microsoft.Extensions.Options;
+
+namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox;
+
+///
+/// Configuration options for the SMS (Twilio-REST) notification delivery adapter,
+/// bound from the ScadaBridge:Sms configuration section.
+///
+/// SMS connection settings are primarily carried by the deployed
+/// SmsConfiguration entity. These values supply the documented fallbacks /
+/// caps used by the central Notification Outbox's
+/// SmsNotificationDeliveryAdapter: caps the
+/// composed body, and is the per-request
+/// timeout used when the deployed SmsConfiguration.ConnectionTimeoutSeconds
+/// field is left unset (non-positive) — a value present on the row always takes
+/// precedence.
+///
+public sealed class SmsOptions
+{
+ ///
+ /// Maximum length of the composed SMS body; longer bodies are truncated with an
+ /// ellipsis before the Twilio POST. Default 1600 (Twilio's per-message maximum).
+ /// Must be strictly positive.
+ ///
+ public int MaxMessageLength { get; set; } = 1600;
+
+ ///
+ /// Per-request connection/response timeout (seconds) used when the deployed
+ /// SmsConfiguration.ConnectionTimeoutSeconds is unset (non-positive).
+ /// Default 30s. Must be strictly positive.
+ ///
+ public int ConnectionTimeoutSeconds { get; set; } = 30;
+}
+
+///
+/// Validates on startup. A non-positive
+/// would truncate every body to nothing,
+/// and a non-positive would make
+/// the per-request timeout meaningless, so both are required to be strictly positive.
+///
+public sealed class SmsOptionsValidator : IValidateOptions
+{
+ ///
+ public ValidateOptionsResult Validate(string? name, SmsOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var failures = new List();
+
+ if (options.MaxMessageLength <= 0)
+ {
+ failures.Add(
+ $"ScadaBridge:Sms:{nameof(SmsOptions.MaxMessageLength)} ({options.MaxMessageLength}) " +
+ "must be > 0; it caps the composed SMS body length.");
+ }
+
+ if (options.ConnectionTimeoutSeconds <= 0)
+ {
+ failures.Add(
+ $"ScadaBridge:Sms:{nameof(SmsOptions.ConnectionTimeoutSeconds)} ({options.ConnectionTimeoutSeconds}) " +
+ "must be > 0; it is the per-request Twilio timeout fallback.");
+ }
+
+ return failures.Count > 0
+ ? ValidateOptionsResult.Fail(failures)
+ : ValidateOptionsResult.Success;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj
index 8498f29d..c18250c8 100644
--- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj
@@ -10,6 +10,9 @@
+
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs
new file mode 100644
index 00000000..c185ed9f
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs
@@ -0,0 +1,445 @@
+using System.Net;
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+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.NotificationOutbox.Delivery;
+
+namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
+
+///
+/// Tests for the Twilio-REST SMS outbox delivery adapter (T9 pivot) — list/recipient/
+/// SMS-config resolution, the per-recipient Twilio POST, the per-attempt
+/// classification, the D6 roll-up, and the hard
+/// invariant that the Twilio Auth Token never leaks into a returned outcome.
+///
+public class SmsNotificationDeliveryAdapterTests
+{
+ private const string AuthToken = "super-secret-auth-token-abcdef0123456789";
+
+ private readonly INotificationRepository _repository = Substitute.For();
+ private readonly IHttpClientFactory _httpClientFactory = Substitute.For();
+
+ ///
+ /// A scriptable that returns a queued sequence of
+ /// responses (one per recipient POST) and records every outbound request so the
+ /// tests can assert the URL / auth header / form body.
+ ///
+ private sealed class ScriptedHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Queue>> _responders;
+
+ public List Requests { get; } = new();
+ public List RequestBodies { get; } = new();
+
+ private ScriptedHttpMessageHandler(IEnumerable>> responders)
+ {
+ _responders = new Queue>>(responders);
+ }
+
+ /// One queued response per status code (one per recipient POST).
+ public static ScriptedHttpMessageHandler ForStatuses(params HttpStatusCode[] statusCodes) =>
+ new(statusCodes.Select(code =>
+ new Func>(
+ _ => Task.FromResult(new HttpResponseMessage(code)
+ {
+ Content = new StringContent("{}", Encoding.UTF8, "application/json"),
+ }))));
+
+ /// Custom per-request responders (e.g. to throw a transport exception).
+ public static ScriptedHttpMessageHandler ForResponders(
+ params Func>[] responders) => new(responders);
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Requests.Add(request);
+ RequestBodies.Add(request.Content == null
+ ? string.Empty
+ : await request.Content.ReadAsStringAsync(cancellationToken));
+
+ if (_responders.Count == 0)
+ {
+ throw new InvalidOperationException("No scripted response left for request.");
+ }
+
+ return await _responders.Dequeue()(request);
+ }
+ }
+
+ private SmsNotificationDeliveryAdapter CreateAdapter(HttpMessageHandler handler, SmsOptions? options = null)
+ {
+ _httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(handler));
+ return new SmsNotificationDeliveryAdapter(
+ _repository,
+ _httpClientFactory,
+ NullLogger.Instance,
+ Options.Create(options ?? new SmsOptions()));
+ }
+
+ private static Notification MakeNotification(string listName = "ops-team")
+ {
+ return new Notification(
+ Guid.NewGuid().ToString(),
+ NotificationType.Sms,
+ listName,
+ "Subject",
+ "Body",
+ "site-1");
+ }
+
+ private void SetupList(
+ string name = "ops-team",
+ IReadOnlyList? phones = null,
+ SmsConfiguration? config = null)
+ {
+ phones ??= new[] { "+15551112222" };
+ var list = new NotificationList(name) { Id = 1, Type = NotificationType.Sms };
+ var recipients = phones
+ .Select((p, i) => { var r = NotificationRecipient.ForSms($"R{i}", p); r.Id = i + 1; r.NotificationListId = 1; return r; })
+ .ToList();
+
+ _repository.GetListByNameAsync(name, Arg.Any()).Returns(list);
+ _repository.GetRecipientsByListIdAsync(1, Arg.Any()).Returns(recipients);
+ _repository.GetSmsConfigurationAsync(Arg.Any())
+ .Returns(config ?? new SmsConfiguration("AC_account_sid", "+15550000000")
+ {
+ Id = 1,
+ AuthToken = AuthToken,
+ ApiBaseUrl = "https://fake-twilio.test",
+ });
+ }
+
+ [Fact]
+ public void Type_IsSms()
+ {
+ Assert.Equal(NotificationType.Sms, CreateAdapter(ScriptedHttpMessageHandler.ForStatuses()).Type);
+ }
+
+ [Fact]
+ public async Task Deliver_SingleRecipient201_ReturnsSuccessWithResolvedTargets()
+ {
+ SetupList(phones: new[] { "+15551112222" });
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.Success, outcome.Result);
+ Assert.NotNull(outcome.ResolvedTargets);
+ Assert.Contains("+15551112222", outcome.ResolvedTargets);
+ Assert.Null(outcome.Error);
+
+ // One POST to the Twilio Messages endpoint with the account SID on the path,
+ // a Basic auth header, and a form-encoded To / From / Body.
+ var request = Assert.Single(handler.Requests);
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal(
+ "https://fake-twilio.test/2010-04-01/Accounts/AC_account_sid/Messages.json",
+ request.RequestUri!.ToString());
+ Assert.Equal("Basic", request.Headers.Authorization!.Scheme);
+ var body = Assert.Single(handler.RequestBodies);
+ Assert.Contains("To=", body);
+ Assert.Contains("From=", body);
+ Assert.Contains("Body=", body);
+ }
+
+ [Fact]
+ public async Task Deliver_MultiRecipient201_AllAccepted_ReturnsSuccess()
+ {
+ SetupList(phones: new[] { "+15551112222", "+15553334444" });
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created, HttpStatusCode.Created);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.Success, outcome.Result);
+ Assert.Contains("+15551112222", outcome.ResolvedTargets);
+ Assert.Contains("+15553334444", outcome.ResolvedTargets);
+ Assert.Equal(2, handler.Requests.Count);
+ }
+
+ [Fact]
+ public async Task Deliver_UsesMessagingServiceSid_WhenFromNumberAbsent()
+ {
+ var config = new SmsConfiguration("AC_account_sid", "")
+ {
+ Id = 1,
+ AuthToken = AuthToken,
+ MessagingServiceSid = "MG_messaging_service",
+ ApiBaseUrl = "https://fake-twilio.test",
+ };
+ SetupList(config: config);
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.Success, outcome.Result);
+ var body = Assert.Single(handler.RequestBodies);
+ Assert.Contains("MessagingServiceSid=MG_messaging_service", body);
+ Assert.DoesNotContain("From=", body);
+ }
+
+ [Fact]
+ public async Task Deliver_500_ReturnsTransient()
+ {
+ SetupList();
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.InternalServerError);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
+ Assert.NotNull(outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_429_ReturnsTransient()
+ {
+ SetupList();
+ var handler = ScriptedHttpMessageHandler.ForStatuses((HttpStatusCode)429);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
+ }
+
+ [Fact]
+ public async Task Deliver_HttpRequestException_ReturnsTransient()
+ {
+ SetupList();
+ var handler = ScriptedHttpMessageHandler.ForResponders(
+ _ => throw new HttpRequestException("connection refused"));
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
+ }
+
+ [Fact]
+ public async Task Deliver_Timeout_ReturnsTransient()
+ {
+ SetupList();
+ // HttpClient surfaces a per-request timeout as a TaskCanceledException with no
+ // caller cancel; the classifier treats that as transient. Simulate it directly.
+ var handler = ScriptedHttpMessageHandler.ForResponders(
+ _ => throw new TaskCanceledException("The request was canceled due to timeout."));
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
+ }
+
+ [Fact]
+ public async Task Deliver_400_ReturnsPermanent()
+ {
+ SetupList();
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.BadRequest);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.NotNull(outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_401_ReturnsPermanent()
+ {
+ SetupList();
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Unauthorized);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ }
+
+ [Fact]
+ public async Task Deliver_MixAcceptedAndPermanentBad_ReturnsSuccessWithBadNumberNoted()
+ {
+ SetupList(phones: new[] { "+15551112222", "+1BADNUMBER" });
+ // First number accepted (201), second permanently rejected (400).
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created, HttpStatusCode.BadRequest);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ // D6: anything got through → Success (do NOT park); the bad number is noted.
+ Assert.Equal(DeliveryResult.Success, outcome.Result);
+ Assert.Contains("+15551112222", outcome.ResolvedTargets);
+ Assert.Contains("+1BADNUMBER", outcome.ResolvedTargets);
+ }
+
+ [Fact]
+ public async Task Deliver_MixAcceptedAndTransient_ReturnsTransient()
+ {
+ SetupList(phones: new[] { "+15551112222", "+15553334444" });
+ // First accepted, second transient (503): D6 says any transient → whole notification retries.
+ var handler = ScriptedHttpMessageHandler.ForStatuses(
+ HttpStatusCode.Created, HttpStatusCode.ServiceUnavailable);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
+ }
+
+ [Fact]
+ public async Task Deliver_ListNotFound_ReturnsPermanent()
+ {
+ _repository.GetListByNameAsync("missing", Arg.Any())
+ .Returns((NotificationList?)null);
+ var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());
+
+ var outcome = await adapter.DeliverAsync(MakeNotification("missing"));
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("missing", outcome.Error);
+ Assert.Contains("not found", outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_NoPhoneRecipients_ReturnsPermanent()
+ {
+ var list = new NotificationList("ops-team") { Id = 1, Type = NotificationType.Sms };
+ // A recipient with only an email and no phone is not SMS-eligible.
+ _repository.GetListByNameAsync("ops-team", Arg.Any()).Returns(list);
+ _repository.GetRecipientsByListIdAsync(1, Arg.Any())
+ .Returns(new List
+ {
+ NotificationRecipient.ForEmail("Alice", "alice@example.com"),
+ });
+ var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("no SMS recipients", outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_NoSmsConfig_ReturnsPermanent()
+ {
+ var list = new NotificationList("ops-team") { Id = 1, Type = NotificationType.Sms };
+ _repository.GetListByNameAsync("ops-team", Arg.Any()).Returns(list);
+ _repository.GetRecipientsByListIdAsync(1, Arg.Any())
+ .Returns(new List { NotificationRecipient.ForSms("R", "+15551112222") });
+ _repository.GetSmsConfigurationAsync(Arg.Any())
+ .Returns((SmsConfiguration?)null);
+ var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("SMS configuration", outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_IncompleteConfig_MissingAuthToken_ReturnsPermanent()
+ {
+ var config = new SmsConfiguration("AC_account_sid", "+15550000000")
+ {
+ Id = 1,
+ AuthToken = null, // missing secret
+ ApiBaseUrl = "https://fake-twilio.test",
+ };
+ SetupList(config: config);
+ var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
+ Assert.Contains("incomplete", outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_DefaultsToTwilioBaseUrl_WhenApiBaseUrlAbsent()
+ {
+ var config = new SmsConfiguration("AC_account_sid", "+15550000000")
+ {
+ Id = 1,
+ AuthToken = AuthToken,
+ ApiBaseUrl = null, // falls back to https://api.twilio.com
+ };
+ SetupList(config: config);
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
+ var adapter = CreateAdapter(handler);
+
+ await adapter.DeliverAsync(MakeNotification());
+
+ var request = Assert.Single(handler.Requests);
+ Assert.StartsWith("https://api.twilio.com/2010-04-01/Accounts/", request.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task Deliver_AuthToken_NeverAppearsInReturnedError()
+ {
+ SetupList();
+ // A permanent failure (400) so the outcome carries an Error string.
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.BadRequest);
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.NotNull(outcome.Error);
+ Assert.DoesNotContain(AuthToken, outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_AuthToken_NeverAppearsOnTransportException()
+ {
+ SetupList();
+ // The exception message echoes the auth token; the adapter must scrub it.
+ var handler = ScriptedHttpMessageHandler.ForResponders(
+ _ => throw new HttpRequestException($"auth failed with token {AuthToken}"));
+ var adapter = CreateAdapter(handler);
+
+ var outcome = await adapter.DeliverAsync(MakeNotification());
+
+ Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
+ Assert.NotNull(outcome.Error);
+ Assert.DoesNotContain(AuthToken, outcome.Error);
+ }
+
+ [Fact]
+ public async Task Deliver_LongBody_TruncatedToMaxMessageLength()
+ {
+ SetupList();
+ var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
+ var adapter = CreateAdapter(handler, new SmsOptions { MaxMessageLength = 20 });
+
+ var longNotification = new Notification(
+ Guid.NewGuid().ToString(), NotificationType.Sms, "ops-team",
+ "Subject", new string('x', 500), "site-1");
+
+ var outcome = await adapter.DeliverAsync(longNotification);
+
+ Assert.Equal(DeliveryResult.Success, outcome.Result);
+ // The encoded form body's Body value must not exceed the cap (form-encoding
+ // length aside, the raw text was truncated to 20 chars incl. ellipsis).
+ var body = Assert.Single(handler.RequestBodies);
+ Assert.Contains("Body=", body);
+ // The 500-char run cannot survive a 20-char cap.
+ Assert.DoesNotContain(new string('x', 100), body);
+ }
+
+ [Fact]
+ public async Task Deliver_CallerCancellation_Propagates()
+ {
+ SetupList();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created));
+
+ await Assert.ThrowsAnyAsync(
+ () => adapter.DeliverAsync(MakeNotification(), cts.Token));
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs
index 1ffa7ede..a9e42359 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs
@@ -99,15 +99,48 @@ public class ServiceRegistrationTests
}
[Fact]
- public void AddNotificationOutbox_RegistersEmailAdapter_AsINotificationDeliveryAdapter()
+ public void AddNotificationOutbox_RegistersSmsDeliveryAdapter()
+ {
+ using var provider = BuildProvider();
+ using var scope = provider.CreateScope();
+
+ var adapter = scope.ServiceProvider.GetRequiredService();
+
+ Assert.NotNull(adapter);
+ Assert.Equal(NotificationType.Sms, adapter.Type);
+ }
+
+ [Fact]
+ public void AddNotificationOutbox_RegistersBothAdapters_AsINotificationDeliveryAdapter()
{
using var provider = BuildProvider();
using var scope = provider.CreateScope();
var adapters = scope.ServiceProvider.GetServices().ToList();
- var email = Assert.Single(adapters);
- Assert.IsType(email);
- Assert.Equal(NotificationType.Email, email.Type);
+ // Both the Email and SMS channel adapters must resolve from the enumerable so the
+ // dispatch sweep can route a notification to the adapter matching its Type.
+ Assert.Equal(2, adapters.Count);
+
+ // The set of advertised channel Types is exactly {Email, Sms}.
+ Assert.Equal(
+ new[] { NotificationType.Email, NotificationType.Sms },
+ adapters.Select(a => a.Type).OrderBy(t => t).ToArray());
+
+ // And the concrete adapter implementations are the expected pair.
+ Assert.Contains(adapters, a => a is EmailNotificationDeliveryAdapter);
+ Assert.Contains(adapters, a => a is SmsNotificationDeliveryAdapter);
+ }
+
+ [Fact]
+ public void AddNotificationOutbox_RegistersSmsOptions_WithDefaults()
+ {
+ using var provider = BuildProvider();
+
+ var options = provider.GetRequiredService>().Value;
+
+ Assert.NotNull(options);
+ Assert.Equal(1600, options.MaxMessageLength);
+ Assert.Equal(30, options.ConnectionTimeoutSeconds);
}
}