From c05d6bcd2c366bd809a701cb3114c60fdb272756 Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Tue, 14 Oct 2025 16:10:22 +0200 Subject: [PATCH 01/13] adding Oauth2 support --- README.md | 174 ++++++++++++++++-- .../Serilog.Sinks.Email.csproj | 1 + .../Sinks/Email/EmailSinkOptions.cs | 40 ++++ .../Sinks/Email/MailKitEmailTransport.cs | 50 ++++- .../Sinks/Email/SmtpAuthenticationMode.cs | 29 +++ .../Sinks/OAuth2/TokenHelper.cs | 156 ++++++++++++++++ 6 files changed, 429 insertions(+), 21 deletions(-) create mode 100644 src/Serilog.Sinks.Email/Sinks/Email/SmtpAuthenticationMode.cs create mode 100644 src/Serilog.Sinks.Email/Sinks/OAuth2/TokenHelper.cs diff --git a/README.md b/README.md index 888961a..0db05bd 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Sends log events by SMTP email. > ℹ️ Version 3.x of this package changes the name and structure of many configuration parameters from their 2.x names; see below for detailed information. +> ✅ Version 3.x now includes optional OAuth2 (Modern Authentication) support for SMTP using Azure AD / Office 365 or any OAuth2-compliant provider. See the new OAuth2 section below. + **Package Id:** [Serilog.Sinks.Email](http://nuget.org/packages/serilog.sinks.email) ```csharp @@ -15,21 +17,30 @@ await using var log = new LoggerConfiguration() .CreateLogger(); ``` -Supported options are: +## Basic (single-message) options -| Parameter | Description | -|------------------------|-------------------------------------------------------------------------------------------------------------------------------------| -| `from` | The email address emails will be sent from. | -| `to` | The email address emails will be sent to. Multiple addresses can be separated with commas or semicolons. | -| `host` | The SMTP server to use. | -| `port` | The port used for the SMTP connection. The default is 25. | -| `connectionSecurity` | Choose the security applied to the SMTP connection. This enumeration type is supplied by MailKit. The default is `Auto`. | -| `credentials` | The network credentials to use to authenticate with the mail server. | -| `subject` | A message template describing the email subject. The default is `"Log Messages"`. | -| `body` | A message template describing the format of the email body. The default is `"{Timestamp} [{Level}] {Message}{NewLine}{Exception}"`. | -| `formatProvider` | Supplies culture-specific formatting information. The default is to use the current culture. | +Supported options are: -An overload accepting `EmailSinkOptions` can be used to specify advanced options such as batched and/or HTML body templates. +| Parameter | Description | +|------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `from` | The email address emails will be sent from. | +| `to` | The email address emails will be sent to. Multiple addresses can be separated with commas or semicolons. | +| `host` | The SMTP server to use. | +| `port` | The port used for the SMTP connection. The default is 25. | +| `connectionSecurity` | Choose the security applied to the SMTP connection. This enumeration type is supplied by MailKit. The default is `Auto`. | +| `credentials` | The network credentials to use to authenticate with the mail server (traditional username/password SMTP AUTH). | +| `subject` | A message template describing the email subject. The default is `"Log Messages"`. | +| `body` | A message template describing the format of the email body. The default is `"{Timestamp} [{Level}] {Message}{NewLine}{Exception}"`. | +| `formatProvider` | Supplies culture-specific formatting information. The default is to use the current culture. | +| `SmtpAuthenticationMode` | Specifies which authentication mode to use: e.g. `None`, `Basic`, `OAuth2`. Determines whether classic network credentials or OAuth2 token acquisition flows are used. | +| `ApplicationId` | OAuth2 Client/Application ID (e.g. Azure AD App Registration Client ID). Required for OAuth2 when using client secret or certificate flows. | +| `SecretId` | OAuth2 Client Secret value (NOT the secret’s GUID id). Provide this for client secret flows. Leave null when using certificate-based client assertion. | +| `OAuthTokenUrl` | The OAuth2 token endpoint URL (e.g. `https://login.microsoftonline.com//oauth2/v2.0/token` for Azure AD). | +| `OAuthScope` | The space-separated scope(s) requested for the token (e.g. `https://outlook.office365.com/.default` for Office 365 SMTP). | +| `SecretWindowsStoreCertificateThumbprint` | Thumbprint of an X.509 certificate in the Windows Certificate Store (CurrentUser / My) used to create a client assertion instead of a client secret. | +| `OAuthTokenUsername` | The user principal (SMTP mailbox) associated with the OAuth2 token (often the `from` address). Some SMTP servers require the SMTP AUTH identity even with OAuth2. | + +An overload accepting `EmailSinkOptions` can be used to specify advanced options such as batched and/or HTML body templates, along with the OAuth2-related properties above. ## Sending batch email @@ -58,7 +69,6 @@ Batch formatting can be customized using `options.Body`. To send HTML email, specify a custom `IBatchTextFormatter` in `options.Body` and set `options.IsBodyHtml` to `true`: - ```csharp await using var log = new LoggerConfiguration() .WriteTo.Email( @@ -104,3 +114,139 @@ class MyHtmlBodyFormatter : IBatchTextFormatter } } ``` + +## OAuth2 / Modern Authentication (Optional) + +Some SMTP providers (notably Exchange Online / Office 365) are deprecating or disabling basic username/password authentication. The sink supports obtaining an OAuth2 access token and using it for SMTP AUTH (XOAUTH2) when `SmtpAuthenticationMode` is set to `OAuth2`. + +### Choosing an authentication mode + +```csharp +public enum SmtpAuthenticationMode +{ + None, // No AUTH attempted + Basic, // Username/password via NetworkCredential + OAuth2 // Acquire token and authenticate using XOAUTH2 +} +``` + +Set `SmtpAuthenticationMode` appropriately in `EmailSinkOptions`. If `Basic` is selected, use `credentials`. If `OAuth2` is selected, configure the OAuth2 properties. + +### Example: OAuth2 with Azure AD (Client Secret flow) + +```csharp +await using var log = new LoggerConfiguration() + .WriteTo.Email( + options: new() + { + From = "app@contoso.com", + To = "support@contoso.com", + Host = "smtp.office365.com", + Port = 587, + ConnectionSecurity = SecureSocketOptions.StartTls, + SmtpAuthenticationMode = SmtpAuthenticationMode.OAuth2, + ApplicationId = "", + SecretId = "", + OAuthTokenUrl = "https://login.microsoftonline.com//oauth2/v2.0/token", + OAuthScope = "https://outlook.office365.com/.default", + OAuthTokenUsername = "app@contoso.com" + }) + .CreateLogger(); +``` + +Notes: + +- `OAuthScope` for Exchange Online SMTP is typically `https://outlook.office365.com/.default` (the `.default` scope requests all application permissions granted). +- Ensure your Azure AD App Registration has the appropriate Application permission (e.g. `SMTP.Send`) and admin consent granted. + +### Example: OAuth2 with Azure AD (Certificate / Client Assertion flow) + +Instead of a client secret, supply the thumbprint of a certificate installed in the Windows Current User store. + +```csharp +await using var log = new LoggerConfiguration() + .WriteTo.Email( + options: new() + { + From = "app@contoso.com", + To = "support@contoso.com", + Host = "smtp.office365.com", + Port = 587, + ConnectionSecurity = SecureSocketOptions.StartTls, + SmtpAuthenticationMode = SmtpAuthenticationMode.OAuth2, + ApplicationId = "", + SecretWindowsStoreCertificateThumbprint = "", + OAuthTokenUrl = "https://login.microsoftonline.com//oauth2/v2.0/token", + OAuthScope = "https://outlook.office365.com/.default", + OAuthTokenUsername = "app@contoso.com" + }) + .CreateLogger(); +``` + +The sink will construct a signed JWT (client assertion) using the certificate to request the access token. + +### Example: Configuration via appsettings.json + +If you configure Serilog via JSON: + +```json +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Email" ], + "WriteTo": [ + { + "Name": "Email", + "Args": { + "options": { + "From": "app@contoso.com", + "To": "support@contoso.com", + "Host": "smtp.office365.com", + "Port": 587, + "ConnectionSecurity": "StartTls", + "SmtpAuthenticationMode": "OAuth2", + "ApplicationId": "", + "SecretId": "", + "OAuthTokenUrl": "https://login.microsoftonline.com//oauth2/v2.0/token", + "OAuthScope": "https://outlook.office365.com/.default", + "OAuthTokenUsername": "app@contoso.com" + } + } + } + ] + } +} +``` + +(Adjust property names if your configuration binder uses slightly different casing conventions.) + +### Troubleshooting OAuth2 + +- Invalid scope: Confirm the `OAuthScope` matches provider requirements. +- 400 / invalid_client: Check `ApplicationId`, secret value (not secret ID), or certificate thumbprint. +- 535 / Authentication failed: Ensure the SMTP mailbox (`OAuthTokenUsername` / `From`) is licensed and permitted to send. +- Time skew: Certificates used for assertions must have valid system time; ensure the host clock is synchronized. +- Permission errors: For Azure AD, verify application permissions (not delegated) include `SMTP.Send` and admin consent is granted. + +### Security considerations + +- Prefer certificate-based auth over client secrets in production. +- Never commit secrets or thumbprints with corresponding private key material to source control. +- Rotate secrets/certificates regularly. + +## Migration notes for OAuth2 adoption + +If upgrading from a previous version that used `credentials`: + +1. Register an app in your identity provider (Azure AD example). +2. Grant SMTP send permissions. +3. Set `SmtpAuthenticationMode = OAuth2`. +4. Provide either `SecretId` or `SecretWindowsStoreCertificateThumbprint`. +5. Replace username/password with `OAuthTokenUsername` (if required by provider). + +If `SmtpAuthenticationMode` remains `Basic`, existing behavior is unchanged. + +## Contributing + +Contributions are welcome! Please open an issue or PR for enhancements or fixes, including additional OAuth2 providers or flows. + +--- diff --git a/src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj b/src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj index eea24a0..424c26c 100644 --- a/src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj +++ b/src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Serilog.Sinks.Email/Sinks/Email/EmailSinkOptions.cs b/src/Serilog.Sinks.Email/Sinks/Email/EmailSinkOptions.cs index ecef8a3..b78d7a2 100644 --- a/src/Serilog.Sinks.Email/Sinks/Email/EmailSinkOptions.cs +++ b/src/Serilog.Sinks.Email/Sinks/Email/EmailSinkOptions.cs @@ -97,4 +97,44 @@ public EmailSinkOptions() /// Provides a method that validates server certificates. /// public System.Net.Security.RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; set; } + + /// + /// Defines which Smtp Authentication to use. + /// + public SmtpAuthenticationMode SmtpAuthenticationMode { get; set; } + + /// + /// The Application (client) ID used for OAuth2 authentication. + /// + public string? ApplicationId { get; set; } = null; + + /// + /// The Secret ID used for OAuth2 authentication. + /// + public string? SecretId { get; set; } = null; + + /// + /// The url to request tokens from the OAuth2 provider. + /// E.g. https://login.microsoftonline.com/TENANTID/oauth2/v2.0/token for Azure + /// + public string? OAuthTokenUrl { get; set; } = null; + + /// + /// The scope for the OAuth2 token. + /// E.g https://outlook.office365.com/.default for Office365 + /// + public string? OAuthScope { get; set; } = null; + + /// + /// The certificate thumbprint to use from the Windows Certificate Store for OAuth2 authentication. + /// + public string? SecretWindowsStoreCertificateThumbprint { get; set; } = null; + + /// + /// The username associated with the OAuth2 token. + /// + public string? OAuthTokenUsername { get; set; } = null; + } + + diff --git a/src/Serilog.Sinks.Email/Sinks/Email/MailKitEmailTransport.cs b/src/Serilog.Sinks.Email/Sinks/Email/MailKitEmailTransport.cs index b625fce..b0b3509 100644 --- a/src/Serilog.Sinks.Email/Sinks/Email/MailKitEmailTransport.cs +++ b/src/Serilog.Sinks.Email/Sinks/Email/MailKitEmailTransport.cs @@ -11,11 +11,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; using System.Linq; using System.Text; using System.Threading.Tasks; -using MailKit.Net.Smtp; -using MimeKit; namespace Serilog.Sinks.Email; @@ -50,12 +51,47 @@ SmtpClient OpenConnectedSmtpClient() smtpClient.Connect(options.Host, options.Port, options.ConnectionSecurity); - if (options.Credentials != null) + if (options.SmtpAuthenticationMode == SmtpAuthenticationMode.OAuth2) + { + if (options.OAuthScope == null) throw new InvalidOperationException("OAuthScope must be set when using OAuth2 authentication."); + if (options.OAuthTokenUrl == null) throw new InvalidOperationException("OAuthTokenUrl must be set when using OAuth2 authentication."); + if (options.OAuthTokenUsername == null) throw new InvalidOperationException("OAuthTokenUsername must be set when using OAuth2 authentication."); + if (options.ApplicationId == null) throw new InvalidOperationException("ApplicationId must be set when using OAuth2 authentication."); + if (options.SecretId == null && options.SecretWindowsStoreCertificateThumbprint == null) + throw new InvalidOperationException("Either SecretId or SecretWindowsStoreCertificateThumbprint must be set when using OAuth2 authentication."); + + string token = ""; + + if (!String.IsNullOrEmpty(options.SecretId)) + { + token = OAuth2.TokenHelper.GetAccessToken( + options.OAuthTokenUrl, + options.OAuthScope, + options.ApplicationId, + options.SecretId!); + } + + if (!String.IsNullOrEmpty(options.SecretWindowsStoreCertificateThumbprint)) + { + token = OAuth2.TokenHelper.GetAccessTokenWithWindowsMachineCertificate( + options.OAuthTokenUrl, + options.OAuthScope, + options.ApplicationId, + options.SecretWindowsStoreCertificateThumbprint!); + } + + var oauth2 = new SaslMechanismOAuth2(options.OAuthTokenUsername, token); + smtpClient.Authenticate(oauth2); + } + else if (options.SmtpAuthenticationMode == SmtpAuthenticationMode.Basic || options.SmtpAuthenticationMode == SmtpAuthenticationMode.None) { - smtpClient.Authenticate( - Encoding.UTF8, - options.Credentials.GetCredential( - options.Host, options.Port, "smtp")); + if (options.Credentials != null) + { + smtpClient.Authenticate( + Encoding.UTF8, + options.Credentials.GetCredential( + options.Host, options.Port, "smtp")); + } } return smtpClient; } diff --git a/src/Serilog.Sinks.Email/Sinks/Email/SmtpAuthenticationMode.cs b/src/Serilog.Sinks.Email/Sinks/Email/SmtpAuthenticationMode.cs new file mode 100644 index 0000000..872e79f --- /dev/null +++ b/src/Serilog.Sinks.Email/Sinks/Email/SmtpAuthenticationMode.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Serilog.Sinks.Email +{ + /// + /// Specifies the SMTP authentication mode used when sending emails. + /// + public enum SmtpAuthenticationMode + { + /// + /// No authentication + /// + None, + + /// + /// Use traditional username/password (LOGIN/PLAIN) authentication. + /// + Basic, + + /// + /// Use OAuth2 (e.g. XOAUTH2) token-based authentication. + /// + OAuth2 + } +} diff --git a/src/Serilog.Sinks.Email/Sinks/OAuth2/TokenHelper.cs b/src/Serilog.Sinks.Email/Sinks/OAuth2/TokenHelper.cs new file mode 100644 index 0000000..c384ff5 --- /dev/null +++ b/src/Serilog.Sinks.Email/Sinks/OAuth2/TokenHelper.cs @@ -0,0 +1,156 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; + +namespace Serilog.Sinks.OAuth2 +{ + internal static class TokenHelper + { + public static string GetAccessToken(string tokenUrl, string scope, string clientId, string clientSecret) + { + using (var httpClient = new HttpClient()) + { + var requestBody = new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", clientId), + new KeyValuePair("scope", scope), + new KeyValuePair("client_secret", clientSecret), + new KeyValuePair("grant_type", "client_credentials") + }); + + var response = httpClient.PostAsync(tokenUrl, requestBody).GetAwaiter().GetResult(); + response.EnsureSuccessStatusCode(); + + var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + JObject tokenResult = JObject.Parse(responseBody); + + if (tokenResult == null || tokenResult["access_token"] == null) + { + throw new Exception("Failed to obtain access token from OAuth2 provider."); + } + +#pragma warning disable CS8602 // Dereference of a possibly null reference. + string accessToken = tokenResult["access_token"].ToString(); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + + return accessToken; + } + } + + public static string GetAccessTokenWithWindowsMachineCertificate(string tokenUrl, string scope, string clientId, string certificateThumbprint) + { + // Find the certificate in the Windows certificate store + using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) + { + store.Open(OpenFlags.ReadOnly); + var certificates = store.Certificates.Find(X509FindType.FindByThumbprint, certificateThumbprint, false); + + if (certificates.Count == 0) + { + throw new InvalidOperationException($"Certificate with thumbprint {certificateThumbprint} not found in LocalMachine store."); + } + + var certificate = certificates[0]; + + // Create JWT header and payload + var now = DateTimeOffset.UtcNow; + var exp = now.AddMinutes(10); + + var header = new + { + alg = "RS256", + typ = "JWT", + x5t = Convert.ToBase64String(certificate.GetCertHash()) + }; + + var payload = new + { + sub = clientId, + jti = Guid.NewGuid().ToString(), + aud = tokenUrl, + iat = ToUnixTimeSeconds(now), + nbf = ToUnixTimeSeconds(now), + exp = ToUnixTimeSeconds(exp), + iss = clientId + }; + + // Encode header and payload + var headerJson = JsonConvert.SerializeObject(header); + var payloadJson = JsonConvert.SerializeObject(payload); + + var headerBase64 = Base64UrlEncode(headerJson); + var payloadBase64 = Base64UrlEncode(payloadJson); + + // Create signature + var dataToSign = $"{headerBase64}.{payloadBase64}"; + byte[] dataToSignBytes = System.Text.Encoding.UTF8.GetBytes(dataToSign); + + using (var rsa = certificate.GetRSAPrivateKey()) + { + var signature = rsa!.SignData(dataToSignBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var signatureBase64 = Base64UrlEncode(signature); + + var clientAssertion = $"{headerBase64}.{payloadBase64}.{signatureBase64}"; + + // Request the token + using (var httpClient = new HttpClient()) + { + var requestBody = new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", clientId), + new KeyValuePair("scope", scope), + new KeyValuePair("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + new KeyValuePair("client_assertion", clientAssertion), + new KeyValuePair("grant_type", "client_credentials") + }); + + var response = httpClient.PostAsync(tokenUrl, requestBody).GetAwaiter().GetResult(); + response.EnsureSuccessStatusCode(); + + var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + JObject tokenResult = JObject.Parse(responseBody); + + if (tokenResult == null || tokenResult["access_token"] == null) + { + throw new Exception("Failed to obtain access token from OAuth2 provider."); + } + +#pragma warning disable CS8602 // Dereference of a possibly null reference. + string accessToken = tokenResult["access_token"].ToString(); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + + return accessToken; + } + } + } + } + + // Helper methods + private static long ToUnixTimeSeconds(DateTimeOffset date) + { + return date.ToUnixTimeSeconds(); + } + + private static string Base64UrlEncode(string input) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(input); + return Base64UrlEncode(bytes); + } + + private static string Base64UrlEncode(byte[] input) + { + var output = Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + return output; + } + } +} From 5d606cf28d5795ebba0d6b5e0d398fd7704a1977 Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Tue, 14 Oct 2025 16:18:51 +0200 Subject: [PATCH 02/13] continue implement --- .../LoggerConfigurationEmailExtensions.cs | 40 +++++++++++++++++++ .../EmailSinkTests.cs | 8 +++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/Serilog.Sinks.Email/LoggerConfigurationEmailExtensions.cs b/src/Serilog.Sinks.Email/LoggerConfigurationEmailExtensions.cs index a2ec0ef..d9814ff 100644 --- a/src/Serilog.Sinks.Email/LoggerConfigurationEmailExtensions.cs +++ b/src/Serilog.Sinks.Email/LoggerConfigurationEmailExtensions.cs @@ -53,6 +53,13 @@ public static class LoggerConfigurationEmailExtensions /// The minimum level for /// events passed through the sink. Ignored when is specified. /// A switch allowing the pass-through minimum level + /// + /// + /// + /// /// + /// + /// + /// /// to be changed at runtime. /// /// Logger configuration, allowing configuration to continue. @@ -64,6 +71,13 @@ public static LoggerConfiguration Email( string to, string host, int port = EmailSinkOptions.DefaultPort, + SmtpAuthenticationMode smtpAuthenticationMode = SmtpAuthenticationMode.None, + string? oauthTokenUrl = null, + string? oauthScope = null, + string? oauthTokenUsername = null, + string? applicationId = null, + string? secretId = null, + string? secretWindowsStoreCertificateThumbprint = null, SecureSocketOptions connectionSecurity = EmailSinkOptions.DefaultConnectionSecurity, ICredentialsByHost? credentials = null, string? subject = null, @@ -77,6 +91,25 @@ public static LoggerConfiguration Email( if (to == null) throw new ArgumentNullException(nameof(to)); if (host == null) throw new ArgumentNullException(nameof(host)); + if (smtpAuthenticationMode == SmtpAuthenticationMode.OAuth2) + { + if (string.IsNullOrWhiteSpace(oauthTokenUrl)) + throw new ArgumentNullException(nameof(oauthTokenUrl), "OAuthTokenUrl must be provided when using OAuth2 authentication."); + if (string.IsNullOrWhiteSpace(oauthScope)) + throw new ArgumentNullException(nameof(oauthScope), "OAuthScope must be provided when using OAuth2 authentication."); + if (string.IsNullOrWhiteSpace(oauthTokenUsername)) + throw new ArgumentNullException(nameof(oauthTokenUsername), "OAuthTokenUsername must be provided when using OAuth2 authentication."); + if (string.IsNullOrWhiteSpace(applicationId)) + throw new ArgumentNullException(nameof(applicationId), "ApplicationId must be provided when using OAuth2 authentication."); + if (string.IsNullOrWhiteSpace(secretId) && string.IsNullOrWhiteSpace(secretWindowsStoreCertificateThumbprint)) + throw new ArgumentException("Either SecretId or SecretWindowsStoreCertificateThumbprint must be provided when using OAuth2 authentication."); + } + + if (credentials != null) + { + smtpAuthenticationMode = SmtpAuthenticationMode.Basic; + } + var connectionInfo = new EmailSinkOptions { From = from, @@ -86,6 +119,13 @@ public static LoggerConfiguration Email( ConnectionSecurity = connectionSecurity, Credentials = credentials, IsBodyHtml = false, // `MessageTemplateTextFormatter` cannot emit valid HTML; the `EmailSinkOptions` overload must be used for this. + SmtpAuthenticationMode = smtpAuthenticationMode, + OAuthScope = oauthScope, + OAuthTokenUrl = oauthTokenUrl, + OAuthTokenUsername = oauthTokenUsername, + ApplicationId = applicationId, + SecretWindowsStoreCertificateThumbprint = secretWindowsStoreCertificateThumbprint, + SecretId = secretId, }; if (subject != null) diff --git a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs index 184ce0d..eea9848 100644 --- a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs +++ b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs @@ -32,7 +32,13 @@ public void Works() to: "to@localhost.local", host: "localhost", body: "[{Level}] {Message}{NewLine}{Exception}", - subject: "subject") + subject: "subject", + smtpAuthenticationMode: SmtpAuthenticationMode.OAuth2, + applicationId: "", + secretWindowsStoreCertificateThumbprint: "", + oauthTokenUsername: "", + oauthTokenUrl: "", + oauthScope: "") .CreateLogger()) { emailLogger.Information("test {test}", "test"); From d470d6a38aa7ce8a86bc1b1731c01b37c7cd85d5 Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Tue, 14 Oct 2025 16:26:03 +0200 Subject: [PATCH 03/13] added test --- README.md | 4 ++-- .../Serilog.Sinks.Email.csproj | 5 ++-- .../EmailSinkTests.cs | 24 ++++++++++++++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0db05bd..c116db5 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Sends log events by SMTP email. > ℹ️ Version 3.x of this package changes the name and structure of many configuration parameters from their 2.x names; see below for detailed information. -> ✅ Version 3.x now includes optional OAuth2 (Modern Authentication) support for SMTP using Azure AD / Office 365 or any OAuth2-compliant provider. See the new OAuth2 section below. +> ✅ Now includes optional OAuth2 (Modern Authentication) support for SMTP using Azure AD / Office 365 or any OAuth2-compliant provider. See the new OAuth2 section below. -**Package Id:** [Serilog.Sinks.Email](http://nuget.org/packages/serilog.sinks.email) +**Package Id:** [Serilog.Sinks.EmailOauth2](http://nuget.org/packages/serilog.sinks.emailoauth2) ```csharp await using var log = new LoggerConfiguration() diff --git a/src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj b/src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj index 424c26c..cff83c3 100644 --- a/src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj +++ b/src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj @@ -1,10 +1,10 @@ - Send Serilog events as SMTP email using MailKit. + Send Serilog events as SMTP email using MailKit and with OAuth2 support. Serilog Contributors net462;net471 $(TargetFrameworks);netstandard2.0;net6.0;net8.0;net9.0 - serilog;smtp;mailkit + serilog;smtp;mailkit;oauth2 serilog-sink-nuget.png https://serilog.net/ Apache-2.0 @@ -13,6 +13,7 @@ README.md Serilog true + Serilog.Sinks.EmailOauth2 diff --git a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs index eea9848..73a2fee 100644 --- a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs +++ b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs @@ -32,13 +32,35 @@ public void Works() to: "to@localhost.local", host: "localhost", body: "[{Level}] {Message}{NewLine}{Exception}", + subject: "subject") + .CreateLogger()) + { + emailLogger.Information("test {test}", "test"); + } + + Assert.Equal(Enumerable.Empty(), selfLogMessages); + } + + [Fact] + public void WorkOAuth2() + { + var selfLogMessages = new List(); + SelfLog.Enable(selfLogMessages.Add); + + using (var emailLogger = new LoggerConfiguration() + .WriteTo.Email( + from: "", + to: "", + host: "smtp.office365.com", + body: "[{Level}] {Message}{NewLine}{Exception}", subject: "subject", smtpAuthenticationMode: SmtpAuthenticationMode.OAuth2, applicationId: "", secretWindowsStoreCertificateThumbprint: "", oauthTokenUsername: "", oauthTokenUrl: "", - oauthScope: "") + oauthScope: "", + connectionSecurity: MailKit.Security.SecureSocketOptions.StartTls) .CreateLogger()) { emailLogger.Information("test {test}", "test"); From 9584ccb2638c58e2c11af4411845d1d942dd9b60 Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Tue, 14 Oct 2025 16:27:11 +0200 Subject: [PATCH 04/13] add cd workflow --- .github/workflows/cd.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/cd.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..98eb4d1 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,30 @@ +name: Publish NuGet Package + +on: + push: + branches: [ master ] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Verify API key + run: | + if [ -z "${{ secrets.NUGET_API_KEY }}" ]; then + echo "Missing NUGET_API_KEY secret" + exit 1 + fi + + - run: dotnet restore src\Serilog.Sinks.Email\Serilog.Sinks.Email.csproj + - run: dotnet build --configuration Release --no-restore src\Serilog.Sinks.Email\Serilog.Sinks.Email.csproj + - run: dotnet pack --configuration Release --no-build --output ./nupkg src\Serilog.Sinks.Email\Serilog.Sinks.Email.csproj + - name: Push + run: dotnet nuget push "./nupkg/*.nupkg" --api-key "${{ secrets.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate \ No newline at end of file From c346b987b60795842a7b0f2012de413cd312e53f Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Tue, 14 Oct 2025 16:32:18 +0200 Subject: [PATCH 05/13] skip fact --- test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs index 73a2fee..3a28f53 100644 --- a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs +++ b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs @@ -41,7 +41,7 @@ public void Works() Assert.Equal(Enumerable.Empty(), selfLogMessages); } - [Fact] + [Fact(Skip = "Requires to fill the prameters")] public void WorkOAuth2() { var selfLogMessages = new List(); From 7b89c83ed7e30567826a4faba29b42e2e85fbcc2 Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Tue, 14 Oct 2025 16:35:06 +0200 Subject: [PATCH 06/13] cd main --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 98eb4d1..30097fc 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -2,7 +2,7 @@ name: Publish NuGet Package on: push: - branches: [ master ] + branches: [ main ] workflow_dispatch: jobs: From 0916f644ec57925c828b16bcba439134c602f656 Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Thu, 16 Oct 2025 07:14:40 +0200 Subject: [PATCH 07/13] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c116db5..088c491 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Serilog.Sinks.Email [![Build status](https://github.com/serilog/serilog-sinks-email/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/serilog/serilog-sinks-email/actions) [![NuGet Version](http://img.shields.io/nuget/v/Serilog.Sinks.Email.svg?style=flat)](https://www.nuget.org/packages/Serilog.Sinks.Email/) +# Serilog.Sinks.EmailOauth2 [![Build status](https://github.com/serilog/serilog-sinks-email/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/serilog/serilog-sinks-email/actions) [![NuGet Version](http://img.shields.io/nuget/v/Serilog.Sinks.Email.svg?style=flat)](https://www.nuget.org/packages/Serilog.Sinks.Email/) Sends log events by SMTP email. From 51160bcbba9f2937c527c8d48794c6f7b55a2d69 Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Thu, 16 Oct 2025 07:20:43 +0200 Subject: [PATCH 08/13] try fix CD --- .github/workflows/cd.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 30097fc..132a0ff 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -23,8 +23,8 @@ jobs: exit 1 fi - - run: dotnet restore src\Serilog.Sinks.Email\Serilog.Sinks.Email.csproj - - run: dotnet build --configuration Release --no-restore src\Serilog.Sinks.Email\Serilog.Sinks.Email.csproj - - run: dotnet pack --configuration Release --no-build --output ./nupkg src\Serilog.Sinks.Email\Serilog.Sinks.Email.csproj + - run: dotnet restore src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj + - run: dotnet build --configuration Release --no-restore src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj + - run: dotnet pack --configuration Release --no-build --output ./nupkg src/Serilog.Sinks.Email/Serilog.Sinks.Email.csproj - name: Push - run: dotnet nuget push "./nupkg/*.nupkg" --api-key "${{ secrets.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate \ No newline at end of file + run: dotnet nuget push "./nupkg/*.nupkg" --api-key "${{ secrets.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate From 9156653bc0582d100ff0f0b51a26402f69dc65fe Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Thu, 16 Oct 2025 12:29:55 +0000 Subject: [PATCH 09/13] Update readme and add appsettings harness test --- README.md | 4 +-- .../EmailSinkTests.cs | 17 +++++----- test/TestHarness/Program.cs | 13 ++++++-- test/TestHarness/TestHarness.csproj | 13 ++++++++ test/TestHarness/appsettings.json | 33 +++++++++++++++++++ 5 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 test/TestHarness/appsettings.json diff --git a/README.md b/README.md index 088c491..3ce4176 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ If you configure Serilog via JSON: { "Name": "Email", "Args": { - "options": { + "Subject": "Subject", + "Body": "{Timestamp} [{Level}] {Message}{NewLine}{Exception}", "From": "app@contoso.com", "To": "support@contoso.com", "Host": "smtp.office365.com", @@ -209,7 +210,6 @@ If you configure Serilog via JSON: "OAuthTokenUrl": "https://login.microsoftonline.com//oauth2/v2.0/token", "OAuthScope": "https://outlook.office365.com/.default", "OAuthTokenUsername": "app@contoso.com" - } } } ] diff --git a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs index 3a28f53..e88f798 100644 --- a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs +++ b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs @@ -41,7 +41,7 @@ public void Works() Assert.Equal(Enumerable.Empty(), selfLogMessages); } - [Fact(Skip = "Requires to fill the prameters")] + [Fact] public void WorkOAuth2() { var selfLogMessages = new List(); @@ -49,17 +49,18 @@ public void WorkOAuth2() using (var emailLogger = new LoggerConfiguration() .WriteTo.Email( - from: "", - to: "", + from: "BraveGuava89@amatest.net", + to: "jrouzies@mantu.com", host: "smtp.office365.com", body: "[{Level}] {Message}{NewLine}{Exception}", subject: "subject", + port: 587, smtpAuthenticationMode: SmtpAuthenticationMode.OAuth2, - applicationId: "", - secretWindowsStoreCertificateThumbprint: "", - oauthTokenUsername: "", - oauthTokenUrl: "", - oauthScope: "", + applicationId: "7c3169da-ee78-4431-b163-fa9517f19708", + secretWindowsStoreCertificateThumbprint: "902125818B838F85A8DC817C09363C683AE39443", + oauthTokenUsername: "BraveGuava89@amatest.net", + oauthTokenUrl: "https://login.microsoftonline.com/d8597321-b69b-480b-b24c-f8bdcb11e023/oauth2/v2.0/token", + oauthScope: "https://outlook.office365.com/.default", connectionSecurity: MailKit.Security.SecureSocketOptions.StartTls) .CreateLogger()) { diff --git a/test/TestHarness/Program.cs b/test/TestHarness/Program.cs index c8cd5fe..932f512 100644 --- a/test/TestHarness/Program.cs +++ b/test/TestHarness/Program.cs @@ -1,10 +1,19 @@ -using Serilog; +using Microsoft.Extensions.Configuration; +using Serilog; using Serilog.Debugging; +using System.Reflection; SelfLog.Enable(Console.Error); +var exeLocation = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + +var configuration = new ConfigurationBuilder() + .SetBasePath(exeLocation!) + .AddJsonFile("appsettings.json") + //.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true) + .Build(); Log.Logger = new LoggerConfiguration() - .WriteTo.Email("from@localhost", "to@localhost", "localhost") + .ReadFrom.Configuration(configuration) .CreateLogger(); Log.Information("Hello, world!"); diff --git a/test/TestHarness/TestHarness.csproj b/test/TestHarness/TestHarness.csproj index f32e98d..94e7349 100644 --- a/test/TestHarness/TestHarness.csproj +++ b/test/TestHarness/TestHarness.csproj @@ -5,8 +5,21 @@ net9.0 + + + + + + + + + + Always + + + diff --git a/test/TestHarness/appsettings.json b/test/TestHarness/appsettings.json new file mode 100644 index 0000000..c6ba7f2 --- /dev/null +++ b/test/TestHarness/appsettings.json @@ -0,0 +1,33 @@ +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Email" ], + "MinimumLevel": { + "Default": "Verbose", + "Override": { + "Microsoft": "Error", + } + }, + "WriteTo": [ + { + "Name": "Email", + "Args": { + "subject": "", + "isBodyHtml": true, + "restrictedToMinimumLevel": "Information", + "body": "Test", + "from": "", + "to": "" , + "host": "", + "port": 587, + "connectionSecurity": "StartTls", + "smtpAuthenticationMode": "OAuth2", + "applicationId": "", + "secretWindowsStoreCertificateThumbprint": "", + "oAuthTokenUrl": "", + "oAuthScope": "", + "oAuthTokenUsername": "" + } + } + ] + } +} From 6598779120b31589354ed1d7fd74ace276663889 Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Thu, 16 Oct 2025 12:39:35 +0000 Subject: [PATCH 10/13] Fix isBodyHtml pass --- .../LoggerConfigurationEmailExtensions.cs | 4 +++- test/TestHarness/appsettings.json | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Serilog.Sinks.Email/LoggerConfigurationEmailExtensions.cs b/src/Serilog.Sinks.Email/LoggerConfigurationEmailExtensions.cs index d9814ff..d2448e4 100644 --- a/src/Serilog.Sinks.Email/LoggerConfigurationEmailExtensions.cs +++ b/src/Serilog.Sinks.Email/LoggerConfigurationEmailExtensions.cs @@ -45,6 +45,7 @@ public static class LoggerConfigurationEmailExtensions /// is supplied by MailKit; see for supported values. The default is /// . /// The network credentials to use to authenticate with mailServer + /// Define if body is HTML /// A message template describing the format used to write to the sink. /// the default is "{Timestamp} [{Level}] {Message}{NewLine}{Exception}". /// Supplies culture-specific formatting information, or null. @@ -71,6 +72,7 @@ public static LoggerConfiguration Email( string to, string host, int port = EmailSinkOptions.DefaultPort, + bool isBodyHtml = false, SmtpAuthenticationMode smtpAuthenticationMode = SmtpAuthenticationMode.None, string? oauthTokenUrl = null, string? oauthScope = null, @@ -118,7 +120,7 @@ public static LoggerConfiguration Email( Port = port, ConnectionSecurity = connectionSecurity, Credentials = credentials, - IsBodyHtml = false, // `MessageTemplateTextFormatter` cannot emit valid HTML; the `EmailSinkOptions` overload must be used for this. + IsBodyHtml = isBodyHtml, SmtpAuthenticationMode = smtpAuthenticationMode, OAuthScope = oauthScope, OAuthTokenUrl = oauthTokenUrl, diff --git a/test/TestHarness/appsettings.json b/test/TestHarness/appsettings.json index c6ba7f2..51b2a6f 100644 --- a/test/TestHarness/appsettings.json +++ b/test/TestHarness/appsettings.json @@ -11,13 +11,13 @@ { "Name": "Email", "Args": { - "subject": "", + "subject": "[{Leve}] Subject", "isBodyHtml": true, "restrictedToMinimumLevel": "Information", - "body": "Test", + "body": "bold not", "from": "", "to": "" , - "host": "", + "host": "smtp.office365.com", "port": 587, "connectionSecurity": "StartTls", "smtpAuthenticationMode": "OAuth2", From 7b21ca4561ff514d5540ddae2586e9855c3636dc Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Thu, 16 Oct 2025 12:44:19 +0000 Subject: [PATCH 11/13] version bump --- Directory.Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Version.props b/Directory.Version.props index 2d2c7c1..cc3f622 100644 --- a/Directory.Version.props +++ b/Directory.Version.props @@ -1,5 +1,5 @@ - 4.1.1 + 4.1.2 From 9a050a548623179d2ade41bc05e166d9e666cbfc Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Thu, 16 Oct 2025 12:48:53 +0000 Subject: [PATCH 12/13] clear sink test --- test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs index e88f798..c039e85 100644 --- a/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs +++ b/test/Serilog.Sinks.Email.Tests/EmailSinkTests.cs @@ -41,7 +41,7 @@ public void Works() Assert.Equal(Enumerable.Empty(), selfLogMessages); } - [Fact] + [Fact(Skip = "Requires parameters fill")] public void WorkOAuth2() { var selfLogMessages = new List(); @@ -49,18 +49,18 @@ public void WorkOAuth2() using (var emailLogger = new LoggerConfiguration() .WriteTo.Email( - from: "BraveGuava89@amatest.net", - to: "jrouzies@mantu.com", + from: "", + to: "", host: "smtp.office365.com", body: "[{Level}] {Message}{NewLine}{Exception}", subject: "subject", port: 587, smtpAuthenticationMode: SmtpAuthenticationMode.OAuth2, - applicationId: "7c3169da-ee78-4431-b163-fa9517f19708", - secretWindowsStoreCertificateThumbprint: "902125818B838F85A8DC817C09363C683AE39443", - oauthTokenUsername: "BraveGuava89@amatest.net", - oauthTokenUrl: "https://login.microsoftonline.com/d8597321-b69b-480b-b24c-f8bdcb11e023/oauth2/v2.0/token", - oauthScope: "https://outlook.office365.com/.default", + applicationId: "", + secretWindowsStoreCertificateThumbprint: "", + oauthTokenUsername: "", + oauthTokenUrl: "", + oauthScope: "", connectionSecurity: MailKit.Security.SecureSocketOptions.StartTls) .CreateLogger()) { From 231cb0f133dcdf65ff94aad85359c61c3c472384 Mon Sep 17 00:00:00 2001 From: ROUZIES Jean-Laurent Date: Thu, 16 Oct 2025 12:50:59 +0000 Subject: [PATCH 13/13] remove publish from CI --- Build.ps1 | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/Build.ps1 b/Build.ps1 index e798284..2860270 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -56,24 +56,7 @@ try { Pop-Location } - - if ($env:NUGET_API_KEY) { - # GitHub Actions will only supply this to branch builds and not PRs. We publish - # builds from any branch this action targets (i.e. main and dev). - - Write-Output "build: Publishing NuGet packages" - - foreach ($nupkg in Get-ChildItem artifacts/*.nupkg) { - & dotnet nuget push -k $env:NUGET_API_KEY -s https://api.nuget.org/v3/index.json "$nupkg" - if($LASTEXITCODE -ne 0) { throw "Publishing failed" } - } - - if (!($suffix)) { - Write-Output "build: Creating release for version $versionPrefix" - - iex "gh release create v$versionPrefix --title v$versionPrefix --generate-notes $(get-item ./artifacts/*.nupkg) $(get-item ./artifacts/*.snupkg)" - } - } + } finally { Pop-Location }