9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
168 lines
7.1 KiB
C#
168 lines
7.1 KiB
C#
using MailKit.Net.Smtp;
|
|
using MailKit.Security;
|
|
using MimeKit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
|
|
|
|
/// <summary>
|
|
/// 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.</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, 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
|
|
{
|
|
// 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)
|
|
{
|
|
// 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."),
|
|
};
|
|
|
|
// 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)
|
|
{
|
|
// 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":
|
|
// 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();
|
|
}
|
|
}
|