@@ -34,7 +34,33 @@ public class SmtpRelayClient(ILogger<SmtpRelayClient>? logger = null) : ISmtpCli
3434
3535 public string Host { get ; set ; } = "localhost" ;
3636 public int Port { get ; set ; } = 25 ;
37+
38+ /// <summary>
39+ /// Gets or sets whether to use implicit TLS (SMTPS) by negotiating TLS immediately on connect.
40+ /// Typically used for port 465. For STARTTLS use <see cref="UseStartTls"/> instead.
41+ /// </summary>
3742 public bool EnableSsl { get ; set ; }
43+
44+ /// <summary>
45+ /// Gets or sets whether to attempt opportunistic STARTTLS after EHLO when the server advertises it.
46+ /// Ignored when <see cref="EnableSsl"/> (implicit TLS) is enabled.
47+ /// </summary>
48+ public bool UseStartTls { get ; set ; } = true ;
49+
50+ /// <summary>
51+ /// Gets or sets whether TLS is mandatory. When true, the connection fails unless TLS is
52+ /// established (via implicit TLS or STARTTLS). When false, TLS is opportunistic and the
53+ /// client falls back to plain text if the server does not offer it.
54+ /// </summary>
55+ public bool RequireTls { get ; set ; }
56+
57+ /// <summary>
58+ /// Gets or sets whether to validate the server certificate when TLS is required.
59+ /// For opportunistic TLS (when <see cref="RequireTls"/> is false) certificate errors are
60+ /// ignored, because encryption without authentication is still preferable to plain text.
61+ /// </summary>
62+ public bool ValidateServerCertificate { get ; set ; } = true ;
63+
3864 public SslProtocols SslProtocols { get ; set ; } = SslProtocols . Tls12 | SslProtocols . Tls13 ;
3965 public X509Certificate2 ? ClientCertificate { get ; set ; }
4066 public NetworkCredential ? Credentials { get ; set ; }
@@ -62,10 +88,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
6288 using CancellationTokenSource cts = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken ) ;
6389 cts . CancelAfter ( Timeout ) ;
6490
65- await _tcpClient . ConnectAsync ( Host , Port ) . ConfigureAwait ( false ) ;
91+ await _tcpClient . ConnectAsync ( Host , Port , cts . Token ) . ConfigureAwait ( false ) ;
6692
6793 _stream = _tcpClient . GetStream ( ) ;
6894
95+ // Implicit TLS (SMTPS, e.g. port 465): negotiate TLS before the greeting.
6996 if ( EnableSsl )
7097 {
7198 await UpgradeToSslAsync ( cts . Token ) . ConfigureAwait ( false ) ;
@@ -84,6 +111,28 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
84111 // Send EHLO
85112 await SendEhloAsync ( cts . Token ) . ConfigureAwait ( false ) ;
86113
114+ // Opportunistic / required STARTTLS upgrade (only when not already using implicit TLS).
115+ if ( ! EnableSsl && ( UseStartTls || RequireTls ) )
116+ {
117+ bool serverSupportsStartTls = _serverCapabilities ? . ContainsKey ( "STARTTLS" ) == true ;
118+
119+ if ( serverSupportsStartTls )
120+ {
121+ await StartTlsAsync ( cts . Token ) . ConfigureAwait ( false ) ;
122+ }
123+ else if ( RequireTls )
124+ {
125+ throw new InvalidOperationException (
126+ $ "TLS is required but server { Host } :{ Port } does not advertise STARTTLS") ;
127+ }
128+ else
129+ {
130+ _logger . LogWarning (
131+ "Server {Host}:{Port} does not support STARTTLS; continuing without encryption" ,
132+ Host , Port ) ;
133+ }
134+ }
135+
87136 _logger . LogInformation ( "Connected to {Host}:{Port}" , Host , Port ) ;
88137 }
89138 catch ( Exception ex )
@@ -386,16 +435,58 @@ private async Task SendEhloAsync(CancellationToken cancellationToken)
386435
387436 private async Task UpgradeToSslAsync ( CancellationToken cancellationToken )
388437 {
389- SslStream sslStream = new ( _stream , false , ValidateServerCertificate ) ;
438+ SslStream sslStream = new ( _stream ! , false , OnCertificateValidation ) ;
439+
440+ SslClientAuthenticationOptions options = new ( )
441+ {
442+ TargetHost = Host ,
443+ EnabledSslProtocols = SslProtocols ,
444+ CertificateRevocationCheckMode = X509RevocationMode . Online
445+ } ;
390446
391- await sslStream . AuthenticateAsClientAsync (
392- Host ,
393- ClientCertificate != null ? [ ClientCertificate ] : null ,
394- SslProtocols ,
395- true ) . ConfigureAwait ( false ) ;
447+ if ( ClientCertificate != null )
448+ {
449+ options . ClientCertificates = new X509CertificateCollection { ClientCertificate } ;
450+ }
451+
452+ // Pass the cancellation token so a stalled handshake honors the connection timeout.
453+ await sslStream . AuthenticateAsClientAsync ( options , cancellationToken ) . ConfigureAwait ( false ) ;
396454
397455 _stream = sslStream ;
398- _logger . LogDebug ( "SSL/TLS connection established" ) ;
456+ _logger . LogDebug ( "SSL/TLS connection established with {Host}:{Port}" , Host , Port ) ;
457+ }
458+
459+ private async Task StartTlsAsync ( CancellationToken cancellationToken )
460+ {
461+ await SendCommandAsync ( "STARTTLS" , cancellationToken ) . ConfigureAwait ( false ) ;
462+ SmtpResponse response = await ReadResponseAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
463+
464+ // RFC 3207: a 220 reply means the server is ready to negotiate TLS.
465+ if ( response . Code != 220 )
466+ {
467+ if ( RequireTls )
468+ {
469+ throw new InvalidOperationException (
470+ $ "STARTTLS command rejected by { Host } :{ Port } : { response . Code } { response . Message } ") ;
471+ }
472+
473+ _logger . LogWarning (
474+ "STARTTLS rejected by {Host}:{Port} ({Code} {Message}); continuing without encryption" ,
475+ Host , Port , response . Code , response . Message ) ;
476+ return ;
477+ }
478+
479+ await UpgradeToSslAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
480+
481+ // The reader/writer must be rebuilt on top of the now-encrypted stream. The old ones
482+ // are intentionally not disposed (that would close the underlying socket). Per RFC 3207
483+ // the server must not transmit anything after its "220" until the TLS handshake, so the
484+ // discarded plain-text reader cannot have buffered any post-handshake bytes.
485+ _reader = new StreamReader ( _stream ! , Encoding . ASCII ) ;
486+ _writer = new StreamWriter ( _stream ! , Encoding . ASCII ) { AutoFlush = true } ;
487+
488+ // RFC 3207: the client must re-issue EHLO over the secured channel.
489+ await SendEhloAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
399490 }
400491
401492 private async Task AuthPlainAsync ( CancellationToken cancellationToken )
@@ -548,7 +639,7 @@ private async Task<SmtpResponse> ReadMultilineResponseAsync(CancellationToken ca
548639 return new SmtpResponse ( code , [ .. lines ] ) ;
549640 }
550641
551- private bool ValidateServerCertificate (
642+ private bool OnCertificateValidation (
552643 object sender ,
553644 X509Certificate ? certificate ,
554645 X509Chain ? chain ,
@@ -559,9 +650,31 @@ private bool ValidateServerCertificate(
559650 return true ;
560651 }
561652
562- _logger . LogWarning ( "SSL certificate validation error: {Errors}" , sslPolicyErrors ) ;
653+ // Opportunistic STARTTLS only: when we upgraded an otherwise plain-text connection
654+ // and TLS is not required, encryption without authentication is still better than
655+ // falling back to plain text, so accept the certificate but warn. This must NOT apply
656+ // to implicit TLS (EnableSsl), where there is no plain-text fallback and silently
657+ // accepting any certificate would mask a man-in-the-middle.
658+ if ( ! EnableSsl && ! RequireTls )
659+ {
660+ _logger . LogWarning (
661+ "Ignoring SSL certificate error ({Errors}) for opportunistic TLS to {Host}:{Port}" ,
662+ sslPolicyErrors , Host , Port ) ;
663+ return true ;
664+ }
665+
666+ // Implicit TLS or required TLS: honor the configured certificate validation policy.
667+ if ( ! ValidateServerCertificate )
668+ {
669+ _logger . LogWarning (
670+ "SSL certificate error ({Errors}) ignored by configuration for {Host}:{Port}" ,
671+ sslPolicyErrors , Host , Port ) ;
672+ return true ;
673+ }
563674
564- // You might want to make this configurable
675+ _logger . LogWarning (
676+ "SSL certificate validation failed ({Errors}) for {Host}:{Port}" ,
677+ sslPolicyErrors , Host , Port ) ;
565678 return false ;
566679 }
567680
0 commit comments