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:
@@ -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<Func<ISmtpClientWrapper>></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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
+27
@@ -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>
|
||||
Reference in New Issue
Block a user