refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,81 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// NS-009: Scrubs SMTP credential secrets out of free text (typically exception
/// messages echoed back by an SMTP server) before that text is written to a log.
/// MailKit authentication exceptions can contain server responses that quote the
/// supplied credentials; this prevents a password, client secret, or OAuth2 token
/// from leaking into the operational logs.
/// <para>
/// Public so the central Notification Outbox's <c>EmailNotificationDeliveryAdapter</c>
/// can share this exact redaction logic rather than carry a divergent copy.
/// </para>
/// </summary>
public static class CredentialRedactor
{
private const string Mask = "***REDACTED***";
/// <summary>
/// Returns <paramref name="text"/> with every secret component of the supplied
/// colon-delimited credential string masked.
/// </summary>
/// <param name="text">The text to scrub (e.g. an exception message).</param>
/// <param name="credentials">
/// The credential string in use — Basic Auth <c>user:pass</c> or OAuth2
/// <c>tenantId:clientId:clientSecret</c>. May be null.
/// </param>
/// <summary>
/// NS-025: minimum length for a colon-separated SECRET component to be
/// considered worth masking. Twelve characters is the standard heuristic
/// for "long enough to be a password / client secret"; shorter components
/// (e.g. a 4-char user name like <c>root</c>, or a 7-char "from" alias)
/// would mask too much unrelated diagnostic text if treated as secrets.
/// </summary>
private const int MinSecretLength = 12;
public static string Scrub(string? text, string? credentials)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials))
{
return text ?? string.Empty;
}
var result = text;
// NS-025: redact only the obviously-secret slots — the LAST
// colon-separated component (the password in Basic, the client
// secret in OAuth2) and the whole packed string — not the user
// name / tenant id / client id. A short user name like "root" or
// a sender alias like "smtp" no longer becomes a global redaction
// token that eats unrelated path / error text.
var secretsToRedact = new List<string>();
// The full packed credential is always the most-sensitive shape.
secretsToRedact.Add(credentials);
// The trailing colon-component is the password / clientSecret slot.
// Only redact it if it's plausibly secret-shaped (>= MinSecretLength).
var parts = credentials.Split(':');
if (parts.Length >= 2)
{
var lastComponent = parts[^1];
if (lastComponent.Length >= MinSecretLength)
{
secretsToRedact.Add(lastComponent);
}
}
// Order longest first so a secret that is a substring of the packed
// string is still fully masked.
var ordered = secretsToRedact
.Distinct()
.OrderByDescending(s => s.Length);
foreach (var part in ordered)
{
result = result.Replace(part, Mask, StringComparison.Ordinal);
}
return result;
}
}
@@ -0,0 +1,40 @@
using MimeKit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// NS-008: Validates the sender and recipient email addresses before an SMTP
/// delivery is attempted, so a malformed address surfaces as a clean error
/// string rather than a <c>ParseException</c> escaping the delivery path.
/// <para>
/// Public so the central Notification Outbox's <c>EmailNotificationDeliveryAdapter</c>
/// can share this exact pre-send validation rather than carry a divergent copy.
/// </para>
/// </summary>
public static class EmailAddressValidator
{
/// <summary>
/// Validates the sender and recipient email addresses, returning a
/// human-readable error string if any is malformed, or null if all parse.
/// </summary>
/// <param name="fromAddress">The sender email address to validate.</param>
/// <param name="recipients">The list of recipient addresses to validate.</param>
public static string? ValidateAddresses(
string fromAddress, IReadOnlyList<NotificationRecipient> recipients)
{
if (!MailboxAddress.TryParse(fromAddress, out _))
{
return $"Invalid sender (from) email address: '{fromAddress}'";
}
var invalid = recipients
.Where(r => !MailboxAddress.TryParse(r.EmailAddress, out _))
.Select(r => r.EmailAddress)
.ToList();
return invalid.Count > 0
? $"Invalid recipient email address(es): {string.Join(", ", invalid)}"
: null;
}
}
@@ -0,0 +1,55 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// Abstraction over SMTP client for testability.
/// </summary>
public interface ISmtpClientWrapper
{
/// <summary>
/// Connects to the SMTP server.
/// </summary>
/// <param name="tlsMode">
/// NS-005: explicit three-state TLS mode (None/StartTls/Ssl) — replaces the old
/// <c>bool useTls</c> which could not represent implicit-SSL and silently fell
/// back to opportunistic negotiation for non-StartTLS configurations.
/// </param>
/// <param name="connectionTimeoutSeconds">
/// NS-007: SMTP connection/operation timeout in seconds. A non-positive value
/// leaves the client's default timeout in place.
/// </param>
/// <param name="host">SMTP server hostname or IP address.</param>
/// <param name="port">SMTP server port.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task ConnectAsync(
string host,
int port,
SmtpTlsMode tlsMode,
int connectionTimeoutSeconds,
CancellationToken cancellationToken = default);
/// <summary>Authenticates to the SMTP server using the specified auth type and credentials.</summary>
/// <param name="authType">Authentication mechanism (e.g. <c>PLAIN</c>, <c>XOAUTH2</c>).</param>
/// <param name="credentials">Credential string appropriate for the auth type, or null.</param>
/// <param name="oauth2UserName">
/// NS-021: mailbox identity the OAuth2 access token was issued for (typically
/// the SMTP <c>FromAddress</c>). Used as the <c>user=</c> field of the XOAUTH2
/// SASL initial response — M365 rejects an empty/mismatched user with
/// <c>535 5.7.3</c>. Ignored for non-OAuth2 auth types; default <c>null</c> for
/// callers that do not authenticate with OAuth2.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AuthenticateAsync(
string authType,
string? credentials,
string? oauth2UserName = null,
CancellationToken cancellationToken = default);
/// <summary>Sends an email message with the specified recipients via BCC.</summary>
/// <param name="from">Sender address.</param>
/// <param name="bccRecipients">Recipients delivered as BCC.</param>
/// <param name="subject">Email subject line.</param>
/// <param name="body">Plain-text email body.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default);
/// <summary>Disconnects from the SMTP server gracefully.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task DisconnectAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,167 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// WP-11: MailKit-based SMTP client wrapper.
/// Supports OAuth2 Client Credentials (M365) and Basic Auth.
/// BCC delivery, plain text.
/// </summary>
/// <remarks>
/// <para>
/// <b>Lifetime — one wrapper, one delivery (NS-022).</b>
/// This wrapper owns a SINGLE underlying <see cref="MailKit.Net.Smtp.SmtpClient"/>
/// — it is NOT a connection pool. MailKit's <c>SmtpClient</c> is a single TCP/TLS
/// connection holder and is NOT thread-safe; reusing one across concurrent or
/// back-to-back deliveries without external synchronization is unsafe.
/// </para>
/// <para>
/// The DI registration (<c>AddSingleton&lt;Func&lt;ISmtpClientWrapper&gt;&gt;</c>)
/// is therefore a per-delivery FACTORY, not a singleton wrapper: callers
/// (<see cref="ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery.EmailNotificationDeliveryAdapter"/>)
/// invoke the factory at the top of every <c>DeliverAsync</c>, run the
/// connect/authenticate/send/disconnect sequence on the fresh wrapper, and
/// dispose it at the end of the send. Each delivery pays a full TCP+TLS
/// handshake; this is the deliberate, documented cost of avoiding shared
/// connection state. The factory shape exists specifically so a future
/// pooled/synchronized implementation can be slotted in without changing
/// callers — but the current implementation deliberately does NOT pool.
/// </para>
/// <para>
/// Do not reuse one wrapper across deliveries. <see cref="ConnectAsync"/>
/// mutates <c>_client.Timeout</c> per call (NS-007), and the underlying
/// <c>SmtpClient</c> rejects concurrent send calls — both are latent footguns
/// for any caller tempted to "fix" the factory into a true singleton.
/// </para>
/// </remarks>
public class MailKitSmtpClientWrapper : ISmtpClientWrapper, IDisposable
{
// NS-022: ONE SmtpClient per wrapper — see class-level remarks. This is NOT a
// connection pool. MailKit's SmtpClient holds a single TCP/TLS connection and
// is not thread-safe; the wrapper is meant for a single connect/auth/send/
// disconnect cycle per instance, after which it MUST be disposed.
private readonly SmtpClient _client = new();
/// <inheritdoc />
public async Task ConnectAsync(
string host,
int port,
SmtpTlsMode tlsMode,
int connectionTimeoutSeconds,
CancellationToken cancellationToken = default)
{
// NS-005: map the explicit three-state TLS mode onto MailKit's socket
// options. The old code collapsed everything to a boolean and used
// SecureSocketOptions.Auto for the non-StartTLS case, which let MailKit
// opportunistically negotiate TLS even when "None" was configured and
// gave SSL-on-connect no representation at all.
var secureSocket = tlsMode switch
{
SmtpTlsMode.None => SecureSocketOptions.None,
SmtpTlsMode.StartTls => SecureSocketOptions.StartTls,
SmtpTlsMode.Ssl => SecureSocketOptions.SslOnConnect,
_ => throw new ArgumentOutOfRangeException(nameof(tlsMode), tlsMode, "Unknown TLS mode."),
};
// NS-007: honour the configured connection timeout. SmtpClient.Timeout is
// in milliseconds and applies to connect/auth/send operations.
if (connectionTimeoutSeconds > 0)
{
_client.Timeout = connectionTimeoutSeconds * 1000;
}
await _client.ConnectAsync(host, port, secureSocket, cancellationToken);
}
/// <inheritdoc />
public async Task AuthenticateAsync(
string authType,
string? credentials,
string? oauth2UserName = null,
CancellationToken cancellationToken = default)
{
// NS-016: missing/unparseable credentials and an unrecognised auth type used
// to make this method silently return and the connection then sent mail
// unauthenticated — masking a misconfiguration against an open relay and, at
// worst, sending where authentication was required. Authentication being
// skipped must never be silent: each of these is a permanent configuration
// fault, surfaced as SmtpPermanentException so SendAsync returns a clean
// failure that the central Notification Outbox dispatcher classifies as permanent.
if (string.IsNullOrEmpty(credentials))
{
throw new SmtpPermanentException(
$"SMTP auth type '{authType}' requires credentials, but none are configured.");
}
switch (authType.ToLowerInvariant())
{
case "basic":
var parts = credentials.Split(':', 2);
if (parts.Length != 2)
{
throw new SmtpPermanentException(
"Basic SMTP credentials must be in 'username:password' form.");
}
await _client.AuthenticateAsync(parts[0], parts[1], cancellationToken);
break;
case "oauth2":
// NS-021: the XOAUTH2 SASL initial response embeds a `user=<userName>`
// field that M365 (and most OAuth2-enabled SMTP relays) require to
// match the mailbox identity the token was issued for. An empty user
// gets rejected with `535 5.7.3`. The token (credentials) is
// pre-fetched by OAuth2TokenService; the user identity is the SMTP
// From address, threaded through `oauth2UserName`.
if (string.IsNullOrEmpty(oauth2UserName))
{
throw new SmtpPermanentException(
"OAuth2 SMTP auth requires a non-empty user identity " +
"(mailbox the access token was issued for); " +
"the caller did not pass an oauth2UserName.");
}
var oauth2 = new SaslMechanismOAuth2(oauth2UserName, credentials);
await _client.AuthenticateAsync(oauth2, cancellationToken);
break;
default:
throw new SmtpPermanentException(
$"Unsupported SMTP auth type '{authType}'. Expected one of: basic, oauth2.");
}
}
/// <inheritdoc />
public async Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
{
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(from));
foreach (var recipient in bccRecipients)
{
message.Bcc.Add(MailboxAddress.Parse(recipient));
}
message.Subject = subject;
message.Body = new TextPart("plain") { Text = body };
await _client.SendAsync(message, cancellationToken);
}
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
if (_client.IsConnected)
{
await _client.DisconnectAsync(true, cancellationToken);
}
}
/// <summary>Disposes the underlying MailKit SMTP client.</summary>
public void Dispose()
{
_client.Dispose();
}
}
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// Configuration options for the Notification Service, bound from the
/// <c>ScadaBridge:Notification</c> configuration section.
///
/// SMTP settings are primarily carried by the deployed <c>SmtpConfiguration</c>
/// entity. NS-017: these values are the fallback used by the central
/// Notification Outbox's <c>EmailNotificationDeliveryAdapter</c> when the
/// corresponding <c>SmtpConfiguration</c> field is left unset (non-positive) on a
/// partially deployed row — a value present on the row always takes precedence.
/// </summary>
public class NotificationOptions
{
/// <summary>
/// Connection timeout (seconds) used when <c>SmtpConfiguration.ConnectionTimeoutSeconds</c>
/// is unset. Default 30s.
/// </summary>
public int ConnectionTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Maximum concurrent SMTP connections used when
/// <c>SmtpConfiguration.MaxConcurrentConnections</c> is unset. Default 5.
/// </summary>
public int MaxConcurrentConnections { get; set; } = 5;
}
@@ -0,0 +1,124 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// WP-11: OAuth2 Client Credentials token lifecycle — fetch, cache, refresh on expiry.
/// Used for Microsoft 365 SMTP authentication.
/// NS-006: tokens are cached per credential identity (tenant/client/secret), so a
/// second SMTP configuration with different credentials never receives the first
/// configuration's token.
/// </summary>
public class OAuth2TokenService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<OAuth2TokenService> _logger;
// NS-006: cache keyed by a hash of the credential string. Each distinct
// tenant/client/secret triple gets its own cached token and its own lock.
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
/// <summary>Initializes the service with an HTTP client factory and logger.</summary>
/// <param name="httpClientFactory">Factory used to create HTTP clients for token endpoint requests.</param>
/// <param name="logger">Logger instance.</param>
public OAuth2TokenService(
IHttpClientFactory httpClientFactory,
ILogger<OAuth2TokenService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <summary>
/// Gets a valid access token, refreshing if expired.
/// Credentials format: "tenantId:clientId:clientSecret"
/// </summary>
/// <param name="credentials">Colon-separated string in the form <c>tenantId:clientId:clientSecret</c>.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A valid OAuth2 access token string.</returns>
public async Task<string> GetTokenAsync(string credentials, CancellationToken cancellationToken = default)
{
var key = CredentialKey(credentials);
var entry = _cache.GetOrAdd(key, _ => new CacheEntry());
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
{
return entry.Token;
}
await entry.Lock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring the per-credential lock.
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
{
return entry.Token;
}
var parts = credentials.Split(':', 3);
if (parts.Length < 3)
{
throw new InvalidOperationException("OAuth2 credentials must be 'tenantId:clientId:clientSecret'");
}
var tenantId = parts[0];
var clientId = parts[1];
var clientSecret = parts[2];
var client = _httpClientFactory.CreateClient("OAuth2");
var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = clientId,
["client_secret"] = clientSecret,
["scope"] = "https://outlook.office365.com/.default"
});
var response = await client.PostAsync(tokenUrl, form, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var token = doc.RootElement.GetProperty("access_token").GetString()
?? throw new InvalidOperationException("No access_token in OAuth2 response");
var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();
entry.Token = token;
entry.Expiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60); // Refresh 60s before expiry
// NS-009: the token endpoint identity is logged by tenant only — never
// the client secret or the access token itself.
_logger.LogInformation(
"OAuth2 token refreshed for tenant {Tenant}, expires in {ExpiresIn}s", tenantId, expiresIn);
return token;
}
finally
{
entry.Lock.Release();
}
}
/// <summary>
/// NS-006: a stable, non-reversible key for the credential string so the cache
/// is partitioned by credential identity without holding the secret as a key.
/// </summary>
private static string CredentialKey(string credentials)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(credentials));
return Convert.ToHexString(hash);
}
private sealed class CacheEntry
{
public string? Token;
public DateTimeOffset Expiry = DateTimeOffset.MinValue;
public readonly SemaphoreSlim Lock = new(1, 1);
}
}
@@ -0,0 +1,38 @@
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the shared SMTP delivery primitives consumed by the central Notification
/// Outbox's <c>EmailNotificationDeliveryAdapter</c>: <see cref="NotificationOptions"/>,
/// <see cref="OAuth2TokenService"/>, and the <see cref="ISmtpClientWrapper"/> factory.
/// Central-only — sites no longer deliver notifications (see
/// <c>Component-NotificationService.md</c>), and the orphaned site-shaped
/// <c>NotificationDeliveryService</c> + <c>INotificationDeliveryService</c> contract
/// was removed (NS-019). Notification dispatch lives in <c>ZB.MOM.WW.ScadaBridge.NotificationOutbox</c>.
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddNotificationService(this IServiceCollection services)
{
services.AddOptions<NotificationOptions>()
.BindConfiguration("ScadaBridge:Notification");
services.AddHttpClient();
services.AddSingleton<OAuth2TokenService>();
services.AddSingleton<Func<ISmtpClientWrapper>>(_ => () => new MailKitSmtpClientWrapper());
return services;
}
/// <summary>
/// Registers Akka.NET actors for the notification service (placeholder for actor registration).
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddNotificationServiceActors(this IServiceCollection services)
{
// Actor registration happens in AkkaHostedService.
return services;
}
}
@@ -0,0 +1,95 @@
using System.Net.Sockets;
using MailKit;
using MailKit.Net.Smtp;
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// NS-002/NS-003: The classification of an SMTP delivery failure. This decides
/// whether a failure is retried or surfaced to the caller, so it is part of the
/// system's correctness-relevant behaviour.
/// </summary>
public enum SmtpErrorClass
{
/// <summary>Cancellation or an unrecognised exception — caller decides.</summary>
Unknown,
/// <summary>Retryable failure (4xx, connection/socket/protocol error, timeout).</summary>
Transient,
/// <summary>Non-retryable failure (5xx) — must not be retried.</summary>
Permanent,
}
/// <summary>
/// NS-002/NS-003: Classifies an SMTP failure using MailKit's typed exceptions and
/// the numeric <see cref="SmtpStatusCode"/> rather than locale-dependent substring
/// matching on the exception message.
/// <para>
/// Public and shared: the central Notification Outbox's <c>EmailNotificationDeliveryAdapter</c>
/// routes every SMTP failure through this single policy. (NS-019: the orphaned site-side
/// <c>NotificationDeliveryService</c> that previously co-used this classifier was removed
/// when sites stopped delivering notifications.)
/// </para>
/// </summary>
public static class SmtpErrorClassifier
{
/// <summary>
/// Classifies an SMTP failure. A cancellation requested by the caller is never
/// treated as a transient SMTP error.
/// </summary>
/// <param name="ex">The exception thrown by the SMTP send sequence.</param>
/// <param name="cancellationToken">
/// The token governing the send; a requested cancellation classifies as
/// <see cref="SmtpErrorClass.Unknown"/> so the caller can re-throw it.
/// </param>
public static SmtpErrorClass Classify(Exception ex, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(ex);
// A deliberate cancellation is not an SMTP error at all.
if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
{
return SmtpErrorClass.Unknown;
}
// MailKit reports SMTP command failures with the real status code; the
// SmtpStatusCode enum's underlying value is the numeric SMTP reply code.
if (ex is SmtpCommandException command)
{
var code = (int)command.StatusCode;
if (code >= 400 && code < 500)
{
return SmtpErrorClass.Transient;
}
if (code >= 500 && code < 600)
{
return SmtpErrorClass.Permanent;
}
return SmtpErrorClass.Unknown;
}
// Protocol errors, a dropped/unavailable service, socket failures and
// timeouts are all retryable — the message has not been rejected.
if (ex is SmtpProtocolException
or ServiceNotConnectedException
or SocketException
or TimeoutException)
{
return SmtpErrorClass.Transient;
}
return SmtpErrorClass.Unknown;
}
/// <summary>
/// Convenience predicate: true when <see cref="Classify"/> returns
/// <see cref="SmtpErrorClass.Transient"/>.
/// </summary>
/// <param name="ex">The exception to classify.</param>
/// <param name="cancellationToken">Cancellation token passed to <see cref="Classify"/>.</param>
public static bool IsTransient(Exception ex, CancellationToken cancellationToken)
=> Classify(ex, cancellationToken) == SmtpErrorClass.Transient;
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// Signals a permanent SMTP failure (5xx) that should not be retried.
/// </summary>
public class SmtpPermanentException : Exception
{
/// <summary>
/// Initializes the exception with a message and optional inner exception.
/// </summary>
/// <param name="message">Message describing the permanent SMTP failure.</param>
/// <param name="innerException">Optional inner exception from the SMTP client.</param>
public SmtpPermanentException(string message, Exception? innerException = null)
: base(message, innerException) { }
}
@@ -0,0 +1,50 @@
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
/// <summary>
/// NS-005: The three TLS modes the design doc defines for SMTP connections.
/// A single boolean cannot represent the requirement, so the configured
/// <c>SmtpConfiguration.TlsMode</c> string is parsed into this three-state enum.
/// </summary>
public enum SmtpTlsMode
{
/// <summary>No transport security — plain SMTP. Maps to <c>SecureSocketOptions.None</c>.</summary>
None,
/// <summary>Opportunistic STARTTLS upgrade (typically port 587). Maps to <c>SecureSocketOptions.StartTls</c>.</summary>
StartTls,
/// <summary>Implicit TLS / SSL-on-connect (typically port 465). Maps to <c>SecureSocketOptions.SslOnConnect</c>.</summary>
Ssl,
}
/// <summary>
/// NS-005: Parses the free-text <c>SmtpConfiguration.TlsMode</c> value into a
/// <see cref="SmtpTlsMode"/>, rejecting unknown values rather than silently
/// falling back to opportunistic negotiation.
/// </summary>
public static class SmtpTlsModeParser
{
/// <summary>
/// Parses a configured TLS mode string. A null or empty value defaults to
/// <see cref="SmtpTlsMode.StartTls"/> (the design-doc default for port 587).
/// </summary>
/// <param name="tlsMode">The TLS mode string to parse (None, StartTLS, or SSL); null/empty defaults to StartTLS.</param>
/// <exception cref="ArgumentException">The value is not one of None/StartTLS/SSL.</exception>
public static SmtpTlsMode Parse(string? tlsMode)
{
if (string.IsNullOrWhiteSpace(tlsMode))
{
return SmtpTlsMode.StartTls;
}
return tlsMode.Trim().ToLowerInvariant() switch
{
"none" => SmtpTlsMode.None,
"starttls" => SmtpTlsMode.StartTls,
"ssl" => SmtpTlsMode.Ssl,
_ => throw new ArgumentException(
$"Unknown SMTP TLS mode '{tlsMode}'. Expected one of: None, StartTLS, SSL.",
nameof(tlsMode)),
};
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.StoreAndForward/ZB.MOM.WW.ScadaBridge.StoreAndForward.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.NotificationService.Tests" />
</ItemGroup>
</Project>