From 559101d7e21c0ef44a3f147a8935b49f0d62e948 Mon Sep 17 00:00:00 2001
From: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Date: Thu, 3 Apr 2025 12:59:19 -0400
Subject: [PATCH] Add SMTP Mail Tests (#5597)
* Add SMTP Mail Tests
Co-authored-by: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com>
* Update test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs
* Add Skipped Tests for upcoming feature
* Safer TCS Completion
---------
Co-authored-by: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com>
---
bitwarden-server.sln | 7 +
.../MailKitSmtpMailDeliveryService.cs | 29 +-
.../Core.IntegrationTest.csproj | 29 ++
.../MailKitSmtpMailDeliveryServiceTests.cs | 410 ++++++++++++++++++
4 files changed, 468 insertions(+), 7 deletions(-)
create mode 100644 test/Core.IntegrationTest/Core.IntegrationTest.csproj
create mode 100644 test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs
diff --git a/bitwarden-server.sln b/bitwarden-server.sln
index e9aff53f8e..892d2f4255 100644
--- a/bitwarden-server.sln
+++ b/bitwarden-server.sln
@@ -127,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -319,6 +321,10 @@ Global
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -370,6 +376,7 @@ Global
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
+ {3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
diff --git a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs
index 4e7b7ee105..dc4e89aa23 100644
--- a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs
+++ b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs
@@ -34,6 +34,9 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
}
public async Task SendEmailAsync(Models.Mail.MailMessage message)
+ => await SendEmailAsync(message, CancellationToken.None);
+
+ public async Task SendEmailAsync(Models.Mail.MailMessage message, CancellationToken cancellationToken)
{
var mimeMessage = new MimeMessage();
mimeMessage.From.Add(new MailboxAddress(_globalSettings.SiteName, _replyEmail));
@@ -76,25 +79,37 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl &&
_globalSettings.Mail.Smtp.Port == 25)
{
- await client.ConnectAsync(_globalSettings.Mail.Smtp.Host, _globalSettings.Mail.Smtp.Port,
- MailKit.Security.SecureSocketOptions.None);
+ await client.ConnectAsync(
+ _globalSettings.Mail.Smtp.Host,
+ _globalSettings.Mail.Smtp.Port,
+ MailKit.Security.SecureSocketOptions.None,
+ cancellationToken
+ );
}
else
{
var useSsl = _globalSettings.Mail.Smtp.Port == 587 && !_globalSettings.Mail.Smtp.SslOverride ?
false : _globalSettings.Mail.Smtp.Ssl;
- await client.ConnectAsync(_globalSettings.Mail.Smtp.Host, _globalSettings.Mail.Smtp.Port, useSsl);
+ await client.ConnectAsync(
+ _globalSettings.Mail.Smtp.Host,
+ _globalSettings.Mail.Smtp.Port,
+ useSsl,
+ cancellationToken
+ );
}
if (CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Username) &&
CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Password))
{
- await client.AuthenticateAsync(_globalSettings.Mail.Smtp.Username,
- _globalSettings.Mail.Smtp.Password);
+ await client.AuthenticateAsync(
+ _globalSettings.Mail.Smtp.Username,
+ _globalSettings.Mail.Smtp.Password,
+ cancellationToken
+ );
}
- await client.SendAsync(mimeMessage);
- await client.DisconnectAsync(true);
+ await client.SendAsync(mimeMessage, cancellationToken);
+ await client.DisconnectAsync(true, cancellationToken);
}
}
}
diff --git a/test/Core.IntegrationTest/Core.IntegrationTest.csproj b/test/Core.IntegrationTest/Core.IntegrationTest.csproj
new file mode 100644
index 0000000000..6094209f23
--- /dev/null
+++ b/test/Core.IntegrationTest/Core.IntegrationTest.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs
new file mode 100644
index 0000000000..db2b945fda
--- /dev/null
+++ b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs
@@ -0,0 +1,410 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Bit.Core.Models.Mail;
+using Bit.Core.Services;
+using Bit.Core.Settings;
+using MailKit.Security;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Rnwood.SmtpServer;
+using Rnwood.SmtpServer.Extensions.Auth;
+using Xunit.Abstractions;
+
+namespace Bit.Core.IntegrationTest;
+
+public class MailKitSmtpMailDeliveryServiceTests
+{
+ private readonly X509Certificate2 _selfSignedCert;
+
+ public MailKitSmtpMailDeliveryServiceTests(ITestOutputHelper testOutputHelper)
+ {
+ ConfigureSmtpServerLogging(testOutputHelper);
+
+ _selfSignedCert = CreateSelfSignedCert("localhost");
+ }
+
+ private static X509Certificate2 CreateSelfSignedCert(string commonName)
+ {
+ using var rsa = RSA.Create(2048);
+ var certRequest = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ return certRequest.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
+ }
+
+ private static async Task SaveCertAsync(string filePath, X509Certificate2 certificate)
+ {
+ await File.WriteAllBytesAsync(filePath, certificate.Export(X509ContentType.Cert));
+ }
+
+ private static void ConfigureSmtpServerLogging(ITestOutputHelper testOutputHelper)
+ {
+ // Unfortunately this package doesn't public expose its logging infrastructure
+ // so we use private reflection to try and access it.
+ try
+ {
+ var loggingType = typeof(DefaultServerBehaviour).Assembly.GetType("Rnwood.SmtpServer.Logging")
+ ?? throw new Exception("No type found in RnWood.SmtpServer named 'Logging'");
+
+ var factoryProperty = loggingType.GetProperty("Factory")
+ ?? throw new Exception($"No property named 'Factory' found on class {loggingType.FullName}");
+
+ var factoryPropertyGet = factoryProperty.GetMethod
+ ?? throw new Exception($"{loggingType.FullName}.{factoryProperty.Name} does not have a get method.");
+
+ if (factoryPropertyGet.Invoke(null, null) is not ILoggerFactory loggerFactory)
+ {
+ throw new Exception($"{loggingType.FullName}.{factoryProperty.Name} is not of type 'ILoggerFactory'" +
+ $"instead it's type '{factoryProperty.PropertyType.FullName}'");
+ }
+
+ loggerFactory.AddXUnit(testOutputHelper);
+ }
+ catch (Exception ex)
+ {
+ testOutputHelper.WriteLine($"Failed to configure logging for RnWood.SmtpServer (logging will not be configured):\n{ex.Message}");
+ }
+ }
+ private static int RandomPort()
+ {
+ return Random.Shared.Next(50000, 60000);
+ }
+
+ private static GlobalSettings GetSettings(Action configure)
+ {
+ var globalSettings = new GlobalSettings();
+ globalSettings.SiteName = "TestSiteName";
+ globalSettings.Mail.ReplyToEmail = "test@example.com";
+ globalSettings.Mail.Smtp.Host = "localhost";
+ // Set common defaults
+ configure(globalSettings);
+ return globalSettings;
+ }
+
+ [Fact]
+ public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertNotInTrustedRootStore_ThrowsException()
+ {
+ // If an SMTP server is using a self signed cert we currently require
+ // that the certificate for their SMTP server is installed in the root CA
+ // we are building the ability to do so without installing it, when we add that
+ // this test can be copied, and changed to utilize that new feature and instead of
+ // failing it should successfully send the email.
+ var port = RandomPort();
+ var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
+ using var smtpServer = new SmtpServer(behavior);
+ smtpServer.Start();
+
+ var globalSettings = GetSettings(gs =>
+ {
+ gs.Mail.Smtp.Port = port;
+ gs.Mail.Smtp.Ssl = true;
+ });
+
+ var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
+ globalSettings,
+ NullLogger.Instance
+ );
+
+ await Assert.ThrowsAsync(
+ async () => await mailKitDeliveryService.SendEmailAsync(new MailMessage
+ {
+ Subject = "Test",
+ ToEmails = ["test@example.com"],
+ TextContent = "Hi",
+ }, new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token)
+ );
+ }
+
+ [Fact(Skip = "Upcoming feature")]
+ public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_Works()
+ {
+ // If an SMTP server is using a self signed cert we will in the future
+ // allow a custom location for certificates to be stored and the certitifactes
+ // stored there will also be trusted.
+ var port = RandomPort();
+ var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
+ using var smtpServer = new SmtpServer(behavior);
+ smtpServer.Start();
+
+ var globalSettings = GetSettings(gs =>
+ {
+ gs.Mail.Smtp.Port = port;
+ gs.Mail.Smtp.Ssl = true;
+ });
+
+ // TODO: Setup custom location and save self signed cert there.
+ // await SaveCertAsync("./my-location", _selfSignedCert);
+
+ var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
+ globalSettings,
+ NullLogger.Instance
+ );
+
+ var tcs = new TaskCompletionSource();
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ cts.Token.Register(() => _ = tcs.TrySetCanceled());
+
+ behavior.MessageReceivedEventHandler += (sender, args) =>
+ {
+ if (args.Message.Recipients.Contains("test1@example.com"))
+ {
+ tcs.SetResult();
+ }
+ return Task.CompletedTask;
+ };
+
+ await mailKitDeliveryService.SendEmailAsync(new MailMessage
+ {
+ Subject = "Test",
+ ToEmails = ["test1@example.com"],
+ TextContent = "Hi",
+ }, cts.Token);
+
+ // Wait for email
+ await tcs.Task;
+ }
+
+ [Fact(Skip = "Upcoming feature")]
+ public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_WithUnrelatedCerts_Works()
+ {
+ // If an SMTP server is using a self signed cert we will in the future
+ // allow a custom location for certificates to be stored and the certitifactes
+ // stored there will also be trusted.
+ var port = RandomPort();
+ var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
+ using var smtpServer = new SmtpServer(behavior);
+ smtpServer.Start();
+
+ var globalSettings = GetSettings(gs =>
+ {
+ gs.Mail.Smtp.Port = port;
+ gs.Mail.Smtp.Ssl = true;
+ });
+
+ // TODO: Setup custom location and save self signed cert there
+ // along with another self signed cert that is not related to
+ // the SMTP server.
+ // await SaveCertAsync("./my-location", _selfSignedCert);
+ // await SaveCertAsync("./my-location", CreateSelfSignedCert("example.com"));
+
+ var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
+ globalSettings,
+ NullLogger.Instance
+ );
+
+ var tcs = new TaskCompletionSource();
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ cts.Token.Register(() => _ = tcs.TrySetCanceled());
+
+ behavior.MessageReceivedEventHandler += (sender, args) =>
+ {
+ if (args.Message.Recipients.Contains("test1@example.com"))
+ {
+ tcs.SetResult();
+ }
+ return Task.CompletedTask;
+ };
+
+ await mailKitDeliveryService.SendEmailAsync(new MailMessage
+ {
+ Subject = "Test",
+ ToEmails = ["test1@example.com"],
+ TextContent = "Hi",
+ }, cts.Token);
+
+ // Wait for email
+ await tcs.Task;
+ }
+
+ [Fact]
+ public async Task SendEmailAsync_Succeeds_WhenCertIsSelfSigned_ServerIsTrusted()
+ {
+ // When the setting `TrustServer = true` is set even if the cert is
+ // self signed and the cert is not trusted in anyway the connection should
+ // still go through.
+ var port = RandomPort();
+ var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
+ using var smtpServer = new SmtpServer(behavior);
+ smtpServer.Start();
+
+ var globalSettings = GetSettings(gs =>
+ {
+ gs.Mail.Smtp.Port = port;
+ gs.Mail.Smtp.Ssl = true;
+ gs.Mail.Smtp.TrustServer = true;
+ });
+
+ var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
+ globalSettings,
+ NullLogger.Instance
+ );
+
+ var tcs = new TaskCompletionSource();
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ cts.Token.Register(() => _ = tcs.TrySetCanceled());
+
+ behavior.MessageReceivedEventHandler += (sender, args) =>
+ {
+ if (args.Message.Recipients.Contains("test1@example.com"))
+ {
+ tcs.SetResult();
+ }
+ return Task.CompletedTask;
+ };
+
+ await mailKitDeliveryService.SendEmailAsync(new MailMessage
+ {
+ Subject = "Test",
+ ToEmails = ["test1@example.com"],
+ TextContent = "Hi",
+ }, cts.Token);
+
+ // Wait for email
+ await tcs.Task;
+ }
+
+ [Fact]
+ public async Task SendEmailAsync_FailsConnectingWithTls_ServerDoesNotSupportTls()
+ {
+ // If the SMTP server is not setup to use TLS but our server is expecting it
+ // to, we should fail.
+ var port = RandomPort();
+ var behavior = new DefaultServerBehaviour(false, port);
+ using var smtpServer = new SmtpServer(behavior);
+ smtpServer.Start();
+
+ var globalSettings = GetSettings(gs =>
+ {
+ gs.Mail.Smtp.Port = port;
+ gs.Mail.Smtp.Ssl = true;
+ gs.Mail.Smtp.TrustServer = true;
+ });
+
+ var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
+ globalSettings,
+ NullLogger.Instance
+ );
+
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+
+ await Assert.ThrowsAsync(
+ async () => await mailKitDeliveryService.SendEmailAsync(new MailMessage
+ {
+ Subject = "Test",
+ ToEmails = ["test1@example.com"],
+ TextContent = "Hi",
+ }, cts.Token)
+ );
+ }
+
+ [Fact(Skip = "Requires permission to privileged port")]
+ public async Task SendEmailAsync_Works_NoSsl()
+ {
+ // If the SMTP server isn't set up with any SSL/TLS and we dont' expect
+ // any, then the email should go through just fine. Just without encryption.
+ // This test has to use port 25
+ var port = 25;
+ var behavior = new DefaultServerBehaviour(false, port);
+ using var smtpServer = new SmtpServer(behavior);
+ smtpServer.Start();
+
+ var globalSettings = GetSettings(gs =>
+ {
+ gs.Mail.Smtp.Port = port;
+ gs.Mail.Smtp.Ssl = false;
+ gs.Mail.Smtp.StartTls = false;
+ });
+
+ var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
+ globalSettings,
+ NullLogger.Instance
+ );
+
+ var tcs = new TaskCompletionSource();
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ cts.Token.Register(() => _ = tcs.TrySetCanceled());
+
+ behavior.MessageReceivedEventHandler += (sender, args) =>
+ {
+ if (args.Message.Recipients.Contains("test1@example.com"))
+ {
+ tcs.SetResult();
+ }
+ return Task.CompletedTask;
+ };
+
+ await mailKitDeliveryService.SendEmailAsync(new MailMessage
+ {
+ Subject = "Test",
+ ToEmails = ["test1@example.com"],
+ TextContent = "Hi",
+ }, cts.Token);
+
+ // Wait for email
+ await tcs.Task;
+ }
+
+ [Fact]
+ public async Task SendEmailAsync_Succeeds_WhenServerNeedsToAuthenticate()
+ {
+ // When the setting `TrustServer = true` is set even if the cert is
+ // self signed and the cert is not trusted in anyway the connection should
+ // still go through.
+ var port = RandomPort();
+ var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert);
+ behavior.AuthenticationCredentialsValidationRequiredEventHandler += (sender, args) =>
+ {
+ args.AuthenticationResult = AuthenticationResult.Failure;
+ if (args.Credentials is not UsernameAndPasswordAuthenticationCredentials usernameAndPasswordCreds)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (usernameAndPasswordCreds.Username != "test" || usernameAndPasswordCreds.Password != "password")
+ {
+ return Task.CompletedTask;
+ }
+
+ args.AuthenticationResult = AuthenticationResult.Success;
+ return Task.CompletedTask;
+ };
+ using var smtpServer = new SmtpServer(behavior);
+ smtpServer.Start();
+
+ var globalSettings = GetSettings(gs =>
+ {
+ gs.Mail.Smtp.Port = port;
+ gs.Mail.Smtp.Ssl = true;
+ gs.Mail.Smtp.TrustServer = true;
+
+ gs.Mail.Smtp.Username = "test";
+ gs.Mail.Smtp.Password = "password";
+ });
+
+ var mailKitDeliveryService = new MailKitSmtpMailDeliveryService(
+ globalSettings,
+ NullLogger.Instance
+ );
+
+ var tcs = new TaskCompletionSource();
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ cts.Token.Register(() => _ = tcs.TrySetCanceled());
+
+ behavior.MessageReceivedEventHandler += (sender, args) =>
+ {
+ if (args.Message.Recipients.Contains("test1@example.com"))
+ {
+ tcs.SetResult();
+ }
+ return Task.CompletedTask;
+ };
+
+ await mailKitDeliveryService.SendEmailAsync(new MailMessage
+ {
+ Subject = "Test",
+ ToEmails = ["test1@example.com"],
+ TextContent = "Hi",
+ }, cts.Token);
+
+ // Wait for email
+ await tcs.Task;
+ }
+}