From 9a6e1c54667d70b1607f75b26da5a2a803a66eaa Mon Sep 17 00:00:00 2001 From: Jeffrey Stedfast Date: Tue, 13 Feb 2024 22:35:39 -0500 Subject: [PATCH] Added Activity/Metrics for Imap/Pop3/SmtpClient Fixes issue #1499 --- MailKit/MailKit.csproj | 6 +- MailKit/MailKitLite.csproj | 4 + MailKit/Net/ClientMetrics.cs | 130 +++++++++ MailKit/Net/Imap/AsyncImapClient.cs | 266 +++++++++--------- MailKit/Net/Imap/ImapClient.cs | 270 ++++++++++--------- MailKit/Net/Imap/ImapEngine.cs | 63 +++-- MailKit/Net/NetworkOperation.cs | 141 ++++++++++ MailKit/Net/Pop3/AsyncPop3Client.cs | 216 ++++++++------- MailKit/Net/Pop3/Pop3Client.cs | 214 ++++++++------- MailKit/Net/Pop3/Pop3Engine.cs | 59 +++- MailKit/Net/Smtp/AsyncSmtpClient.cs | 324 ++++++++++++---------- MailKit/Net/Smtp/SmtpClient.cs | 395 +++++++++++++++++---------- MailKit/Net/SocketMetrics.cs | 150 +++++++++++ MailKit/Net/SocketUtils.cs | 53 +++- MailKit/Telemetry.cs | 400 ++++++++++++++++++++++++++++ Telemetry.md | 384 ++++++++++++++++++++++++++ 16 files changed, 2322 insertions(+), 753 deletions(-) create mode 100644 MailKit/Net/ClientMetrics.cs create mode 100644 MailKit/Net/NetworkOperation.cs create mode 100644 MailKit/Net/SocketMetrics.cs create mode 100644 MailKit/Telemetry.cs create mode 100644 Telemetry.md diff --git a/MailKit/MailKit.csproj b/MailKit/MailKit.csproj index 855c385b02..1d5e3684fd 100644 --- a/MailKit/MailKit.csproj +++ b/MailKit/MailKit.csproj @@ -1,4 +1,4 @@ - + An Open Source cross-platform .NET mail-client library that is based on MimeKit and optimized for mobile devices. @@ -122,8 +122,11 @@ + + + @@ -273,6 +276,7 @@ + diff --git a/MailKit/MailKitLite.csproj b/MailKit/MailKitLite.csproj index f8309fe334..5c78cf197e 100644 --- a/MailKit/MailKitLite.csproj +++ b/MailKit/MailKitLite.csproj @@ -127,8 +127,11 @@ + + + @@ -276,6 +279,7 @@ + diff --git a/MailKit/Net/ClientMetrics.cs b/MailKit/Net/ClientMetrics.cs new file mode 100644 index 0000000000..65de5117f8 --- /dev/null +++ b/MailKit/Net/ClientMetrics.cs @@ -0,0 +1,130 @@ +// +// ClientMetrics.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2024 .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#if NET6_0_OR_GREATER + +using System; +using System.Diagnostics; +using System.Globalization; +using System.Diagnostics.Metrics; + +using MailKit.Net.Smtp; +using MailKit.Security; + +namespace MailKit.Net { + sealed class ClientMetrics + { + public readonly Histogram ConnectionDuration; + public readonly Counter OperationCounter; + public readonly Histogram OperationDuration; + public readonly string MeterName; + + public ClientMetrics (Meter meter, string meterName, string an, string protocol) + { + MeterName = meterName; + + ConnectionDuration = meter.CreateHistogram ( + name: $"{meterName}.client.connection.duration", + unit: "s", + description: $"The duration of successfully established connections to {an} {protocol} server."); + + OperationCounter = meter.CreateCounter ( + name: $"{meterName}.client.operation.count", + unit: "{operation}", + description: $"The number of times a client performed an operation on {an} {protocol} server."); + + OperationDuration = meter.CreateHistogram ( + name: $"{meterName}.client.operation.duration", + unit: "ms", + description: $"The amount of time it takes for the {protocol} server to perform an operation."); + } + + static bool TryGetErrorType (Exception exception, out string errorType) + { + if (SocketMetrics.TryGetErrorType (exception, false, out errorType)) + return true; + + if (exception is SslHandshakeException) { + // Note: The string "secure_connection_error" is used by HttpClient for SSL/TLS handshake errors. + errorType = "secure_connection_error"; + return true; + } + + if (exception is ProtocolException) { + // TODO: ProtocolExceptions tend to be either "Unexpectedly disconnected" or "Parse error". + // If we add a property to ProtocolException to tell us this, we could report it better here. + // + // To mimic HttpClient error.type values, we could use "response_ended" and "invalid_response", respectively. + // + // Alternatively, HttpClient also uses "http_protocol_error" so we could use "smtp/pop3/imap_protocol_error". + errorType = "protocol_error"; + return true; + } + + if (exception is SmtpCommandException smtp) { + errorType = ((int) smtp.StatusCode).ToString (CultureInfo.InvariantCulture); + return true; + } + + if (exception is CommandException) { + // FIXME: We need to add a property to CommandException to tell us the error type. + errorType = "command_error"; + return true; + } + + // Fall back to using the exception type name. + errorType = exception.GetType ().FullName; + + return true; + } + + internal static TagList GetTags (Uri uri, Exception ex) + { + var tags = new TagList { + { "url.scheme", uri.Scheme }, + { "server.address", uri.Host }, + { "server.port", uri.Port } + }; + + if (ex is not null && TryGetErrorType (ex, out var errorType)) + tags.Add ("error.type", errorType); + + return tags; + } + + public void RecordClientDisconnected (long startTimestamp, Uri uri, Exception ex = null) + { + if (ConnectionDuration.Enabled) { + var duration = TimeSpan.FromTicks (Stopwatch.GetTimestamp () - startTimestamp).TotalSeconds; + var tags = GetTags (uri, ex); + + ConnectionDuration.Record (duration, tags); + } + } + } +} + +#endif // NET6_0_OR_GREATER diff --git a/MailKit/Net/Imap/AsyncImapClient.cs b/MailKit/Net/Imap/AsyncImapClient.cs index 0f919ea210..670f067721 100644 --- a/MailKit/Net/Imap/AsyncImapClient.cs +++ b/MailKit/Net/Imap/AsyncImapClient.cs @@ -312,22 +312,29 @@ public override async Task AuthenticateAsync (SaslMechanism mechanism, Cancellat await imap.Stream.FlushAsync (cmd.CancellationToken).ConfigureAwait (false); }; - detector.IsAuthenticating = true; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Authenticate); try { - await engine.RunAsync (ic).ConfigureAwait (false); - } finally { - detector.IsAuthenticating = false; - } + detector.IsAuthenticating = true; + + try { + await engine.RunAsync (ic).ConfigureAwait (false); + } finally { + detector.IsAuthenticating = false; + } - ProcessAuthenticateResponse (ic, mechanism); + ProcessAuthenticateResponse (ic, mechanism); - // Query the CAPABILITIES again if the server did not include an - // untagged CAPABILITIES response to the AUTHENTICATE command. - if (engine.CapabilitiesVersion == capabilitiesVersion) - await engine.QueryCapabilitiesAsync (cancellationToken).ConfigureAwait (false); + // Query the CAPABILITIES again if the server did not include an + // untagged CAPABILITIES response to the AUTHENTICATE command. + if (engine.CapabilitiesVersion == capabilitiesVersion) + await engine.QueryCapabilitiesAsync (cancellationToken).ConfigureAwait (false); - await OnAuthenticatedAsync (ic.ResponseText ?? string.Empty, cancellationToken).ConfigureAwait (false); + await OnAuthenticatedAsync (ic.ResponseText ?? string.Empty, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } /// @@ -386,41 +393,84 @@ public override async Task AuthenticateAsync (Encoding encoding, ICredentials cr { CheckCanAuthenticate (encoding, credentials); - int capabilitiesVersion = engine.CapabilitiesVersion; - var uri = new Uri ("imap://" + engine.Uri.Host); - NetworkCredential cred; - ImapCommand ic = null; - SaslMechanism sasl; - string id; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Authenticate); + + try { + int capabilitiesVersion = engine.CapabilitiesVersion; + var uri = new Uri ("imap://" + engine.Uri.Host); + NetworkCredential cred; + ImapCommand ic = null; + SaslMechanism sasl; + string id; - foreach (var authmech in SaslMechanism.Rank (engine.AuthenticationMechanisms)) { - cred = credentials.GetCredential (uri, authmech); + foreach (var authmech in SaslMechanism.Rank (engine.AuthenticationMechanisms)) { + cred = credentials.GetCredential (uri, authmech); - if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) - continue; + if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) + continue; - ConfigureSaslMechanism (sasl, uri); + ConfigureSaslMechanism (sasl, uri); - cancellationToken.ThrowIfCancellationRequested (); + cancellationToken.ThrowIfCancellationRequested (); - var command = string.Format ("AUTHENTICATE {0}", sasl.MechanismName); + var command = string.Format ("AUTHENTICATE {0}", sasl.MechanismName); - if ((engine.Capabilities & ImapCapabilities.SaslIR) != 0 && sasl.SupportsInitialResponse) { - string ir = await sasl.ChallengeAsync (null, cancellationToken).ConfigureAwait (false); + if ((engine.Capabilities & ImapCapabilities.SaslIR) != 0 && sasl.SupportsInitialResponse) { + string ir = await sasl.ChallengeAsync (null, cancellationToken).ConfigureAwait (false); - command += " " + ir + "\r\n"; - } else { - command += "\r\n"; + command += " " + ir + "\r\n"; + } else { + command += "\r\n"; + } + + ic = engine.QueueCommand (cancellationToken, null, command); + ic.ContinuationHandler = async (imap, cmd, text, xdoAsync) => { + string challenge = await sasl.ChallengeAsync (text, cmd.CancellationToken).ConfigureAwait (false); + var buf = Encoding.ASCII.GetBytes (challenge + "\r\n"); + + await imap.Stream.WriteAsync (buf, 0, buf.Length, cmd.CancellationToken).ConfigureAwait (false); + await imap.Stream.FlushAsync (cmd.CancellationToken).ConfigureAwait (false); + }; + + detector.IsAuthenticating = true; + + try { + await engine.RunAsync (ic).ConfigureAwait (false); + } finally { + detector.IsAuthenticating = false; + } + + if (ic.Response != ImapCommandResponse.Ok) { + EmitAndThrowOnAlert (ic); + if (ic.Bye) + throw new ImapProtocolException (ic.ResponseText); + continue; + } + + engine.State = ImapEngineState.Authenticated; + + cred = credentials.GetCredential (uri, sasl.MechanismName); + id = GetSessionIdentifier (cred.UserName); + if (id != identifier) { + engine.FolderCache.Clear (); + identifier = id; + } + + // Query the CAPABILITIES again if the server did not include an + // untagged CAPABILITIES response to the AUTHENTICATE command. + if (engine.CapabilitiesVersion == capabilitiesVersion) + await engine.QueryCapabilitiesAsync (cancellationToken).ConfigureAwait (false); + + await OnAuthenticatedAsync (ic.ResponseText ?? string.Empty, cancellationToken).ConfigureAwait (false); + return; } - ic = engine.QueueCommand (cancellationToken, null, command); - ic.ContinuationHandler = async (imap, cmd, text, xdoAsync) => { - string challenge = await sasl.ChallengeAsync (text, cmd.CancellationToken).ConfigureAwait (false); - var buf = Encoding.ASCII.GetBytes (challenge + "\r\n"); + CheckCanLogin (ic); - await imap.Stream.WriteAsync (buf, 0, buf.Length, cmd.CancellationToken).ConfigureAwait (false); - await imap.Stream.FlushAsync (cmd.CancellationToken).ConfigureAwait (false); - }; + // fall back to the classic LOGIN command... + cred = credentials.GetCredential (uri, "DEFAULT"); + + ic = engine.QueueCommand (cancellationToken, null, "LOGIN %S %S\r\n", cred.UserName, cred.Password); detector.IsAuthenticating = true; @@ -430,16 +480,11 @@ public override async Task AuthenticateAsync (Encoding encoding, ICredentials cr detector.IsAuthenticating = false; } - if (ic.Response != ImapCommandResponse.Ok) { - EmitAndThrowOnAlert (ic); - if (ic.Bye) - throw new ImapProtocolException (ic.ResponseText); - continue; - } + if (ic.Response != ImapCommandResponse.Ok) + throw CreateAuthenticationException (ic); engine.State = ImapEngineState.Authenticated; - cred = credentials.GetCredential (uri, sasl.MechanismName); id = GetSessionIdentifier (cred.UserName); if (id != identifier) { engine.FolderCache.Clear (); @@ -447,46 +492,15 @@ public override async Task AuthenticateAsync (Encoding encoding, ICredentials cr } // Query the CAPABILITIES again if the server did not include an - // untagged CAPABILITIES response to the AUTHENTICATE command. + // untagged CAPABILITIES response to the LOGIN command. if (engine.CapabilitiesVersion == capabilitiesVersion) await engine.QueryCapabilitiesAsync (cancellationToken).ConfigureAwait (false); await OnAuthenticatedAsync (ic.ResponseText ?? string.Empty, cancellationToken).ConfigureAwait (false); - return; - } - - CheckCanLogin (ic); - - // fall back to the classic LOGIN command... - cred = credentials.GetCredential (uri, "DEFAULT"); - - ic = engine.QueueCommand (cancellationToken, null, "LOGIN %S %S\r\n", cred.UserName, cred.Password); - - detector.IsAuthenticating = true; - - try { - await engine.RunAsync (ic).ConfigureAwait (false); - } finally { - detector.IsAuthenticating = false; - } - - if (ic.Response != ImapCommandResponse.Ok) - throw CreateAuthenticationException (ic); - - engine.State = ImapEngineState.Authenticated; - - id = GetSessionIdentifier (cred.UserName); - if (id != identifier) { - engine.FolderCache.Clear (); - identifier = id; + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - // Query the CAPABILITIES again if the server did not include an - // untagged CAPABILITIES response to the LOGIN command. - if (engine.CapabilitiesVersion == capabilitiesVersion) - await engine.QueryCapabilitiesAsync (cancellationToken).ConfigureAwait (false); - - await OnAuthenticatedAsync (ic.ResponseText ?? string.Empty, cancellationToken).ConfigureAwait (false); } async Task SslHandshakeAsync (SslStream ssl, string host, CancellationToken cancellationToken) @@ -551,9 +565,9 @@ async Task PostConnectAsync (Stream stream, string host, int port, SecureSocketO throw ImapCommandException.Create ("STARTTLS", ic); } } - } catch { + } catch (Exception ex) { secure = false; - engine.Disconnect (); + engine.Disconnect (ex); throw; } finally { connecting = false; @@ -639,30 +653,37 @@ public override async Task ConnectAsync (string host, int port = 0, SecureSocket ComputeDefaultValues (host, ref port, ref options, out var uri, out var starttls); - var stream = await ConnectNetworkAsync (host, port, cancellationToken).ConfigureAwait (false); - stream.WriteTimeout = timeout; - stream.ReadTimeout = timeout; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Connect); - engine.Uri = uri; + try { + var stream = await ConnectNetworkAsync (host, port, cancellationToken).ConfigureAwait (false); + stream.WriteTimeout = timeout; + stream.ReadTimeout = timeout; - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + engine.Uri = uri; - try { - await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); - } catch (Exception ex) { - ssl.Dispose (); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + + try { + await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "IMAP", host, port, 993, 143); + } - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "IMAP", host, port, 993, 143); + secure = true; + stream = ssl; + } else { + secure = false; } - secure = true; - stream = ssl; - } else { - secure = false; + await PostConnectAsync (stream, host, port, options, starttls, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - await PostConnectAsync (stream, host, port, options, starttls, cancellationToken).ConfigureAwait (false); } /// @@ -804,36 +825,43 @@ public override async Task ConnectAsync (Stream stream, string host, int port = { CheckCanConnect (stream, host, port); - Stream network; - ComputeDefaultValues (host, ref port, ref options, out var uri, out var starttls); - engine.Uri = uri; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Connect); - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + try { + Stream network; - try { - await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); - } catch (Exception ex) { - ssl.Dispose (); + engine.Uri = uri; + + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "IMAP", host, port, 993, 143); + try { + await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "IMAP", host, port, 993, 143); + } + + network = ssl; + secure = true; + } else { + network = stream; + secure = false; } - network = ssl; - secure = true; - } else { - network = stream; - secure = false; - } + if (network.CanTimeout) { + network.WriteTimeout = timeout; + network.ReadTimeout = timeout; + } - if (network.CanTimeout) { - network.WriteTimeout = timeout; - network.ReadTimeout = timeout; + await PostConnectAsync (network, host, port, options, starttls, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - await PostConnectAsync (network, host, port, options, starttls, cancellationToken).ConfigureAwait (false); } /// @@ -871,7 +899,7 @@ public override async Task DisconnectAsync (bool quit, CancellationToken cancell disconnecting = true; - engine.Disconnect (); + engine.Disconnect (null); } /// diff --git a/MailKit/Net/Imap/ImapClient.cs b/MailKit/Net/Imap/ImapClient.cs index 1eb8f730ad..e4b72db4a2 100644 --- a/MailKit/Net/Imap/ImapClient.cs +++ b/MailKit/Net/Imap/ImapClient.cs @@ -1137,22 +1137,29 @@ public override void Authenticate (SaslMechanism mechanism, CancellationToken ca return Task.CompletedTask; }; - detector.IsAuthenticating = true; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Authenticate); try { - engine.Run (ic); - } finally { - detector.IsAuthenticating = false; - } + detector.IsAuthenticating = true; - ProcessAuthenticateResponse (ic, mechanism); + try { + engine.Run (ic); + } finally { + detector.IsAuthenticating = false; + } - // Query the CAPABILITIES again if the server did not include an - // untagged CAPABILITIES response to the AUTHENTICATE command. - if (engine.CapabilitiesVersion == capabilitiesVersion) - engine.QueryCapabilities (cancellationToken); + ProcessAuthenticateResponse (ic, mechanism); - OnAuthenticated (ic.ResponseText ?? string.Empty, cancellationToken); + // Query the CAPABILITIES again if the server did not include an + // untagged CAPABILITIES response to the AUTHENTICATE command. + if (engine.CapabilitiesVersion == capabilitiesVersion) + engine.QueryCapabilities (cancellationToken); + + OnAuthenticated (ic.ResponseText ?? string.Empty, cancellationToken); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } void CheckCanAuthenticate (Encoding encoding, ICredentials credentials) @@ -1235,44 +1242,87 @@ public override void Authenticate (Encoding encoding, ICredentials credentials, { CheckCanAuthenticate (encoding, credentials); - int capabilitiesVersion = engine.CapabilitiesVersion; - var uri = new Uri ("imap://" + engine.Uri.Host); - NetworkCredential cred; - ImapCommand ic = null; - SaslMechanism sasl; - string id; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Authenticate); - foreach (var authmech in SaslMechanism.Rank (engine.AuthenticationMechanisms)) { - cred = credentials.GetCredential (uri, authmech); + try { + int capabilitiesVersion = engine.CapabilitiesVersion; + var uri = new Uri ("imap://" + engine.Uri.Host); + NetworkCredential cred; + ImapCommand ic = null; + SaslMechanism sasl; + string id; - if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) - continue; + foreach (var authmech in SaslMechanism.Rank (engine.AuthenticationMechanisms)) { + cred = credentials.GetCredential (uri, authmech); - ConfigureSaslMechanism (sasl, uri); + if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) + continue; - cancellationToken.ThrowIfCancellationRequested (); + ConfigureSaslMechanism (sasl, uri); - var command = string.Format ("AUTHENTICATE {0}", sasl.MechanismName); + cancellationToken.ThrowIfCancellationRequested (); - if ((engine.Capabilities & ImapCapabilities.SaslIR) != 0 && sasl.SupportsInitialResponse) { - string ir = sasl.Challenge (null, cancellationToken); + var command = string.Format ("AUTHENTICATE {0}", sasl.MechanismName); - command += " " + ir + "\r\n"; - } else { - command += "\r\n"; - } + if ((engine.Capabilities & ImapCapabilities.SaslIR) != 0 && sasl.SupportsInitialResponse) { + string ir = sasl.Challenge (null, cancellationToken); + + command += " " + ir + "\r\n"; + } else { + command += "\r\n"; + } + + ic = engine.QueueCommand (cancellationToken, null, command); + ic.ContinuationHandler = (imap, cmd, text, xdoAsync) => { + string challenge = sasl.Challenge (text, cmd.CancellationToken); + + var buf = Encoding.ASCII.GetBytes (challenge + "\r\n"); - ic = engine.QueueCommand (cancellationToken, null, command); - ic.ContinuationHandler = (imap, cmd, text, xdoAsync) => { - string challenge = sasl.Challenge (text, cmd.CancellationToken); + imap.Stream.Write (buf, 0, buf.Length, cmd.CancellationToken); + imap.Stream.Flush (cmd.CancellationToken); - var buf = Encoding.ASCII.GetBytes (challenge + "\r\n"); + return Task.CompletedTask; + }; - imap.Stream.Write (buf, 0, buf.Length, cmd.CancellationToken); - imap.Stream.Flush (cmd.CancellationToken); + detector.IsAuthenticating = true; - return Task.CompletedTask; - }; + try { + engine.Run (ic); + } finally { + detector.IsAuthenticating = false; + } + + if (ic.Response != ImapCommandResponse.Ok) { + EmitAndThrowOnAlert (ic); + if (ic.Bye) + throw new ImapProtocolException (ic.ResponseText); + continue; + } + + engine.State = ImapEngineState.Authenticated; + + cred = credentials.GetCredential (uri, sasl.MechanismName); + id = GetSessionIdentifier (cred.UserName); + if (id != identifier) { + engine.FolderCache.Clear (); + identifier = id; + } + + // Query the CAPABILITIES again if the server did not include an + // untagged CAPABILITIES response to the AUTHENTICATE command. + if (engine.CapabilitiesVersion == capabilitiesVersion) + engine.QueryCapabilities (cancellationToken); + + OnAuthenticated (ic.ResponseText ?? string.Empty, cancellationToken); + return; + } + + CheckCanLogin (ic); + + // fall back to the classic LOGIN command... + cred = credentials.GetCredential (uri, "DEFAULT"); + + ic = engine.QueueCommand (cancellationToken, null, "LOGIN %S %S\r\n", cred.UserName, cred.Password); detector.IsAuthenticating = true; @@ -1282,16 +1332,11 @@ public override void Authenticate (Encoding encoding, ICredentials credentials, detector.IsAuthenticating = false; } - if (ic.Response != ImapCommandResponse.Ok) { - EmitAndThrowOnAlert (ic); - if (ic.Bye) - throw new ImapProtocolException (ic.ResponseText); - continue; - } + if (ic.Response != ImapCommandResponse.Ok) + throw CreateAuthenticationException (ic); engine.State = ImapEngineState.Authenticated; - cred = credentials.GetCredential (uri, sasl.MechanismName); id = GetSessionIdentifier (cred.UserName); if (id != identifier) { engine.FolderCache.Clear (); @@ -1299,46 +1344,15 @@ public override void Authenticate (Encoding encoding, ICredentials credentials, } // Query the CAPABILITIES again if the server did not include an - // untagged CAPABILITIES response to the AUTHENTICATE command. + // untagged CAPABILITIES response to the LOGIN command. if (engine.CapabilitiesVersion == capabilitiesVersion) engine.QueryCapabilities (cancellationToken); OnAuthenticated (ic.ResponseText ?? string.Empty, cancellationToken); - return; - } - - CheckCanLogin (ic); - - // fall back to the classic LOGIN command... - cred = credentials.GetCredential (uri, "DEFAULT"); - - ic = engine.QueueCommand (cancellationToken, null, "LOGIN %S %S\r\n", cred.UserName, cred.Password); - - detector.IsAuthenticating = true; - - try { - engine.Run (ic); - } finally { - detector.IsAuthenticating = false; - } - - if (ic.Response != ImapCommandResponse.Ok) - throw CreateAuthenticationException (ic); - - engine.State = ImapEngineState.Authenticated; - - id = GetSessionIdentifier (cred.UserName); - if (id != identifier) { - engine.FolderCache.Clear (); - identifier = id; + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - // Query the CAPABILITIES again if the server did not include an - // untagged CAPABILITIES response to the LOGIN command. - if (engine.CapabilitiesVersion == capabilitiesVersion) - engine.QueryCapabilities (cancellationToken); - - OnAuthenticated (ic.ResponseText ?? string.Empty, cancellationToken); } internal static void ComputeDefaultValues (string host, ref int port, ref SecureSocketOptions options, out Uri uri, out bool starttls) @@ -1463,9 +1477,9 @@ void PostConnect (Stream stream, string host, int port, SecureSocketOptions opti throw ImapCommandException.Create ("STARTTLS", ic); } } - } catch { + } catch (Exception ex) { secure = false; - engine.Disconnect (); + engine.Disconnect (ex); throw; } finally { connecting = false; @@ -1547,30 +1561,37 @@ public override void Connect (string host, int port = 0, SecureSocketOptions opt ComputeDefaultValues (host, ref port, ref options, out var uri, out var starttls); - var stream = ConnectNetwork (host, port, cancellationToken); - stream.WriteTimeout = timeout; - stream.ReadTimeout = timeout; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Connect); - engine.Uri = uri; + try { + var stream = ConnectNetwork (host, port, cancellationToken); + stream.WriteTimeout = timeout; + stream.ReadTimeout = timeout; - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + engine.Uri = uri; - try { - SslHandshake (ssl, host, cancellationToken); - } catch (Exception ex) { - ssl.Dispose (); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "IMAP", host, port, 993, 143); + try { + SslHandshake (ssl, host, cancellationToken); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "IMAP", host, port, 993, 143); + } + + secure = true; + stream = ssl; + } else { + secure = false; } - secure = true; - stream = ssl; - } else { - secure = false; + PostConnect (stream, host, port, options, starttls, cancellationToken); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - PostConnect (stream, host, port, options, starttls, cancellationToken); } void CheckCanConnect (Stream stream, string host, int port) @@ -1723,36 +1744,43 @@ public override void Connect (Stream stream, string host, int port = 0, SecureSo { CheckCanConnect (stream, host, port); - Stream network; - ComputeDefaultValues (host, ref port, ref options, out var uri, out var starttls); - engine.Uri = uri; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Connect); - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + try { + Stream network; - try { - SslHandshake (ssl, host, cancellationToken); - } catch (Exception ex) { - ssl.Dispose (); + engine.Uri = uri; - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "IMAP", host, port, 993, 143); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + + try { + SslHandshake (ssl, host, cancellationToken); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "IMAP", host, port, 993, 143); + } + + network = ssl; + secure = true; + } else { + network = stream; + secure = false; } - network = ssl; - secure = true; - } else { - network = stream; - secure = false; - } + if (network.CanTimeout) { + network.WriteTimeout = timeout; + network.ReadTimeout = timeout; + } - if (network.CanTimeout) { - network.WriteTimeout = timeout; - network.ReadTimeout = timeout; + PostConnect (network, host, port, options, starttls, cancellationToken); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - PostConnect (network, host, port, options, starttls, cancellationToken); } /// @@ -1789,7 +1817,7 @@ public override void Disconnect (bool quit, CancellationToken cancellationToken disconnecting = true; - engine.Disconnect (); + engine.Disconnect (null); } ImapCommand QueueNoOpCommand (CancellationToken cancellationToken) diff --git a/MailKit/Net/Imap/ImapEngine.cs b/MailKit/Net/Imap/ImapEngine.cs index 9739998e36..df8b9cf746 100644 --- a/MailKit/Net/Imap/ImapEngine.cs +++ b/MailKit/Net/Imap/ImapEngine.cs @@ -145,11 +145,16 @@ class ImapEngine : IDisposable static int TagPrefixIndex; +#if NET6_0_OR_GREATER + readonly ClientMetrics metrics; +#endif + internal readonly Dictionary FolderCache; readonly CreateImapFolderDelegate createImapFolder; readonly ImapFolderNameComparer cacheComparer; internal ImapQuirksMode QuirksMode; readonly List queue; + long clientConnectedTimestamp; internal char TagPrefix; ImapCommand current; MimeParser parser; @@ -158,6 +163,11 @@ class ImapEngine : IDisposable public ImapEngine (CreateImapFolderDelegate createImapFolderDelegate) { +#if NET6_0_OR_GREATER + // Use the globally configured Pop3Client metrics. + metrics = Telemetry.ImapClient.Metrics; +#endif + cacheComparer = new ImapFolderNameComparer ('.'); FolderCache = new Dictionary (cacheComparer); @@ -609,8 +619,18 @@ internal void SetStream (ImapStream stream) Stream = stream; } + public NetworkOperation StartNetworkOperation (NetworkOperationKind kind) + { +#if NET6_0_OR_GREATER + return NetworkOperation.Start (kind, Uri, Telemetry.ImapClient.ActivitySource, metrics); +#else + return NetworkOperation.Start (kind, Uri); +#endif + } + void Initialize (ImapStream stream) { + clientConnectedTimestamp = Stopwatch.GetTimestamp (); ProtocolVersion = ImapProtocolVersion.Unknown; Capabilities = ImapCapabilities.None; AuthenticationMechanisms.Clear (); @@ -736,8 +756,8 @@ public void Connect (ImapStream stream, CancellationToken cancellationToken) DetectQuirksMode (text); State = state; - } catch { - Disconnect (); + } catch (Exception ex) { + Disconnect (ex); throw; } } @@ -797,20 +817,31 @@ public async Task ConnectAsync (ImapStream stream, CancellationToken cancellatio DetectQuirksMode (text); State = state; - } catch { - Disconnect (); + } catch (Exception ex) { + Disconnect (ex); throw; } } + void RecordClientDisconnected (Exception ex) + { +#if NET6_0_OR_GREATER + metrics?.RecordClientDisconnected (clientConnectedTimestamp, Uri, ex); +#endif + clientConnectedTimestamp = 0; + } + /// /// Disconnects the . /// /// /// Disconnects the . /// - public void Disconnect () + /// The exception that is causing the disconnection. + public void Disconnect (Exception ex) { + RecordClientDisconnected (ex); + if (Selected != null) { Selected.Reset (); Selected.OnClosed (); @@ -3031,11 +3062,11 @@ void PopNextCommand () } } - void OnImapProtocolException () + void OnImapProtocolException (ImapProtocolException ex) { var ic = current; - Disconnect (); + Disconnect (ex); if (ic.Bye) { if (ic.RespCodes.Count > 0) { @@ -3069,11 +3100,11 @@ void Iterate () if (current.Bye && !current.Logout) throw new ImapProtocolException ("Bye."); - } catch (ImapProtocolException) { - OnImapProtocolException (); + } catch (ImapProtocolException ex) { + OnImapProtocolException (ex); throw; - } catch { - Disconnect (); + } catch (Exception ex) { + Disconnect (ex); throw; } finally { current = null; @@ -3096,11 +3127,11 @@ async Task IterateAsync () if (current.Bye && !current.Logout) throw new ImapProtocolException ("Bye."); - } catch (ImapProtocolException) { - OnImapProtocolException (); + } catch (ImapProtocolException ex) { + OnImapProtocolException (ex); throw; - } catch { - Disconnect (); + } catch (Exception ex) { + Disconnect (ex); throw; } finally { current = null; @@ -4187,7 +4218,7 @@ void OnDisconnected () public void Dispose () { disposed = true; - Disconnect (); + Disconnect (null); } } } diff --git a/MailKit/Net/NetworkOperation.cs b/MailKit/Net/NetworkOperation.cs new file mode 100644 index 0000000000..cfe33ffe49 --- /dev/null +++ b/MailKit/Net/NetworkOperation.cs @@ -0,0 +1,141 @@ +// +// NetworkOperation.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2024 .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System; +using System.Diagnostics; + +namespace MailKit.Net { + enum NetworkOperationKind + { + Authenticate, + Connect, + Send + } + + class NetworkOperation : IDisposable + { +#if NET6_0_OR_GREATER + static readonly string[] ActivityNames = { + "Authenticate", + "Connect", + "Send" + }; + static readonly string[] OperationValues = { + "authenticate", + "connect", + "send" + }; + + readonly NetworkOperationKind kind; + readonly ClientMetrics metrics; + readonly Activity activity; + readonly long startTimestamp; + readonly Uri uri; + Exception ex; + + NetworkOperation (NetworkOperationKind kind, Uri uri, Activity activity, ClientMetrics metrics) + { + this.kind = kind; + this.uri = uri; + this.activity = activity; + this.metrics = metrics; + + if (activity is not null) { + activity.AddTag ("url.scheme", uri.Scheme); + activity.AddTag ("server.address", uri.Host); + activity.AddTag ("server.port", uri.Port); + } + + startTimestamp = Stopwatch.GetTimestamp (); + } +#else + Exception ex; + + NetworkOperation () + { + } +#endif + + public void SetError (Exception ex) + { + this.ex = ex; + } + +#if NET6_0_OR_GREATER + // TagList is a huge struct, so we avoid storing it in a field to reduce the amount we allocate on the heap. + TagList GetTags () + { + var tags = ClientMetrics.GetTags (uri, ex); + + tags.Add ("network.operation", OperationValues[(int) kind]); + + return tags; + } +#endif + + public void Dispose () + { +#if NET6_0_OR_GREATER + if (metrics is not null && (metrics.OperationCounter.Enabled || metrics.OperationDuration.Enabled)) { + var tags = GetTags (); + + if (metrics.OperationDuration.Enabled) { + var duration = TimeSpan.FromTicks (Stopwatch.GetTimestamp () - startTimestamp).TotalMilliseconds; + + metrics.OperationDuration.Record (duration, tags); + } + + if (metrics.OperationCounter.Enabled) + metrics.OperationCounter.Add (1, tags); + } + + if (activity is not null) { + if (ex is not null) + activity.SetStatus (ActivityStatusCode.Error); + else + activity.SetStatus (ActivityStatusCode.Ok); + + activity.Dispose (); + } +#endif + } + +#if NET6_0_OR_GREATER + public static NetworkOperation Start (NetworkOperationKind kind, Uri uri, ActivitySource source, ClientMetrics metrics) + { + + var activity = source?.StartActivity (ActivityNames[(int) kind], ActivityKind.Client); + + return new NetworkOperation (kind, uri, activity, metrics); + } +#else + public static NetworkOperation Start (NetworkOperationKind kind, Uri uri) + { + return new NetworkOperation (); + } +#endif + } +} diff --git a/MailKit/Net/Pop3/AsyncPop3Client.cs b/MailKit/Net/Pop3/AsyncPop3Client.cs index c7d04f1b46..5cb34d2226 100644 --- a/MailKit/Net/Pop3/AsyncPop3Client.cs +++ b/MailKit/Net/Pop3/AsyncPop3Client.cs @@ -141,17 +141,24 @@ public override async Task AuthenticateAsync (SaslMechanism mechanism, Cancellat { CheckCanAuthenticate (mechanism, cancellationToken); - var saslUri = new Uri ("pop://" + engine.Uri.Host); - var ctx = GetSaslAuthContext (mechanism, saslUri); + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Authenticate); - var pc = await ctx.AuthenticateAsync (cancellationToken).ConfigureAwait (false); + try { + var saslUri = new Uri ("pop://" + engine.Uri.Host); + var ctx = GetSaslAuthContext (mechanism, saslUri); + + var pc = await ctx.AuthenticateAsync (cancellationToken).ConfigureAwait (false); - if (pc.Status == Pop3CommandStatus.Error) - throw new AuthenticationException (); + if (pc.Status == Pop3CommandStatus.Error) + throw new AuthenticationException (); - pc.ThrowIfError (); + pc.ThrowIfError (); - await OnAuthenticatedAsync (ctx.AuthMessage, cancellationToken).ConfigureAwait (false); + await OnAuthenticatedAsync (ctx.AuthMessage, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } /// @@ -216,70 +223,77 @@ public override async Task AuthenticateAsync (Encoding encoding, ICredentials cr { CheckCanAuthenticate (encoding, credentials, cancellationToken); - var saslUri = new Uri ("pop://" + engine.Uri.Host); - string userName, password, message = null; - NetworkCredential cred; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Authenticate); - if ((engine.Capabilities & Pop3Capabilities.Apop) != 0) { - var apop = GetApopCommand (encoding, credentials, saslUri); + try { + var saslUri = new Uri ("pop://" + engine.Uri.Host); + string userName, password, message = null; + NetworkCredential cred; - detector.IsAuthenticating = true; + if ((engine.Capabilities & Pop3Capabilities.Apop) != 0) { + var apop = GetApopCommand (encoding, credentials, saslUri); - try { - message = await SendCommandAsync (cancellationToken, encoding, apop).ConfigureAwait (false); - engine.State = Pop3EngineState.Transaction; - } catch (Pop3CommandException) { - } finally { - detector.IsAuthenticating = false; - } + detector.IsAuthenticating = true; + + try { + message = await SendCommandAsync (cancellationToken, encoding, apop).ConfigureAwait (false); + engine.State = Pop3EngineState.Transaction; + } catch (Pop3CommandException) { + } finally { + detector.IsAuthenticating = false; + } - if (engine.State == Pop3EngineState.Transaction) { - await OnAuthenticatedAsync (message ?? string.Empty, cancellationToken).ConfigureAwait (false); - return; + if (engine.State == Pop3EngineState.Transaction) { + await OnAuthenticatedAsync (message ?? string.Empty, cancellationToken).ConfigureAwait (false); + return; + } } - } - if ((engine.Capabilities & Pop3Capabilities.Sasl) != 0) { - foreach (var authmech in SaslMechanism.Rank (engine.AuthenticationMechanisms)) { - SaslMechanism sasl; + if ((engine.Capabilities & Pop3Capabilities.Sasl) != 0) { + foreach (var authmech in SaslMechanism.Rank (engine.AuthenticationMechanisms)) { + SaslMechanism sasl; - cred = credentials.GetCredential (saslUri, authmech); + cred = credentials.GetCredential (saslUri, authmech); - if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) - continue; + if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) + continue; - cancellationToken.ThrowIfCancellationRequested (); + cancellationToken.ThrowIfCancellationRequested (); - var ctx = GetSaslAuthContext (sasl, saslUri); + var ctx = GetSaslAuthContext (sasl, saslUri); - var pc = await ctx.AuthenticateAsync (cancellationToken).ConfigureAwait (false); + var pc = await ctx.AuthenticateAsync (cancellationToken).ConfigureAwait (false); - if (pc.Status == Pop3CommandStatus.Error) - continue; + if (pc.Status == Pop3CommandStatus.Error) + continue; - pc.ThrowIfError (); + pc.ThrowIfError (); - await OnAuthenticatedAsync (ctx.AuthMessage, cancellationToken).ConfigureAwait (false); - return; + await OnAuthenticatedAsync (ctx.AuthMessage, cancellationToken).ConfigureAwait (false); + return; + } } - } - // fall back to the classic USER & PASS commands... - cred = credentials.GetCredential (saslUri, "DEFAULT"); - userName = utf8 ? SaslMechanism.SaslPrep (cred.UserName) : cred.UserName; - password = utf8 ? SaslMechanism.SaslPrep (cred.Password) : cred.Password; - detector.IsAuthenticating = true; + // fall back to the classic USER & PASS commands... + cred = credentials.GetCredential (saslUri, "DEFAULT"); + userName = utf8 ? SaslMechanism.SaslPrep (cred.UserName) : cred.UserName; + password = utf8 ? SaslMechanism.SaslPrep (cred.Password) : cred.Password; + detector.IsAuthenticating = true; - try { - await SendCommandAsync (cancellationToken, encoding, "USER {0}\r\n", userName).ConfigureAwait (false); - message = await SendCommandAsync (cancellationToken, encoding, "PASS {0}\r\n", password).ConfigureAwait (false); - } catch (Pop3CommandException) { - throw new AuthenticationException (); - } finally { - detector.IsAuthenticating = false; - } + try { + await SendCommandAsync (cancellationToken, encoding, "USER {0}\r\n", userName).ConfigureAwait (false); + message = await SendCommandAsync (cancellationToken, encoding, "PASS {0}\r\n", password).ConfigureAwait (false); + } catch (Pop3CommandException) { + throw new AuthenticationException (); + } finally { + detector.IsAuthenticating = false; + } - await OnAuthenticatedAsync (message, cancellationToken).ConfigureAwait (false); + await OnAuthenticatedAsync (message, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } async Task SslHandshakeAsync (SslStream ssl, string host, CancellationToken cancellationToken) @@ -330,8 +344,8 @@ async Task PostConnectAsync (Stream stream, string host, int port, SecureSocketO // re-issue a CAPA command await engine.QueryCapabilitiesAsync (cancellationToken).ConfigureAwait (false); } - } catch { - engine.Disconnect (); + } catch (Exception ex) { + engine.Disconnect (ex); secure = false; throw; } @@ -411,30 +425,37 @@ public override async Task ConnectAsync (string host, int port = 0, SecureSocket ComputeDefaultValues (host, ref port, ref options, out var uri, out var starttls); - var stream = await ConnectNetworkAsync (host, port, cancellationToken).ConfigureAwait (false); - stream.WriteTimeout = timeout; - stream.ReadTimeout = timeout; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Connect); + + try { + var stream = await ConnectNetworkAsync (host, port, cancellationToken).ConfigureAwait (false); + stream.WriteTimeout = timeout; + stream.ReadTimeout = timeout; - engine.Uri = uri; + engine.Uri = uri; - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); - try { - await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); - } catch (Exception ex) { - ssl.Dispose (); + try { + await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "POP3", host, port, 995, 110); + } - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "POP3", host, port, 995, 110); + secure = true; + stream = ssl; + } else { + secure = false; } - secure = true; - stream = ssl; - } else { - secure = false; + await PostConnectAsync (stream, host, port, options, starttls, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - await PostConnectAsync (stream, host, port, options, starttls, cancellationToken).ConfigureAwait (false); } /// @@ -580,36 +601,39 @@ public override async Task ConnectAsync (Stream stream, string host, int port = ComputeDefaultValues (host, ref port, ref options, out var uri, out var starttls); - engine.Uri = uri; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Connect); - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + try { + engine.Uri = uri; - try { -#if NET5_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - await ssl.AuthenticateAsClientAsync (GetSslClientAuthenticationOptions (host, ValidateRemoteCertificate), cancellationToken).ConfigureAwait (false); -#else - await ssl.AuthenticateAsClientAsync (host, ClientCertificates, SslProtocols, CheckCertificateRevocation).ConfigureAwait (false); -#endif - } catch (Exception ex) { - ssl.Dispose (); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + + try { + await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "POP3", host, port, 995, 110); + } - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "POP3", host, port, 995, 110); + network = ssl; + secure = true; + } else { + network = stream; + secure = false; } - network = ssl; - secure = true; - } else { - network = stream; - secure = false; - } + if (network.CanTimeout) { + network.WriteTimeout = timeout; + network.ReadTimeout = timeout; + } - if (network.CanTimeout) { - network.WriteTimeout = timeout; - network.ReadTimeout = timeout; + await PostConnectAsync (network, host, port, options, starttls, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - await PostConnectAsync (network, host, port, options, starttls, cancellationToken).ConfigureAwait (false); } /// @@ -645,7 +669,7 @@ public override async Task DisconnectAsync (bool quit, CancellationToken cancell } disconnecting = true; - engine.Disconnect (); + engine.Disconnect (null); } /// diff --git a/MailKit/Net/Pop3/Pop3Client.cs b/MailKit/Net/Pop3/Pop3Client.cs index 61f3c5d9fb..b38a207d74 100644 --- a/MailKit/Net/Pop3/Pop3Client.cs +++ b/MailKit/Net/Pop3/Pop3Client.cs @@ -822,17 +822,24 @@ public override void Authenticate (SaslMechanism mechanism, CancellationToken ca { CheckCanAuthenticate (mechanism, cancellationToken); - var saslUri = new Uri ("pop://" + engine.Uri.Host); - var ctx = GetSaslAuthContext (mechanism, saslUri); + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Authenticate); - var pc = ctx.Authenticate (cancellationToken); + try { + var saslUri = new Uri ("pop://" + engine.Uri.Host); + var ctx = GetSaslAuthContext (mechanism, saslUri); - if (pc.Status == Pop3CommandStatus.Error) - throw new AuthenticationException (); + var pc = ctx.Authenticate (cancellationToken); - pc.ThrowIfError (); + if (pc.Status == Pop3CommandStatus.Error) + throw new AuthenticationException (); - OnAuthenticated (ctx.AuthMessage, cancellationToken); + pc.ThrowIfError (); + + OnAuthenticated (ctx.AuthMessage, cancellationToken); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } void CheckCanAuthenticate (Encoding encoding, ICredentials credentials, CancellationToken cancellationToken) @@ -931,70 +938,77 @@ public override void Authenticate (Encoding encoding, ICredentials credentials, { CheckCanAuthenticate (encoding, credentials, cancellationToken); - var saslUri = new Uri ("pop://" + engine.Uri.Host); - string userName, password, message = null; - NetworkCredential cred; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Authenticate); - if ((engine.Capabilities & Pop3Capabilities.Apop) != 0) { - var apop = GetApopCommand (encoding, credentials, saslUri); + try { + var saslUri = new Uri ("pop://" + engine.Uri.Host); + string userName, password, message = null; + NetworkCredential cred; - detector.IsAuthenticating = true; + if ((engine.Capabilities & Pop3Capabilities.Apop) != 0) { + var apop = GetApopCommand (encoding, credentials, saslUri); - try { - message = SendCommand (cancellationToken, encoding, apop); - engine.State = Pop3EngineState.Transaction; - } catch (Pop3CommandException) { - } finally { - detector.IsAuthenticating = false; - } + detector.IsAuthenticating = true; - if (engine.State == Pop3EngineState.Transaction) { - OnAuthenticated (message ?? string.Empty, cancellationToken); - return; + try { + message = SendCommand (cancellationToken, encoding, apop); + engine.State = Pop3EngineState.Transaction; + } catch (Pop3CommandException) { + } finally { + detector.IsAuthenticating = false; + } + + if (engine.State == Pop3EngineState.Transaction) { + OnAuthenticated (message ?? string.Empty, cancellationToken); + return; + } } - } - if ((engine.Capabilities & Pop3Capabilities.Sasl) != 0) { - foreach (var authmech in SaslMechanism.Rank (engine.AuthenticationMechanisms)) { - SaslMechanism sasl; + if ((engine.Capabilities & Pop3Capabilities.Sasl) != 0) { + foreach (var authmech in SaslMechanism.Rank (engine.AuthenticationMechanisms)) { + SaslMechanism sasl; - cred = credentials.GetCredential (saslUri, authmech); + cred = credentials.GetCredential (saslUri, authmech); - if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) - continue; + if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) + continue; - cancellationToken.ThrowIfCancellationRequested (); + cancellationToken.ThrowIfCancellationRequested (); - var ctx = GetSaslAuthContext (sasl, saslUri); + var ctx = GetSaslAuthContext (sasl, saslUri); - var pc = ctx.Authenticate (cancellationToken); + var pc = ctx.Authenticate (cancellationToken); - if (pc.Status == Pop3CommandStatus.Error) - continue; + if (pc.Status == Pop3CommandStatus.Error) + continue; - pc.ThrowIfError (); + pc.ThrowIfError (); - OnAuthenticated (ctx.AuthMessage, cancellationToken); - return; + OnAuthenticated (ctx.AuthMessage, cancellationToken); + return; + } } - } - // fall back to the classic USER & PASS commands... - cred = credentials.GetCredential (saslUri, "DEFAULT"); - userName = utf8 ? SaslMechanism.SaslPrep (cred.UserName) : cred.UserName; - password = utf8 ? SaslMechanism.SaslPrep (cred.Password) : cred.Password; - detector.IsAuthenticating = true; + // fall back to the classic USER & PASS commands... + cred = credentials.GetCredential (saslUri, "DEFAULT"); + userName = utf8 ? SaslMechanism.SaslPrep (cred.UserName) : cred.UserName; + password = utf8 ? SaslMechanism.SaslPrep (cred.Password) : cred.Password; + detector.IsAuthenticating = true; - try { - SendCommand (cancellationToken, encoding, "USER {0}\r\n", userName); - message = SendCommand (cancellationToken, encoding, "PASS {0}\r\n", password); - } catch (Pop3CommandException) { - throw new AuthenticationException (); - } finally { - detector.IsAuthenticating = false; - } + try { + SendCommand (cancellationToken, encoding, "USER {0}\r\n", userName); + message = SendCommand (cancellationToken, encoding, "PASS {0}\r\n", password); + } catch (Pop3CommandException) { + throw new AuthenticationException (); + } finally { + detector.IsAuthenticating = false; + } - OnAuthenticated (message, cancellationToken); + OnAuthenticated (message, cancellationToken); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } internal static void ComputeDefaultValues (string host, ref int port, ref SecureSocketOptions options, out Uri uri, out bool starttls) @@ -1105,8 +1119,8 @@ void PostConnect (Stream stream, string host, int port, SecureSocketOptions opti // re-issue a CAPA command engine.QueryCapabilities (cancellationToken); } - } catch { - engine.Disconnect (); + } catch (Exception ex) { + engine.Disconnect (ex); secure = false; throw; } @@ -1185,30 +1199,37 @@ public override void Connect (string host, int port = 0, SecureSocketOptions opt ComputeDefaultValues (host, ref port, ref options, out var uri, out var starttls); - var stream = ConnectNetwork (host, port, cancellationToken); - stream.WriteTimeout = timeout; - stream.ReadTimeout = timeout; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Connect); + + try { + var stream = ConnectNetwork (host, port, cancellationToken); + stream.WriteTimeout = timeout; + stream.ReadTimeout = timeout; - engine.Uri = uri; + engine.Uri = uri; - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); - try { - SslHandshake (ssl, host, cancellationToken); - } catch (Exception ex) { - ssl.Dispose (); + try { + SslHandshake (ssl, host, cancellationToken); + } catch (Exception ex) { + ssl.Dispose (); - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "POP3", host, port, 995, 110); + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "POP3", host, port, 995, 110); + } + + secure = true; + stream = ssl; + } else { + secure = false; } - secure = true; - stream = ssl; - } else { - secure = false; + PostConnect (stream, host, port, options, starttls, cancellationToken); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - PostConnect (stream, host, port, options, starttls, cancellationToken); } void CheckCanConnect (Stream stream, string host, int port) @@ -1371,32 +1392,39 @@ public override void Connect (Stream stream, string host, int port = 0, SecureSo ComputeDefaultValues (host, ref port, ref options, out var uri, out var starttls); - engine.Uri = uri; + using var operation = engine.StartNetworkOperation (NetworkOperationKind.Connect); - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + try { + engine.Uri = uri; - try { - SslHandshake (ssl, host, cancellationToken); - } catch (Exception ex) { - ssl.Dispose (); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + + try { + SslHandshake (ssl, host, cancellationToken); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "POP3", host, port, 995, 110); + } - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "POP3", host, port, 995, 110); + network = ssl; + secure = true; + } else { + network = stream; + secure = false; } - network = ssl; - secure = true; - } else { - network = stream; - secure = false; - } + if (network.CanTimeout) { + network.WriteTimeout = timeout; + network.ReadTimeout = timeout; + } - if (network.CanTimeout) { - network.WriteTimeout = timeout; - network.ReadTimeout = timeout; + PostConnect (network, host, port, options, starttls, cancellationToken); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - PostConnect (network, host, port, options, starttls, cancellationToken); } /// @@ -1431,7 +1459,7 @@ public override void Disconnect (bool quit, CancellationToken cancellationToken } disconnecting = true; - engine.Disconnect (); + engine.Disconnect (null); } /// @@ -3343,7 +3371,7 @@ public override IEnumerator GetEnumerator () protected override void Dispose (bool disposing) { if (disposing && !disposed) { - engine.Disconnect (); + engine.Disconnect (null); disposed = true; } diff --git a/MailKit/Net/Pop3/Pop3Engine.cs b/MailKit/Net/Pop3/Pop3Engine.cs index 45d4089ed2..8839246848 100644 --- a/MailKit/Net/Pop3/Pop3Engine.cs +++ b/MailKit/Net/Pop3/Pop3Engine.cs @@ -27,6 +27,7 @@ using System; using System.Text; using System.Threading; +using System.Diagnostics; using System.Globalization; using System.Threading.Tasks; using System.Collections.Generic; @@ -58,7 +59,11 @@ enum Pop3EngineState { /// class Pop3Engine { +#if NET6_0_OR_GREATER + readonly ClientMetrics metrics; +#endif readonly List queue; + long clientConnectedTimestamp; Pop3Stream stream; /// @@ -69,6 +74,11 @@ public Pop3Engine () AuthenticationMechanisms = new HashSet (StringComparer.Ordinal); Capabilities = Pop3Capabilities.User; queue = new List (); + +#if NET6_0_OR_GREATER + // Use the globally configured Pop3Client metrics. + metrics = Telemetry.Pop3Client.Metrics; +#endif } /// @@ -193,6 +203,7 @@ void Initialize (Pop3Stream pop3) { stream?.Dispose (); + clientConnectedTimestamp = Stopwatch.GetTimestamp (); Capabilities = Pop3Capabilities.User; AuthenticationMechanisms.Clear (); State = Pop3EngineState.Disconnected; @@ -240,6 +251,15 @@ void ParseGreeting (string greeting) State = Pop3EngineState.Connected; } + public NetworkOperation StartNetworkOperation (NetworkOperationKind kind) + { +#if NET6_0_OR_GREATER + return NetworkOperation.Start (kind, Uri, Telemetry.Pop3Client.ActivitySource, metrics); +#else + return NetworkOperation.Start (kind, Uri); +#endif + } + /// /// Takes posession of the and reads the greeting. /// @@ -283,14 +303,25 @@ void OnDisconnected () Disconnected?.Invoke (this, EventArgs.Empty); } + void RecordClientDisconnected (Exception ex) + { +#if NET6_0_OR_GREATER + metrics?.RecordClientDisconnected (clientConnectedTimestamp, Uri, ex); +#endif + clientConnectedTimestamp = 0; + } + /// /// Disconnects the . /// /// /// Disconnects the . /// - public void Disconnect () + /// The exception that is causing the disconnection. + public void Disconnect (Exception ex) { + RecordClientDisconnected (ex); + if (stream != null) { stream.Dispose (); stream = null; @@ -404,9 +435,9 @@ void ReadResponse (Pop3Command pc, CancellationToken cancellationToken) try { response = ReadLine (cancellationToken).TrimEnd (); - } catch { + } catch (Exception ex) { pc.Status = Pop3CommandStatus.ProtocolError; - Disconnect (); + Disconnect (ex); throw; } @@ -415,16 +446,17 @@ void ReadResponse (Pop3Command pc, CancellationToken cancellationToken) switch (pc.Status) { case Pop3CommandStatus.ProtocolError: - Disconnect (); - throw new Pop3ProtocolException (string.Format ("Unexpected response from server: {0}", response)); + var pex = new Pop3ProtocolException (string.Format ("Unexpected response from server: {0}", response)); + Disconnect (pex); + throw pex; case Pop3CommandStatus.Continue: case Pop3CommandStatus.Ok: if (pc.Handler != null) { try { pc.Handler (this, pc, text, false, cancellationToken); - } catch { + } catch (Exception ex) { pc.Status = Pop3CommandStatus.ProtocolError; - Disconnect (); + Disconnect (ex); throw; } } @@ -438,9 +470,9 @@ async Task ReadResponseAsync (Pop3Command pc, CancellationToken cancellationToke try { response = (await ReadLineAsync (cancellationToken).ConfigureAwait (false)).TrimEnd (); - } catch { + } catch (Exception ex) { pc.Status = Pop3CommandStatus.ProtocolError; - Disconnect (); + Disconnect (ex); throw; } @@ -449,16 +481,17 @@ async Task ReadResponseAsync (Pop3Command pc, CancellationToken cancellationToke switch (pc.Status) { case Pop3CommandStatus.ProtocolError: - Disconnect (); - throw new Pop3ProtocolException (string.Format ("Unexpected response from server: {0}", response)); + var pex = new Pop3ProtocolException (string.Format ("Unexpected response from server: {0}", response)); + Disconnect (pex); + throw pex; case Pop3CommandStatus.Continue: case Pop3CommandStatus.Ok: if (pc.Handler != null) { try { await pc.Handler (this, pc, text, true, cancellationToken).ConfigureAwait (false); - } catch { + } catch (Exception ex) { pc.Status = Pop3CommandStatus.ProtocolError; - Disconnect (); + Disconnect (ex); throw; } } diff --git a/MailKit/Net/Smtp/AsyncSmtpClient.cs b/MailKit/Net/Smtp/AsyncSmtpClient.cs index f4d1230de7..e7d55ac121 100644 --- a/MailKit/Net/Smtp/AsyncSmtpClient.cs +++ b/MailKit/Net/Smtp/AsyncSmtpClient.cs @@ -30,6 +30,7 @@ using System.Text; using System.Threading; using System.Net.Sockets; +using System.Diagnostics; using System.Globalization; using System.Threading.Tasks; using System.Collections.Generic; @@ -208,57 +209,64 @@ public override async Task AuthenticateAsync (SaslMechanism mechanism, Cancellat cancellationToken.ThrowIfCancellationRequested (); - SaslException saslException = null; - SmtpResponse response; - string challenge; - string command; - - // send an initial challenge if the mechanism supports it - if (mechanism.SupportsInitialResponse) { - challenge = await mechanism.ChallengeAsync (null, cancellationToken).ConfigureAwait (false); - command = string.Format ("AUTH {0} {1}\r\n", mechanism.MechanismName, challenge); - } else { - command = string.Format ("AUTH {0}\r\n", mechanism.MechanismName); - } - - detector.IsAuthenticating = true; + using var operation = StartNetworkOperation (NetworkOperationKind.Authenticate); try { - response = await SendCommandInternalAsync (command, cancellationToken).ConfigureAwait (false); + SaslException saslException = null; + SmtpResponse response; + string challenge; + string command; - if (response.StatusCode == SmtpStatusCode.AuthenticationMechanismTooWeak) - throw new AuthenticationException (response.Response); + // send an initial challenge if the mechanism supports it + if (mechanism.SupportsInitialResponse) { + challenge = await mechanism.ChallengeAsync (null, cancellationToken).ConfigureAwait (false); + command = string.Format ("AUTH {0} {1}\r\n", mechanism.MechanismName, challenge); + } else { + command = string.Format ("AUTH {0}\r\n", mechanism.MechanismName); + } + + detector.IsAuthenticating = true; try { - while (response.StatusCode == SmtpStatusCode.AuthenticationChallenge) { - challenge = await mechanism.ChallengeAsync (response.Response, cancellationToken).ConfigureAwait (false); - response = await SendCommandInternalAsync (challenge + "\r\n", cancellationToken).ConfigureAwait (false); - } + response = await SendCommandInternalAsync (command, cancellationToken).ConfigureAwait (false); - saslException = null; - } catch (SaslException ex) { - // reset the authentication state - response = await SendCommandInternalAsync ("\r\n", cancellationToken).ConfigureAwait (false); - saslException = ex; + if (response.StatusCode == SmtpStatusCode.AuthenticationMechanismTooWeak) + throw new AuthenticationException (response.Response); + + try { + while (response.StatusCode == SmtpStatusCode.AuthenticationChallenge) { + challenge = await mechanism.ChallengeAsync (response.Response, cancellationToken).ConfigureAwait (false); + response = await SendCommandInternalAsync (challenge + "\r\n", cancellationToken).ConfigureAwait (false); + } + + saslException = null; + } catch (SaslException ex) { + // reset the authentication state + response = await SendCommandInternalAsync ("\r\n", cancellationToken).ConfigureAwait (false); + saslException = ex; + } + } finally { + detector.IsAuthenticating = false; } - } finally { - detector.IsAuthenticating = false; - } - if (response.StatusCode == SmtpStatusCode.AuthenticationSuccessful) { - if (mechanism.NegotiatedSecurityLayer) - await EhloAsync (false, cancellationToken).ConfigureAwait (false); - authenticated = true; - OnAuthenticated (response.Response); - return; - } + if (response.StatusCode == SmtpStatusCode.AuthenticationSuccessful) { + if (mechanism.NegotiatedSecurityLayer) + await EhloAsync (false, cancellationToken).ConfigureAwait (false); + authenticated = true; + OnAuthenticated (response.Response); + return; + } - var message = string.Format (CultureInfo.InvariantCulture, "{0}: {1}", (int) response.StatusCode, response.Response); + var message = string.Format (CultureInfo.InvariantCulture, "{0}: {1}", (int) response.StatusCode, response.Response); - if (saslException != null) - throw new AuthenticationException (message, saslException); + if (saslException != null) + throw new AuthenticationException (message, saslException); - throw new AuthenticationException (message); + throw new AuthenticationException (message); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } /// @@ -320,87 +328,94 @@ public override async Task AuthenticateAsync (Encoding encoding, ICredentials cr { ValidateArguments (encoding, credentials); - var saslUri = new Uri ($"smtp://{uri.Host}"); - AuthenticationException authException = null; - SaslException saslException; - SmtpResponse response; - SaslMechanism sasl; - bool tried = false; - string challenge; - string command; + using var operation = StartNetworkOperation (NetworkOperationKind.Authenticate); - foreach (var authmech in SaslMechanism.Rank (AuthenticationMechanisms)) { - var cred = credentials.GetCredential (uri, authmech); + try { + var saslUri = new Uri ($"smtp://{uri.Host}"); + AuthenticationException authException = null; + SaslException saslException; + SmtpResponse response; + SaslMechanism sasl; + bool tried = false; + string challenge; + string command; + + foreach (var authmech in SaslMechanism.Rank (AuthenticationMechanisms)) { + var cred = credentials.GetCredential (uri, authmech); + + if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) + continue; - if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) - continue; + sasl.ChannelBindingContext = Stream.Stream as IChannelBindingContext; + sasl.Uri = saslUri; - sasl.ChannelBindingContext = Stream.Stream as IChannelBindingContext; - sasl.Uri = saslUri; + tried = true; - tried = true; + cancellationToken.ThrowIfCancellationRequested (); - cancellationToken.ThrowIfCancellationRequested (); + // send an initial challenge if the mechanism supports it + if (sasl.SupportsInitialResponse) { + challenge = await sasl.ChallengeAsync (null, cancellationToken).ConfigureAwait (false); + command = string.Format ("AUTH {0} {1}\r\n", authmech, challenge); + } else { + command = string.Format ("AUTH {0}\r\n", authmech); + } - // send an initial challenge if the mechanism supports it - if (sasl.SupportsInitialResponse) { - challenge = await sasl.ChallengeAsync (null, cancellationToken).ConfigureAwait (false); - command = string.Format ("AUTH {0} {1}\r\n", authmech, challenge); - } else { - command = string.Format ("AUTH {0}\r\n", authmech); - } + detector.IsAuthenticating = true; + saslException = null; - detector.IsAuthenticating = true; - saslException = null; + try { + response = await SendCommandInternalAsync (command, cancellationToken).ConfigureAwait (false); - try { - response = await SendCommandInternalAsync (command, cancellationToken).ConfigureAwait (false); + if (response.StatusCode == SmtpStatusCode.AuthenticationMechanismTooWeak) + continue; - if (response.StatusCode == SmtpStatusCode.AuthenticationMechanismTooWeak) - continue; + try { + while (!sasl.IsAuthenticated) { + if (response.StatusCode != SmtpStatusCode.AuthenticationChallenge) + break; - try { - while (!sasl.IsAuthenticated) { - if (response.StatusCode != SmtpStatusCode.AuthenticationChallenge) - break; + challenge = await sasl.ChallengeAsync (response.Response, cancellationToken).ConfigureAwait (false); + response = await SendCommandInternalAsync (challenge + "\r\n", cancellationToken).ConfigureAwait (false); + } - challenge = await sasl.ChallengeAsync (response.Response, cancellationToken).ConfigureAwait (false); - response = await SendCommandInternalAsync (challenge + "\r\n", cancellationToken).ConfigureAwait (false); + saslException = null; + } catch (SaslException ex) { + // reset the authentication state + response = await SendCommandInternalAsync ("\r\n", cancellationToken).ConfigureAwait (false); + saslException = ex; } - - saslException = null; - } catch (SaslException ex) { - // reset the authentication state - response = await SendCommandInternalAsync ("\r\n", cancellationToken).ConfigureAwait (false); - saslException = ex; + } finally { + detector.IsAuthenticating = false; } - } finally { - detector.IsAuthenticating = false; - } - if (response.StatusCode == SmtpStatusCode.AuthenticationSuccessful) { - if (sasl.NegotiatedSecurityLayer) - await EhloAsync (false, cancellationToken).ConfigureAwait (false); - authenticated = true; - OnAuthenticated (response.Response); - return; - } + if (response.StatusCode == SmtpStatusCode.AuthenticationSuccessful) { + if (sasl.NegotiatedSecurityLayer) + await EhloAsync (false, cancellationToken).ConfigureAwait (false); + authenticated = true; + OnAuthenticated (response.Response); + return; + } - var message = string.Format (CultureInfo.InvariantCulture, "{0}: {1}", (int) response.StatusCode, response.Response); - Exception inner; + var message = string.Format (CultureInfo.InvariantCulture, "{0}: {1}", (int) response.StatusCode, response.Response); + Exception inner; - if (saslException != null) - inner = new SmtpCommandException (SmtpErrorCode.UnexpectedStatusCode, response.StatusCode, response.Response, saslException); - else - inner = new SmtpCommandException (SmtpErrorCode.UnexpectedStatusCode, response.StatusCode, response.Response); + if (saslException != null) + inner = new SmtpCommandException (SmtpErrorCode.UnexpectedStatusCode, response.StatusCode, response.Response, saslException); + else + inner = new SmtpCommandException (SmtpErrorCode.UnexpectedStatusCode, response.StatusCode, response.Response); - authException = new AuthenticationException (message, inner); - } + authException = new AuthenticationException (message, inner); + } - if (tried) - throw authException ?? new AuthenticationException (); + if (tried) + throw authException ?? new AuthenticationException (); - throw new NotSupportedException ("No compatible authentication mechanisms found."); + throw new NotSupportedException ("No compatible authentication mechanisms found."); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } async Task SslHandshakeAsync (SslStream ssl, string host, CancellationToken cancellationToken) @@ -414,6 +429,8 @@ async Task SslHandshakeAsync (SslStream ssl, string host, CancellationToken canc async Task PostConnectAsync (Stream stream, string host, int port, SecureSocketOptions options, bool starttls, CancellationToken cancellationToken) { + clientConnectedTimestamp = Stopwatch.GetTimestamp (); + try { ProtocolLogger.LogConnect (uri); } catch { @@ -458,7 +475,8 @@ async Task PostConnectAsync (Stream stream, string host, int port, SecureSocketO } connected = true; - } catch { + } catch (Exception ex) { + RecordClientDisconnected (ex); Stream.Dispose (); secure = false; Stream = null; @@ -550,28 +568,35 @@ public override async Task ConnectAsync (string host, int port = 0, SecureSocket ComputeDefaultValues (host, ref port, ref options, out uri, out var starttls); - var stream = await ConnectNetworkAsync (host, port, cancellationToken).ConfigureAwait (false); - stream.WriteTimeout = timeout; - stream.ReadTimeout = timeout; + using var operation = StartNetworkOperation (NetworkOperationKind.Connect); - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + try { + var stream = await ConnectNetworkAsync (host, port, cancellationToken).ConfigureAwait (false); + stream.WriteTimeout = timeout; + stream.ReadTimeout = timeout; - try { - await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); - } catch (Exception ex) { - ssl.Dispose (); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + + try { + await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "SMTP", host, port, 465, 25, 587); + } - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "SMTP", host, port, 465, 25, 587); + secure = true; + stream = ssl; + } else { + secure = false; } - secure = true; - stream = ssl; - } else { - secure = false; + await PostConnectAsync (stream, host, port, options, starttls, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - await PostConnectAsync (stream, host, port, options, starttls, cancellationToken).ConfigureAwait (false); } /// @@ -717,32 +742,39 @@ public override async Task ConnectAsync (Stream stream, string host, int port = ComputeDefaultValues (host, ref port, ref options, out uri, out var starttls); - Stream network; + using var operation = StartNetworkOperation (NetworkOperationKind.Connect); - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + try { + Stream network; - try { - await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); - } catch (Exception ex) { - ssl.Dispose (); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + + try { + await SslHandshakeAsync (ssl, host, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + ssl.Dispose (); - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "SMTP", host, port, 465, 25, 587); + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "SMTP", host, port, 465, 25, 587); + } + + network = ssl; + secure = true; + } else { + network = stream; + secure = false; } - network = ssl; - secure = true; - } else { - network = stream; - secure = false; - } + if (network.CanTimeout) { + network.WriteTimeout = timeout; + network.ReadTimeout = timeout; + } - if (network.CanTimeout) { - network.WriteTimeout = timeout; - network.ReadTimeout = timeout; + await PostConnectAsync (network, host, port, options, starttls, cancellationToken).ConfigureAwait (false); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - await PostConnectAsync (network, host, port, options, starttls, cancellationToken).ConfigureAwait (false); } /// @@ -952,6 +984,8 @@ async Task SendAsync (FormatOptions options, MimeMessage message, Mailbo size = -1; } + using var operation = StartNetworkOperation (NetworkOperationKind.Send); + try { // Note: if PIPELINING is supported, MailFrom() and RcptTo() will // queue their commands instead of sending them immediately. @@ -988,13 +1022,21 @@ async Task SendAsync (FormatOptions options, MimeMessage message, Mailbo ParseDataResponse (dataResponse); return await MessageDataAsync (format, message, size, cancellationToken, progress).ConfigureAwait (false); - } catch (ServiceNotAuthenticatedException) { + } catch (ServiceNotAuthenticatedException ex) { + operation.SetError (ex); + + // do not disconnect await ResetAsync (cancellationToken).ConfigureAwait (false); throw; - } catch (SmtpCommandException) { + } catch (SmtpCommandException ex) { + operation.SetError (ex); + + // do not disconnect await ResetAsync (cancellationToken).ConfigureAwait (false); throw; - } catch { + } catch (Exception ex) { + operation.SetError (ex); + Disconnect (uri.Host, uri.Port, GetSecureSocketOptions (uri), false); throw; } diff --git a/MailKit/Net/Smtp/SmtpClient.cs b/MailKit/Net/Smtp/SmtpClient.cs index e8ca026e7c..f8c84c9139 100644 --- a/MailKit/Net/Smtp/SmtpClient.cs +++ b/MailKit/Net/Smtp/SmtpClient.cs @@ -31,11 +31,14 @@ using System.Text; using System.Buffers; using System.Threading; +using System.Diagnostics; using System.Net.Sockets; using System.Net.Security; using System.Globalization; -using System.Threading.Tasks; using System.Collections.Generic; +#if NET6_0_OR_GREATER +using System.Diagnostics.Metrics; +#endif using System.Net.NetworkInformation; using System.Security.Authentication; using System.Runtime.CompilerServices; @@ -84,6 +87,10 @@ enum SmtpCommand { readonly SmtpAuthenticationSecretDetector detector = new SmtpAuthenticationSecretDetector (); readonly List queued = new List (); SslCertificateValidationInfo sslValidationInfo; +#if NET6_0_OR_GREATER + readonly ClientMetrics metrics; +#endif + long clientConnectedTimestamp; SmtpCapabilities capabilities; int timeout = 2 * 60 * 1000; bool authenticated; @@ -160,8 +167,46 @@ public SmtpClient () : this (new NullProtocolLogger ()) public SmtpClient (IProtocolLogger protocolLogger) : base (protocolLogger) { protocolLogger.AuthenticationSecretDetector = detector; + +#if NET6_0_OR_GREATER + // Use the globally configured SmtpClient metrics. + metrics = Telemetry.SmtpClient.Metrics; +#endif } +#if NET8_0_OR_GREATER + /// + /// Initializes a new instance of the class. + /// + /// + /// Before you can send messages with the , you must first call one of + /// the Connect methods. + /// Depending on whether the SMTP server requires authenticating or not, you may also need to + /// authenticate using one of the + /// Authenticate methods. + /// + /// The protocol logger. + /// The meter factory. + /// + /// is null. + /// -or- + /// is null. + /// + /// + /// + /// + public SmtpClient (IProtocolLogger protocolLogger, IMeterFactory meterFactory) : base (protocolLogger) + { + if (meterFactory == null) + throw new ArgumentNullException (nameof (meterFactory)); + + protocolLogger.AuthenticationSecretDetector = detector; + + var meter = meterFactory.Create (Telemetry.SmtpClient.MeterName, Telemetry.SmtpClient.MeterVersion); + metrics = Telemetry.SmtpClient.CreateMetrics (meter); + } +#endif + /// /// Get the underlying SMTP stream. /// @@ -549,6 +594,15 @@ public override bool IsAuthenticated { get { return authenticated; } } + NetworkOperation StartNetworkOperation (NetworkOperationKind kind) + { +#if NET6_0_OR_GREATER + return NetworkOperation.Start (kind, uri, Telemetry.SmtpClient.ActivitySource, metrics); +#else + return NetworkOperation.Start (kind, uri); +#endif + } + bool ValidateRemoteCertificate (object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { bool valid; @@ -978,57 +1032,64 @@ public override void Authenticate (SaslMechanism mechanism, CancellationToken ca cancellationToken.ThrowIfCancellationRequested (); - SaslException saslException = null; - SmtpResponse response; - string challenge; - string command; - - // send an initial challenge if the mechanism supports it - if (mechanism.SupportsInitialResponse) { - challenge = mechanism.Challenge (null, cancellationToken); - command = string.Format ("AUTH {0} {1}\r\n", mechanism.MechanismName, challenge); - } else { - command = string.Format ("AUTH {0}\r\n", mechanism.MechanismName); - } - - detector.IsAuthenticating = true; + using var operation = StartNetworkOperation (NetworkOperationKind.Authenticate); try { - response = SendCommandInternal (command, cancellationToken); + SaslException saslException = null; + SmtpResponse response; + string challenge; + string command; + + // send an initial challenge if the mechanism supports it + if (mechanism.SupportsInitialResponse) { + challenge = mechanism.Challenge (null, cancellationToken); + command = string.Format ("AUTH {0} {1}\r\n", mechanism.MechanismName, challenge); + } else { + command = string.Format ("AUTH {0}\r\n", mechanism.MechanismName); + } - if (response.StatusCode == SmtpStatusCode.AuthenticationMechanismTooWeak) - throw new AuthenticationException (response.Response); + detector.IsAuthenticating = true; try { - while (response.StatusCode == SmtpStatusCode.AuthenticationChallenge) { - challenge = mechanism.Challenge (response.Response, cancellationToken); - response = SendCommandInternal (challenge + "\r\n", cancellationToken); - } + response = SendCommandInternal (command, cancellationToken); - saslException = null; - } catch (SaslException ex) { - // reset the authentication state - response = SendCommandInternal ("\r\n", cancellationToken); - saslException = ex; + if (response.StatusCode == SmtpStatusCode.AuthenticationMechanismTooWeak) + throw new AuthenticationException (response.Response); + + try { + while (response.StatusCode == SmtpStatusCode.AuthenticationChallenge) { + challenge = mechanism.Challenge (response.Response, cancellationToken); + response = SendCommandInternal (challenge + "\r\n", cancellationToken); + } + + saslException = null; + } catch (SaslException ex) { + // reset the authentication state + response = SendCommandInternal ("\r\n", cancellationToken); + saslException = ex; + } + } finally { + detector.IsAuthenticating = false; } - } finally { - detector.IsAuthenticating = false; - } - if (response.StatusCode == SmtpStatusCode.AuthenticationSuccessful) { - if (mechanism.NegotiatedSecurityLayer) - Ehlo (false, cancellationToken); - authenticated = true; - OnAuthenticated (response.Response); - return; - } + if (response.StatusCode == SmtpStatusCode.AuthenticationSuccessful) { + if (mechanism.NegotiatedSecurityLayer) + Ehlo (false, cancellationToken); + authenticated = true; + OnAuthenticated (response.Response); + return; + } - var message = string.Format (CultureInfo.InvariantCulture, "{0}: {1}", (int) response.StatusCode, response.Response); + var message = string.Format (CultureInfo.InvariantCulture, "{0}: {1}", (int) response.StatusCode, response.Response); - if (saslException != null) - throw new AuthenticationException (message, saslException); + if (saslException != null) + throw new AuthenticationException (message, saslException); - throw new AuthenticationException (message); + throw new AuthenticationException (message); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } void ValidateArguments (Encoding encoding, ICredentials credentials) @@ -1109,87 +1170,94 @@ public override void Authenticate (Encoding encoding, ICredentials credentials, { ValidateArguments (encoding, credentials); - var saslUri = new Uri ($"smtp://{uri.Host}"); - AuthenticationException authException = null; - SaslException saslException; - SmtpResponse response; - SaslMechanism sasl; - bool tried = false; - string challenge; - string command; - - foreach (var authmech in SaslMechanism.Rank (AuthenticationMechanisms)) { - var cred = credentials.GetCredential (uri, authmech); + using var operation = StartNetworkOperation (NetworkOperationKind.Authenticate); - if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) - continue; - - sasl.ChannelBindingContext = Stream.Stream as IChannelBindingContext; - sasl.Uri = saslUri; - - tried = true; + try { + var saslUri = new Uri ($"smtp://{uri.Host}"); + AuthenticationException authException = null; + SaslException saslException; + SmtpResponse response; + SaslMechanism sasl; + bool tried = false; + string challenge; + string command; + + foreach (var authmech in SaslMechanism.Rank (AuthenticationMechanisms)) { + var cred = credentials.GetCredential (uri, authmech); + + if ((sasl = SaslMechanism.Create (authmech, encoding, cred)) == null) + continue; - cancellationToken.ThrowIfCancellationRequested (); + sasl.ChannelBindingContext = Stream.Stream as IChannelBindingContext; + sasl.Uri = saslUri; - // send an initial challenge if the mechanism supports it - if (sasl.SupportsInitialResponse) { - challenge = sasl.Challenge (null, cancellationToken); - command = string.Format ("AUTH {0} {1}\r\n", authmech, challenge); - } else { - command = string.Format ("AUTH {0}\r\n", authmech); - } + tried = true; - detector.IsAuthenticating = true; - saslException = null; + cancellationToken.ThrowIfCancellationRequested (); - try { - response = SendCommandInternal (command, cancellationToken); + // send an initial challenge if the mechanism supports it + if (sasl.SupportsInitialResponse) { + challenge = sasl.Challenge (null, cancellationToken); + command = string.Format ("AUTH {0} {1}\r\n", authmech, challenge); + } else { + command = string.Format ("AUTH {0}\r\n", authmech); + } - if (response.StatusCode == SmtpStatusCode.AuthenticationMechanismTooWeak) - continue; + detector.IsAuthenticating = true; + saslException = null; try { - while (!sasl.IsAuthenticated) { - if (response.StatusCode != SmtpStatusCode.AuthenticationChallenge) - break; + response = SendCommandInternal (command, cancellationToken); - challenge = sasl.Challenge (response.Response, cancellationToken); - response = SendCommandInternal (challenge + "\r\n", cancellationToken); - } + if (response.StatusCode == SmtpStatusCode.AuthenticationMechanismTooWeak) + continue; - saslException = null; - } catch (SaslException ex) { - // reset the authentication state - response = SendCommandInternal ("\r\n", cancellationToken); - saslException = ex; + try { + while (!sasl.IsAuthenticated) { + if (response.StatusCode != SmtpStatusCode.AuthenticationChallenge) + break; + + challenge = sasl.Challenge (response.Response, cancellationToken); + response = SendCommandInternal (challenge + "\r\n", cancellationToken); + } + + saslException = null; + } catch (SaslException ex) { + // reset the authentication state + response = SendCommandInternal ("\r\n", cancellationToken); + saslException = ex; + } + } finally { + detector.IsAuthenticating = false; } - } finally { - detector.IsAuthenticating = false; - } - if (response.StatusCode == SmtpStatusCode.AuthenticationSuccessful) { - if (sasl.NegotiatedSecurityLayer) - Ehlo (false, cancellationToken); - authenticated = true; - OnAuthenticated (response.Response); - return; - } + if (response.StatusCode == SmtpStatusCode.AuthenticationSuccessful) { + if (sasl.NegotiatedSecurityLayer) + Ehlo (false, cancellationToken); + authenticated = true; + OnAuthenticated (response.Response); + return; + } - var message = string.Format (CultureInfo.InvariantCulture, "{0}: {1}", (int) response.StatusCode, response.Response); - Exception inner; + var message = string.Format (CultureInfo.InvariantCulture, "{0}: {1}", (int) response.StatusCode, response.Response); + Exception inner; - if (saslException != null) - inner = new SmtpCommandException (SmtpErrorCode.UnexpectedStatusCode, response.StatusCode, response.Response, saslException); - else - inner = new SmtpCommandException (SmtpErrorCode.UnexpectedStatusCode, response.StatusCode, response.Response); + if (saslException != null) + inner = new SmtpCommandException (SmtpErrorCode.UnexpectedStatusCode, response.StatusCode, response.Response, saslException); + else + inner = new SmtpCommandException (SmtpErrorCode.UnexpectedStatusCode, response.StatusCode, response.Response); - authException = new AuthenticationException (message, inner); - } + authException = new AuthenticationException (message, inner); + } - if (tried) - throw authException ?? new AuthenticationException (); + if (tried) + throw authException ?? new AuthenticationException (); - throw new NotSupportedException ("No compatible authentication mechanisms found."); + throw new NotSupportedException ("No compatible authentication mechanisms found."); + } catch (Exception ex) { + operation.SetError (ex); + throw; + } } internal static void ComputeDefaultValues (string host, ref int port, ref SecureSocketOptions options, out Uri uri, out bool starttls) @@ -1244,8 +1312,18 @@ void SslHandshake (SslStream ssl, string host, CancellationToken cancellationTok #endif } + void RecordClientDisconnected (Exception ex) + { +#if NET6_0_OR_GREATER + metrics?.RecordClientDisconnected (clientConnectedTimestamp, uri, ex); +#endif + clientConnectedTimestamp = 0; + } + void PostConnect (Stream stream, string host, int port, SecureSocketOptions options, bool starttls, CancellationToken cancellationToken) { + clientConnectedTimestamp = Stopwatch.GetTimestamp (); + try { ProtocolLogger.LogConnect (uri); } catch { @@ -1290,7 +1368,8 @@ void PostConnect (Stream stream, string host, int port, SecureSocketOptions opti } connected = true; - } catch { + } catch (Exception ex) { + RecordClientDisconnected (ex); Stream.Dispose (); secure = false; Stream = null; @@ -1398,28 +1477,35 @@ public override void Connect (string host, int port = 0, SecureSocketOptions opt ComputeDefaultValues (host, ref port, ref options, out uri, out var starttls); - var stream = ConnectNetwork (host, port, cancellationToken); - stream.WriteTimeout = timeout; - stream.ReadTimeout = timeout; + using var operation = StartNetworkOperation (NetworkOperationKind.Connect); - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + try { + var stream = ConnectNetwork (host, port, cancellationToken); + stream.WriteTimeout = timeout; + stream.ReadTimeout = timeout; - try { - SslHandshake (ssl, host, cancellationToken); - } catch (Exception ex) { - ssl.Dispose (); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "SMTP", host, port, 465, 25, 587); + try { + SslHandshake (ssl, host, cancellationToken); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "SMTP", host, port, 465, 25, 587); + } + + secure = true; + stream = ssl; + } else { + secure = false; } - secure = true; - stream = ssl; - } else { - secure = false; + PostConnect (stream, host, port, options, starttls, cancellationToken); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - PostConnect (stream, host, port, options, starttls, cancellationToken); } void ValidateArguments (Socket socket, string host, int port) @@ -1582,32 +1668,39 @@ public override void Connect (Stream stream, string host, int port = 0, SecureSo ComputeDefaultValues (host, ref port, ref options, out uri, out var starttls); - Stream network; + using var operation = StartNetworkOperation (NetworkOperationKind.Connect); - if (options == SecureSocketOptions.SslOnConnect) { - var ssl = new SslStream (stream, false, ValidateRemoteCertificate); + try { + Stream network; - try { - SslHandshake (ssl, host, cancellationToken); - } catch (Exception ex) { - ssl.Dispose (); + if (options == SecureSocketOptions.SslOnConnect) { + var ssl = new SslStream (stream, false, ValidateRemoteCertificate); - throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "SMTP", host, port, 465, 25, 587); + try { + SslHandshake (ssl, host, cancellationToken); + } catch (Exception ex) { + ssl.Dispose (); + + throw SslHandshakeException.Create (ref sslValidationInfo, ex, false, "SMTP", host, port, 465, 25, 587); + } + + network = ssl; + secure = true; + } else { + network = stream; + secure = false; } - network = ssl; - secure = true; - } else { - network = stream; - secure = false; - } + if (network.CanTimeout) { + network.WriteTimeout = timeout; + network.ReadTimeout = timeout; + } - if (network.CanTimeout) { - network.WriteTimeout = timeout; - network.ReadTimeout = timeout; + PostConnect (network, host, port, options, starttls, cancellationToken); + } catch (Exception ex) { + operation.SetError (ex); + throw; } - - PostConnect (network, host, port, options, starttls, cancellationToken); } /// @@ -1682,6 +1775,8 @@ public override void NoOp (CancellationToken cancellationToken = default) void Disconnect (string host, int port, SecureSocketOptions options, bool requested) { + RecordClientDisconnected (null); + capabilities = SmtpCapabilities.None; authenticated = false; connected = false; @@ -1698,7 +1793,7 @@ void Disconnect (string host, int port, SecureSocketOptions options, bool reques OnDisconnected (host, port, options, requested); } - #endregion +#endregion #region IMailTransport implementation @@ -2346,6 +2441,8 @@ string Send (FormatOptions options, MimeMessage message, MailboxAddress sender, size = -1; } + using var operation = StartNetworkOperation (NetworkOperationKind.Send); + try { // Note: if PIPELINING is supported, MailFrom() and RcptTo() will // queue their commands instead of sending them immediately. @@ -2382,13 +2479,21 @@ string Send (FormatOptions options, MimeMessage message, MailboxAddress sender, ParseDataResponse (dataResponse); return MessageData (format, message, size, cancellationToken, progress); - } catch (ServiceNotAuthenticatedException) { + } catch (ServiceNotAuthenticatedException ex) { + operation.SetError (ex); + + // do not disconnect Reset (cancellationToken); throw; - } catch (SmtpCommandException) { + } catch (SmtpCommandException ex) { + operation.SetError (ex); + + // do not disconnect Reset (cancellationToken); throw; - } catch { + } catch (Exception ex) { + operation.SetError (ex); + Disconnect (uri.Host, uri.Port, GetSecureSocketOptions (uri), false); throw; } diff --git a/MailKit/Net/SocketMetrics.cs b/MailKit/Net/SocketMetrics.cs new file mode 100644 index 0000000000..961256e4a6 --- /dev/null +++ b/MailKit/Net/SocketMetrics.cs @@ -0,0 +1,150 @@ +// +// SocketMetrics.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2024 .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#if NET6_0_OR_GREATER + +using System; +using System.Net; +using System.Net.Sockets; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace MailKit.Net { + sealed class SocketMetrics + { + readonly Counter connectCounter; + readonly Histogram connectDuration; + + public SocketMetrics (Meter meter) + { + connectCounter = meter.CreateCounter ( + name: $"{Telemetry.Socket.MeterName}.connect.count", + unit: "{attempt}", + description: "The number of times a socket attempted to connect to a remote host."); + + connectDuration = meter.CreateHistogram ( + name: $"{Telemetry.Socket.MeterName}.connect.duration", + unit: "ms", + description: "The number of milliseconds taken for a socket to connect to a remote host."); + } + + static SocketException GetSocketException (Exception exception) + { + Exception ex = exception; + + do { + if (ex is SocketException se) + return se; + + ex = ex.InnerException; + } while (ex is not null); + + return null; + } + + internal static bool TryGetErrorType (Exception exception, bool exceptionTypeFallback, out string errorType) + { + if (exception is OperationCanceledException) { + errorType = "cancelled"; + return true; + } + + var socketException = GetSocketException (exception); + + if (socketException is not null) { + switch (socketException.SocketErrorCode) { + case SocketError.HostNotFound: errorType = "host_not_found"; return true; + case SocketError.HostUnreachable: errorType = "host_unreachable"; return true; + case SocketError.NetworkUnreachable: errorType = "network_unreachable"; return true; + + case SocketError.ConnectionAborted: errorType = "connection_aborted"; return true; + case SocketError.ConnectionRefused: errorType = "connection_refused"; return true; + case SocketError.ConnectionReset: errorType = "connection_reset"; return true; + + case SocketError.TimedOut: errorType = "timed_out"; return true; + case SocketError.TooManyOpenSockets: errorType = "too_many_open_sockets"; return true; + } + } + + if (exceptionTypeFallback) { + errorType = exception.GetType ().FullName; + return true; + } + + errorType = null; + + return false; + } + + static TagList GetTags (IPAddress ip, string host, int port, Exception ex = null) + { + var tags = new TagList { + { "network.peer.address", ip.ToString () }, + { "server.address", host }, + { "server.port", port }, + }; + + if (ex is not null && TryGetErrorType (ex, true, out var errorType)) + tags.Add ("error.type", errorType); + + return tags; + } + + public void RecordConnected (long connectStartedTimestamp, IPAddress ip, string host, int port) + { + if (connectCounter.Enabled || connectDuration.Enabled) { + var tags = GetTags (ip, host, port); + + if (connectDuration.Enabled) { + var duration = TimeSpan.FromTicks (Stopwatch.GetTimestamp () - connectStartedTimestamp).TotalMilliseconds; + + connectDuration.Record (duration, tags); + } + + if (connectCounter.Enabled) + connectCounter.Add (1, tags); + } + } + + public void RecordConnectFailed (long connectStartedTimestamp, IPAddress ip, string host, int port, bool cancelled, Exception ex = null) + { + if (connectCounter.Enabled || connectDuration.Enabled) { + var tags = GetTags (ip, host, port, ex); + + if (connectDuration.Enabled) { + var duration = TimeSpan.FromTicks (Stopwatch.GetTimestamp () - connectStartedTimestamp).TotalMilliseconds; + + connectDuration.Record (duration, tags); + } + + if (connectCounter.Enabled) + connectCounter.Add (1, tags); + } + } + } +} + +#endif // NET6_0_OR_GREATER diff --git a/MailKit/Net/SocketUtils.cs b/MailKit/Net/SocketUtils.cs index b3d2e8c4e8..67cd77a40d 100644 --- a/MailKit/Net/SocketUtils.cs +++ b/MailKit/Net/SocketUtils.cs @@ -29,6 +29,7 @@ using System.Net; using System.Threading; using System.Net.Sockets; +using System.Diagnostics; using System.Threading.Tasks; namespace MailKit.Net @@ -38,11 +39,20 @@ static class SocketUtils class SocketConnectState { readonly TaskCompletionSource tcs = new TaskCompletionSource (); +#if NET6_0_OR_GREATER + readonly long connectStartTicks = Stopwatch.GetTimestamp (); +#endif readonly Socket socket; + readonly IPAddress ip; + readonly string host; + readonly int port; - public SocketConnectState (Socket socket) + public SocketConnectState (Socket socket, IPAddress ip, string host, int port) { this.socket = socket; + this.ip = ip; + this.host = host; + this.port = port; } public Task Task { get { return tcs.Task; } } @@ -57,17 +67,31 @@ public void OnEndConnect (IAsyncResult ar) try { socket.EndConnect (ar); } catch (Exception ex) { - // The connection failed. Try setting an exception in case the connection hasn't also been canceled. + // The connection failed. Try setting an exception in case the connection hasn't also been cancelled. +#if NET6_0_OR_GREATER + bool cancelled = !tcs.TrySetException (ex); + + Telemetry.Socket.Metrics?.RecordConnectFailed (connectStartTicks, ip, host, port, cancelled, ex); +#else tcs.TrySetException (ex); +#endif socket.Dispose (); return; } // The connection was successful. - if (tcs.TrySetResult (true)) + if (tcs.TrySetResult (true)) { +#if NET6_0_OR_GREATER + Telemetry.Socket.Metrics?.RecordConnected (connectStartTicks, ip, host, port); +#endif return; + } + + // Note: If we get this far, then it means that the connection has been cancelled. +#if NET6_0_OR_GREATER + Telemetry.Socket.Metrics?.RecordConnectFailed (connectStartTicks, ip, host, port, true); +#endif - // Note: If we get this far, then it means that the connection has been canceled. try { socket.Disconnect (false); socket.Dispose (); @@ -107,9 +131,13 @@ public static Socket Connect (string host, int port, IPEndPoint localEndPoint, C continue; } +#if NET6_0_OR_GREATER + long connectStartTicks = Stopwatch.GetTimestamp (); +#endif + try { if (cancellationToken.CanBeCanceled) { - var state = new SocketConnectState (socket); + var state = new SocketConnectState (socket, ipAddresses[i], host, port); using (var registration = cancellationToken.Register (state.OnCanceled, false)) { var ar = socket.BeginConnect (ipAddresses[i], port, OnEndConnect, state); @@ -117,14 +145,23 @@ public static Socket Connect (string host, int port, IPEndPoint localEndPoint, C } } else { socket.Connect (ipAddresses[i], port); + +#if NET6_0_OR_GREATER + Telemetry.Socket.Metrics?.RecordConnected (connectStartTicks, ipAddresses[i], host, port); +#endif } return socket; } catch (OperationCanceledException) { throw; - } catch { - if (!cancellationToken.CanBeCanceled) + } catch (Exception ex) { + if (!cancellationToken.CanBeCanceled) { +#if NET6_0_OR_GREATER + Telemetry.Socket.Metrics?.RecordConnectFailed (connectStartTicks, ipAddresses[i], host, port, false, ex); +#endif + socket.Dispose (); + } if (i + 1 == ipAddresses.Length) throw; @@ -162,7 +199,7 @@ public static async Task ConnectAsync (string host, int port, IPEndPoint } try { - var state = new SocketConnectState (socket); + var state = new SocketConnectState (socket, ipAddresses[i], host, port); using (var registration = cancellationToken.Register (state.OnCanceled, false)) { var ar = socket.BeginConnect (ipAddresses[i], port, OnEndConnect, state); diff --git a/MailKit/Telemetry.cs b/MailKit/Telemetry.cs new file mode 100644 index 0000000000..ec799c6fa5 --- /dev/null +++ b/MailKit/Telemetry.cs @@ -0,0 +1,400 @@ +// +// Telemetry.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2024 .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#if NET6_0_OR_GREATER + +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +using MailKit.Net; + +namespace MailKit { + /// + /// Telemetry constants for MailKit. + /// + /// + /// Telemetry constants for MailKit. + /// + public static class Telemetry + { + /// + /// The socket-level telemetry information. + /// + /// + /// The socket-level telemetry information. + /// + public static class Socket + { + /// + /// The name of the socket-level meter. + /// + /// + /// The name of the socket-level meter. + /// + public const string MeterName = "mailkit.net.socket"; + + /// + /// The version of the socket-level meter. + /// + /// + /// The version of the socket-level meter. + /// + public const string MeterVersion = "0.1"; + + static Meter Meter; + + internal static SocketMetrics Metrics { get; private set; } + + /// + /// Configure socket metering. + /// + /// + /// Configures socket metering. + /// + public static void Configure () + { + Meter ??= new Meter (MeterName, MeterVersion); + Metrics ??= new SocketMetrics (Meter); + } + +#if NET8_0_OR_GREATER + /// + /// Configure socket telemetry. + /// + /// + /// Configures socket telemetry. + /// + /// The meter factory. + /// + /// is null. + /// + public static void Configure (IMeterFactory meterFactory) + { + if (meterFactory is null) + throw new ArgumentNullException (nameof (meterFactory)); + + Meter ??= meterFactory.Create (MeterName, MeterVersion); + Metrics ??= new SocketMetrics (Meter); + } +#endif + } + + /// + /// The SmtpClient-level telemetry information. + /// + /// + /// The SmtpClient-level telemetry information. + /// + public static class SmtpClient + { + /// + /// The name of the SmtpClient activity source used for tracing. + /// + /// + /// The name of the SmtpClient activity source used for tracing. + /// + public const string ActivitySourceName = "MailKit.Net.SmtpClient"; + + /// + /// The version of the SmtpClient activity source used for tracing. + /// + /// + /// The version of the SmtpClient activity source used for tracing. + /// + public const string ActivitySourceVersion = "0.1"; + + internal static readonly ActivitySource ActivitySource = new ActivitySource (ActivitySourceName, ActivitySourceVersion); + + /// + /// The name of the SmtpClient meter. + /// + /// + /// The name of the SmtpClient meter. + /// + public const string MeterName = "mailkit.net.smtp"; + + /// + /// The version of the SmtpClient meter. + /// + /// + /// The version of the SmtpClient meter. + /// + public const string MeterVersion = "0.1"; + + static Meter Meter; + + internal static ClientMetrics Metrics { get; private set; } + + internal static ClientMetrics CreateMetrics (Meter meter) + { + return new ClientMetrics (Meter, MeterName, "an", "SMTP"); + } + + /// + /// Configure SmtpClient telemetry. + /// + /// + /// Configures SmtpClient telemetry. + /// + public static void Configure () + { + Meter ??= new Meter (MeterName, MeterVersion); + Metrics ??= CreateMetrics (Meter); + } + +#if NET8_0_OR_GREATER + /// + /// Configure SmtpClient telemetry. + /// + /// + /// Configures SmtpClient telemetry. + /// + /// The meter factory. + /// + /// is null. + /// + public static void Configure (IMeterFactory meterFactory) + { + if (meterFactory is null) + throw new ArgumentNullException (nameof (meterFactory)); + + Meter ??= meterFactory.Create (MeterName, MeterVersion); + Metrics ??= CreateMetrics (Meter); + } +#endif + } + + /// + /// The Pop3Client-level telemetry information. + /// + /// + /// The Pop3Client-level telemetry information. + /// + public static class Pop3Client + { + /// + /// The name of the Pop3Client activity source used for tracing. + /// + /// + /// The name of the Pop3Client activity source used for tracing. + /// + public const string ActivitySourceName = "MailKit.Net.Pop3Client"; + + /// + /// The version of the Pop3Client activity source used for tracing. + /// + /// + /// The version of the Pop3Client activity source used for tracing. + /// + public const string ActivitySourceVersion = "0.1"; + + internal static readonly ActivitySource ActivitySource = new ActivitySource (ActivitySourceName, ActivitySourceVersion); + + /// + /// The name of the Pop3Client meter. + /// + /// + /// The name of the Pop3Client meter. + /// + public const string MeterName = "mailkit.net.pop3"; + + /// + /// The version of the Pop3Client meter. + /// + /// + /// The version of the Pop3Client meter. + /// + public const string MeterVersion = "0.1"; + + static Meter Meter; + + internal static ClientMetrics Metrics { get; private set; } + + internal static ClientMetrics CreateMetrics (Meter meter) + { + return new ClientMetrics (Meter, MeterName, "a", "POP3"); + } + + /// + /// Configure Pop3Client telemetry. + /// + /// + /// Configures Pop3Client telemetry. + /// + public static void Configure () + { + Meter ??= new Meter (MeterName, MeterVersion); + Metrics ??= CreateMetrics (Meter); + } + +#if NET8_0_OR_GREATER + /// + /// Configure Pop3Client telemetry. + /// + /// + /// Configures Pop3Client telemetry. + /// + /// The meter factory. + /// + /// is null. + /// + public static void Configure (IMeterFactory meterFactory) + { + if (meterFactory is null) + throw new ArgumentNullException (nameof (meterFactory)); + + Meter ??= meterFactory.Create (MeterName, MeterVersion); + Metrics ??= CreateMetrics (Meter); + } +#endif + } + + /// + /// The ImapClient-level telemetry information. + /// + /// + /// The ImapClient-level telemetry information. + /// + public static class ImapClient + { + /// + /// The name of the ImapClient activity source used for tracing. + /// + /// + /// The name of the ImapClient activity source used for tracing. + /// + public const string ActivitySourceName = "MailKit.Net.ImapClient"; + + /// + /// The version of the ImapClient activity source used for tracing. + /// + /// + /// The version of the ImapClient activity source used for tracing. + /// + public const string ActivitySourceVersion = "0.1"; + + internal static readonly ActivitySource ActivitySource = new ActivitySource (ActivitySourceName, ActivitySourceVersion); + + /// + /// The name of the ImapClient meter. + /// + /// + /// The name of the ImapClient meter. + /// + public const string MeterName = "mailkit.net.imap"; + + /// + /// The version of the ImapClient meter. + /// + /// + /// The version of the ImapClient meter. + /// + public const string MeterVersion = "0.1"; + + static Meter Meter; + + internal static ClientMetrics Metrics { get; private set; } + + internal static ClientMetrics CreateMetrics (Meter meter) + { + return new ClientMetrics (Meter, MeterName, "an", "IMAP"); + } + + /// + /// Configure ImapClient telemetry. + /// + /// + /// Configures ImapClient telemetry. + /// + public static void Configure () + { + Meter ??= new Meter (MeterName, MeterVersion); + Metrics ??= CreateMetrics (Meter); + } + +#if NET8_0_OR_GREATER + /// + /// Configure ImapClient telemetry. + /// + /// + /// Configures ImapClient telemetry. + /// + /// The meter factory. + /// + /// is null. + /// + public static void Configure (IMeterFactory meterFactory) + { + if (meterFactory is null) + throw new ArgumentNullException (nameof (meterFactory)); + + Meter ??= meterFactory.Create (MeterName, MeterVersion); + Metrics ??= CreateMetrics (Meter); + } +#endif + } + + /// + /// Configure telemetry in MailKit. + /// + /// + /// Configures telemetry in MailKit. + /// + public static void Configure () + { + Socket.Configure (); + SmtpClient.Configure (); + Pop3Client.Configure (); + ImapClient.Configure (); + } + +#if NET8_0_OR_GREATER + /// + /// Configure telemetry in MailKit. + /// + /// + /// Configures telemetry in MailKit. + /// + /// The meter factory. + /// + /// is null. + /// + public static void Configure (IMeterFactory meterFactory) + { + if (meterFactory is null) + throw new ArgumentNullException (nameof (meterFactory)); + + Socket.Configure (meterFactory); + SmtpClient.Configure (meterFactory); + Pop3Client.Configure (meterFactory); + ImapClient.Configure (meterFactory); + } +#endif + } +} + +#endif // NET6_0_OR_GREATER diff --git a/Telemetry.md b/Telemetry.md new file mode 100644 index 0000000000..259243fd64 --- /dev/null +++ b/Telemetry.md @@ -0,0 +1,384 @@ +# MailKit Telemetry Documentation + +## Socket Metrics + +### Metric: `mailkit.net.socket.connect.count` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.socket.connect.count` | Counter | `{attempt}` | The number of times a socket attempted to connect to a remote host. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `network.peer.address` | string | Peer IP address of the socket connection. | `142.251.167.109` | Always | +| `server.address` | string | The host name that the socket is connecting to. | `smtp.gmail.com` | Always | +| `server.port` | int | The port that the socket is connecting to. | `465` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | + +This metric tracks the number of times a socket attempted to connect to a remote host. + +`error.type` has the following values: + +| **Value** | **Description** | +|:------------------------|:-------------------------------------------------------------------------------| +| `cancelled` | The operation was cancelled. | +| `host_not_found` | No such host is known. The name is not an official host name or alias. | +| `host_unreachable` | There is no network route to the specified host. | +| `network_unreachable` | No route to the remote host exists. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_refused` | The remote host is actively refusing a connection. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `too_many_open_sockets` | There are too many open sockets in the underlying socket provider. | + +Available starting in: MailKit v4.7.0 + +### Metric: `mailkit.net.socket.connect.duration` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.socket.connect.duration` | Histogram | `ms` | The number of milliseconds taken for a socket to connect to a remote host. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `network.peer.address` | string | Peer IP address of the socket connection. | `142.251.167.109` | Always | +| `server.address` | string | The host name that the socket is connecting to. | `smtp.gmail.com` | Always | +| `server.port` | int | The port that the socket is connecting to. | `465` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | + +This metric measures the time it takes to connect a socket to a remote host. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `host_not_found` | No such host is known. The name is not an official host name or alias. | +| `host_unreachable` | There is no network route to the specified host. | +| `network_unreachable` | No route to the remote host exists. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_refused` | The remote host is actively refusing a connection. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `too_many_open_sockets` | There are too many open sockets in the underlying socket provider. | + +Available starting in: MailKit v4.7.0 + +## SmtpClient Metrics + +### Metric: `mailkit.net.smtp.client.connection.duration` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.smtp.client.connection.duration` | Histogram | `s` | The duration of successfully established connections to an SMTP server. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `server.address` | string | The host name that the client is connected to. | `smtp.gmail.com` | Always | +| `server.port` | int | The port that the client is connected to. | `25`, `465`, `587` | Always | +| `url.scheme` | string | The URL scheme of the protocol used. | `smtp` or `smtps` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | +| `network.operation` | string | The name of the operation. | `connect`, `authenticate`, `send`, ... | Always | + +This metric tracks the connection duration of each SmtpClient connection and records any error details if the connection was terminated involuntarily. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `secure_connection_error` | An SSL or TLS connection could not be negotiated. | +| `protocol_error` | The connection was terminated due to an incomplete or invalid response from the server. | + +Available starting in: MailKit v4.7.0 + +### Metric: `mailkit.net.smtp.client.operation.count` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.smtp.client.operation.count` | Counter | `{operation}` | The number of times a client performed an operation on an SMTP server. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `server.address` | string | The host name that the client is connected to. | `smtp.gmail.com` | Always | +| `server.port` | int | The port that the client is connected to. | `25`, `465`, `587` | Always | +| `url.scheme` | string | The URL scheme of the protocol used. | `smtp` or `smtps` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | +| `network.operation` | string | The name of the operation. | `connect`, `authenticate`, `send`, ... | Always | + +This metric tracks the number of times an SmtpClient has performed an operation on an SMTP server. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `host_not_found` | No such host is known. The name is not an official host name or alias. | +| `host_unreachable` | There is no network route to the specified host. | +| `network_unreachable` | No route to the remote host exists. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_refused` | The remote host is actively refusing a connection. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `too_many_open_sockets` | There are too many open sockets in the underlying socket provider. | +| `secure_connection_error` | An SSL or TLS connection could not be negotiated. | +| `protocol_error` | The connection was terminated due to an incomplete or invalid response from the server. | + +Available starting in: MailKit v4.7.0 + +### Metric: `mailkit.net.smtp.client.operation.duration` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.smtp.client.operation.duration` | Histogram | `ms` | The amount of time it takes for the SMTP server to perform an operation. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `server.address` | string | The host name that the client is connected to. | `smtp.gmail.com` | Always | +| `server.port` | int | The port that the client is connected to. | `25`, `465`, `587` | Always | +| `url.scheme` | string | The URL scheme of the protocol used. | `smtp` or `smtps` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | +| `network.operation` | string | The name of the operation. | `connect`, `authenticate`, `send`, ... | Always | + +This metric tracks the amount of time it takes an SMTP server to perform an operation. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `host_not_found` | No such host is known. The name is not an official host name or alias. | +| `host_unreachable` | There is no network route to the specified host. | +| `network_unreachable` | No route to the remote host exists. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_refused` | The remote host is actively refusing a connection. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `too_many_open_sockets` | There are too many open sockets in the underlying socket provider. | +| `secure_connection_error` | An SSL or TLS connection could not be negotiated. | +| `protocol_error` | The connection was terminated due to an incomplete or invalid response from the server. | + +Available starting in: MailKit v4.7.0 + +## Pop3Client Metrics + +### Metric: `mailkit.net.pop3.client.connection.duration` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.pop3.client.connection.duration` | Histogram | `s` | The duration of successfully established connections to a POP3 server. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `server.address` | string | The host name that the client is connected to. | `pop.gmail.com` | Always | +| `server.port` | int | The port that the client is connected to. | `110`, `995` | Always | +| `url.scheme` | string | The URL scheme of the protocol used. | `pop3` or `pop3s` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | +| `network.operation` | string | The name of the operation. | `connect`, `authenticate`, ... | Always | + +This metric tracks the connection duration of each Pop3Client connection and records any error details if the connection was terminated involuntarily. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `secure_connection_error` | An SSL or TLS connection could not be negotiated. | +| `protocol_error` | The connection was terminated due to an incomplete or invalid response from the server. | + +Available starting in: MailKit v4.7.0 + +### Metric: `mailkit.net.pop3.client.operation.count` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.pop3.client.operation.count` | Counter | `{operation}` | The number of times a client performed an operation on a POP3 server. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `server.address` | string | The host name that the client is connected to. | `pop.gmail.com` | Always | +| `server.port` | int | The port that the client is connected to. | `110`, `995` | Always | +| `url.scheme` | string | The URL scheme of the protocol used. | `pop3` or `pop3s` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | +| `network.operation` | string | The name of the operation. | `connect`, `authenticate`, ... | Always | + +This metric tracks the number of times an Pop3Client has performed an operation on a POP3 server. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `host_not_found` | No such host is known. The name is not an official host name or alias. | +| `host_unreachable` | There is no network route to the specified host. | +| `network_unreachable` | No route to the remote host exists. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_refused` | The remote host is actively refusing a connection. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `too_many_open_sockets` | There are too many open sockets in the underlying socket provider. | +| `secure_connection_error` | An SSL or TLS connection could not be negotiated. | +| `protocol_error` | The connection was terminated due to an incomplete or invalid response from the server. | + +Available starting in: MailKit v4.7.0 + +### Metric: `mailkit.net.pop3.client.operation.duration` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.pop3.client.operation.duration` | Histogram | `ms` | The amount of time it takes for the POP3 server to perform an operation. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `server.address` | string | The host name that the client is connected to. | `pop.gmail.com` | Always | +| `server.port` | int | The port that the client is connected to. | `110`, `995` | Always | +| `url.scheme` | string | The URL scheme of the protocol used. | `pop3` or `pop3s` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | +| `network.operation` | string | The name of the operation. | `connect`, `authenticate`, ... | Always | + +This metric tracks the amount of time it takes a POP3 server to perform an operation. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `host_not_found` | No such host is known. The name is not an official host name or alias. | +| `host_unreachable` | There is no network route to the specified host. | +| `network_unreachable` | No route to the remote host exists. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_refused` | The remote host is actively refusing a connection. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `too_many_open_sockets` | There are too many open sockets in the underlying socket provider. | +| `secure_connection_error` | An SSL or TLS connection could not be negotiated. | +| `protocol_error` | The connection was terminated due to an incomplete or invalid response from the server. | + +Available starting in: MailKit v4.7.0 + +## ImapClient Metrics + +### Metric: `mailkit.net.imap.client.connection.duration` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.imap.client.connection.duration` | Histogram | `s` | The duration of successfully established connections to an IMAP server. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `server.address` | string | The host name that the client is connected to. | `imap.gmail.com` | Always | +| `server.port` | int | The port that the client is connected to. | `143`, `993` | Always | +| `url.scheme` | string | The URL scheme of the protocol used. | `imap` or `imaps` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | +| `network.operation` | string | The name of the operation. | `connect`, `authenticate`, ... | Always | + +This metric tracks the connection duration of each ImapClient connection and records any error details if the connection was terminated involuntarily. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `secure_connection_error` | An SSL or TLS connection could not be negotiated. | +| `protocol_error` | The connection was terminated due to an incomplete or invalid response from the server. | + +Available starting in: MailKit v4.7.0 + +### Metric: `mailkit.net.imap.client.operation.count` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.imap.client.operation.count` | Counter | `{operation}` | The number of times a client performed an operation on an IMAP server. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `server.address` | string | The host name that the client is connected to. | `imap.gmail.com` | Always | +| `server.port` | int | The port that the client is connected to. | `143`, `993` | Always | +| `url.scheme` | string | The URL scheme of the protocol used. | `imap` or `imaps` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | +| `network.operation` | string | The name of the operation. | `connect`, `authenticate`, ... | Always | + +This metric tracks the number of times an ImapClient has performed an operation on an IMAP server. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `host_not_found` | No such host is known. The name is not an official host name or alias. | +| `host_unreachable` | There is no network route to the specified host. | +| `network_unreachable` | No route to the remote host exists. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_refused` | The remote host is actively refusing a connection. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `too_many_open_sockets` | There are too many open sockets in the underlying socket provider. | +| `secure_connection_error` | An SSL or TLS connection could not be negotiated. | +| `protocol_error` | The connection was terminated due to an incomplete or invalid response from the server. | + +Available starting in: MailKit v4.7.0 + +### Metric: `mailkit.net.imap.client.operation.duration` + +**Status:** [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.30.0/specification/document-status.md) + +| **Name** | **Instrument Type** | **Unit** | **Description** | +|:----------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------------------| +| `mailkit.net.imap.client.operation.duration` | Histogram | `ms` | The amount of time it takes for the IMAP server to perform an operation. | + +| **Attribute** | **Type** | **Description** | **Examples** | **Presence** | +|:---------------------------|:---------|:-------------------------------------------------|:------------------------------------------------|:----------------------| +| `server.address` | string | The host name that the client is connected to. | `imap.gmail.com` | Always | +| `server.port` | int | The port that the client is connected to. | `143`, `993` | Always | +| `url.scheme` | string | The URL scheme of the protocol used. | `imap` or `imaps` | Always | +| `error.type` | string | The type of error encountered. | `host_not_found`, `host_unreachable`, ... | If an error occurred. | +| `network.operation` | string | The name of the operation. | `connect`, `authenticate`, ... | Always | + +This metric tracks the amount of time it takes an IMAP server to perform an operation. + +`error.type` has the following values: + +| **Value** | **Description** | +|:--------------------------|:----------------------------------------------------------------------------------------| +| `cancelled` | An operation was cancelled. | +| `host_not_found` | No such host is known. The name is not an official host name or alias. | +| `host_unreachable` | There is no network route to the specified host. | +| `network_unreachable` | No route to the remote host exists. | +| `connection_aborted` | The connection was aborted by .NET or the underlying socket provider. | +| `connection_refused` | The remote host is actively refusing a connection. | +| `connection_reset` | The connection was reset by the remote peer. | +| `timed_out` | The connection attempt timed out, or the connected host has failed to respond. | +| `too_many_open_sockets` | There are too many open sockets in the underlying socket provider. | +| `secure_connection_error` | An SSL or TLS connection could not be negotiated. | +| `protocol_error` | The connection was terminated due to an incomplete or invalid response from the server. | + +Available starting in: MailKit v4.7.0