using MailKit.Net.Smtp; using MailKit.Security; using MimeKit; namespace ZB.MOM.WW.ScadaBridge.NotificationService; /// /// WP-11: MailKit-based SMTP client wrapper. /// Supports OAuth2 Client Credentials (M365) and Basic Auth. /// BCC delivery, plain text. /// /// /// /// Lifetime — one wrapper, one delivery (NS-022). /// This wrapper owns a SINGLE underlying /// — it is NOT a connection pool. MailKit's SmtpClient 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. /// /// /// The DI registration (AddSingleton<Func<ISmtpClientWrapper>>) /// is therefore a per-delivery FACTORY, not a singleton wrapper: callers /// () /// invoke the factory at the top of every DeliverAsync, 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. /// /// /// Do not reuse one wrapper across deliveries. /// mutates _client.Timeout per call (NS-007), and the underlying /// SmtpClient rejects concurrent send calls — both are latent footguns /// for any caller tempted to "fix" the factory into a true singleton. /// /// 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(); /// 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); } /// 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=` // 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."); } } /// public async Task SendAsync(string from, IEnumerable 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); } /// public async Task DisconnectAsync(CancellationToken cancellationToken = default) { if (_client.IsConnected) { await _client.DisconnectAsync(true, cancellationToken); } } /// Disposes the underlying MailKit SMTP client. public void Dispose() { _client.Dispose(); } }