From 014064376c28de0594d7a02db0bfaffbce5b56a2 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 19 Jul 2024 11:08:42 +0200 Subject: [PATCH 1/6] Add migrate on SmtpServer startup (#111) --- src/Services/AliasVault.SmtpService/Program.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Services/AliasVault.SmtpService/Program.cs b/src/Services/AliasVault.SmtpService/Program.cs index b12d71ce5..925f465f2 100644 --- a/src/Services/AliasVault.SmtpService/Program.cs +++ b/src/Services/AliasVault.SmtpService/Program.cs @@ -120,6 +120,14 @@ builder.Services.AddSingleton( ); builder.Services.AddHostedService(); - var host = builder.Build(); + +using (var scope = host.Services.CreateScope()) +{ + var container = scope.ServiceProvider; + var db = container.GetRequiredService(); + + await db.Database.MigrateAsync(); +} + await host.RunAsync(); From 533362210b88a3a76dd743c5700c8e24a4d4b183 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 19 Jul 2024 14:42:43 +0200 Subject: [PATCH 2/6] Add IntegrationTests project (#111) --- aliasvault.sln | 7 ++ .../AliasVault.IntegrationTests.csproj | 35 +++++++ .../SmtpServer/SmtpServerTests.cs | 99 +++++++++++++++++++ .../SmtpServer/TestHostBuilder.cs | 86 ++++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 src/Tests/AliasVault.IntegrationTests/AliasVault.IntegrationTests.csproj create mode 100644 src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs create mode 100644 src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs diff --git a/aliasvault.sln b/aliasvault.sln index 067939259..1f437f45c 100644 --- a/aliasvault.sln +++ b/aliasvault.sln @@ -39,6 +39,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{8A EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.SmtpService", "src\Services\AliasVault.SmtpService\AliasVault.SmtpService.csproj", "{B095A174-E528-4D38-BEC1-D1D38B3B30C0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.IntegrationTests", "src\Tests\AliasVault.IntegrationTests\AliasVault.IntegrationTests.csproj", "{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -97,6 +99,10 @@ Global {B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Debug|Any CPU.Build.0 = Debug|Any CPU {B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Release|Any CPU.ActiveCfg = Release|Any CPU {B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Release|Any CPU.Build.0 = Release|Any CPU + {1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -112,6 +118,7 @@ Global {607945F3-9896-4544-99EC-F3496CF4D36B} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB} {A9C9A606-C87E-4298-AB32-09B1884D7487} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8} {B095A174-E528-4D38-BEC1-D1D38B3B30C0} = {8A477241-B96C-4174-968D-D40CB77F1ECD} + {1C7C8DE9-5F2A-43DB-A25E-33319E80A509} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E} diff --git a/src/Tests/AliasVault.IntegrationTests/AliasVault.IntegrationTests.csproj b/src/Tests/AliasVault.IntegrationTests/AliasVault.IntegrationTests.csproj new file mode 100644 index 000000000..3c5af2523 --- /dev/null +++ b/src/Tests/AliasVault.IntegrationTests/AliasVault.IntegrationTests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + stylecop.json + + + + + + + + diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs new file mode 100644 index 000000000..7ffb27837 --- /dev/null +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -0,0 +1,99 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.IntegrationTests.SmtpServer; + +using AliasServerDb; + +using MailKit.Security; +using MailKit.Net.Smtp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MimeKit; + +[TestFixture] +public class SmtpServerTests +{ + private IHost _testHost; + + [SetUp] + public async Task Setup() + { + _testHost = new TestHostBuilder().Build(services => + { + // Here you can override services or add mocks as needed + }); + + await _testHost.StartAsync(); + } + + [TearDown] + public async Task TearDown() + { + if (_testHost != null) + { + await _testHost.StopAsync(); + _testHost.Dispose(); + } + } + + [Test] + public async Task TestWorkerProcessesEmails() + { + // Arrange + // Simulate sending an email to your SMTP server + // You might need to implement a method to do this in your test SMTP server + // Arrange + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "recipient.to@example.tld")); + message.Cc.Add(new MailboxAddress("Test Recipient 2", "recipient.cc@example.tld")); + message.Subject = "Test Email with multiple recipients."; + message.Body = new TextPart("plain") + { + Text = "This is a test email." + }; + + using var client = new SmtpClient(); + + // Send message to SMTP server + await client.ConnectAsync("localhost", 25, SecureSocketOptions.None); + try + { + await client.SendAsync(message); + } + catch (Exception ex) + { + throw; + // Show failure message indicating that message was not sent: SMTP server issue? + Assert.Fail($"Failed to send email, check SMTP server receive logs: {ex}"); + } + finally + { + await client.DisconnectAsync(true); + } + + // Act + // Wait for the worker to process the email + await Task.Delay(1000); // Adjust as needed + + // Assert + using (var scope = _testHost.Services.CreateScope()) + { + var dbContextFactory = scope.ServiceProvider.GetRequiredService>(); + var dbContext = await dbContextFactory.CreateDbContextAsync(); + + // Check the database for the expected results + var processedEmail = await dbContext.Emails.FirstOrDefaultAsync(e => e.Subject == "Test Email"); + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.To, Is.EqualTo("test@test.com")); + // Add more assertions as needed + } + + } +} diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs new file mode 100644 index 000000000..49687f57b --- /dev/null +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs @@ -0,0 +1,86 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +// ----------------------------------------------------------------------- + +namespace AliasVault.IntegrationTests.SmtpServer; + +using System.Data.Common; +using AliasServerDb; +using AliasVault.SmtpService; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using global::SmtpServer; +using global::SmtpServer.Storage; + +public class TestHostBuilder +{ + public IHost Build(Action configureServices = null) + { + var builder = Host.CreateDefaultBuilder() + .ConfigureServices((context, services) => + { + // Add your services here, similar to your Program.cs + services.AddSingleton(new Config + { + AllowedToDomains = new List { "example.tld" }, + SmtpTlsEnabled = "false" + }); + + services.AddSingleton(sp => + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + return connection; + }); + + services.AddDbContextFactory((sp, options) => + { + var connection = sp.GetRequiredService(); + options.UseSqlite(connection).UseLazyLoadingProxies(); + }); + + services.AddTransient(); + services.AddTransient(); + + services.AddSingleton( + provider => + { + var options = new SmtpServerOptionsBuilder() + .ServerName("aliasvault"); + + // No TLS + options.Endpoint(serverBuilder => + serverBuilder + .Port(25, false)) + .Endpoint(serverBuilder => + serverBuilder + .Port(587, false) + ); + + return new SmtpServer(options.Build(), provider.GetRequiredService()); + } + ); + + services.AddHostedService(); + + // Ensure the in-memory database is populated with tables + var serviceProvider = services.BuildServiceProvider(); + using (var scope = serviceProvider.CreateScope()) + { + var dbContextFactory = scope.ServiceProvider.GetRequiredService>(); + var dbContext = dbContextFactory.CreateDbContext(); + dbContext.Database.Migrate(); + } + + // Allow additional service configuration from the test + configureServices.Invoke(services); + }); + + return builder.Build(); + } +} From 0e4d0b0f8474bc8e30fa3390cdd413beb74cbc1b Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 19 Jul 2024 15:06:44 +0200 Subject: [PATCH 3/6] Make basic SMTP service integration test work (#111) --- .../AliasVault.SmtpService/Program.cs | 2 +- .../WebApplicationApiFactoryFixture.cs | 10 ++-- .../SmtpServer/SmtpServerTests.cs | 30 ++++------- .../SmtpServer/TestHostBuilder.cs | 51 ++++++++++++++----- 4 files changed, 54 insertions(+), 39 deletions(-) diff --git a/src/Services/AliasVault.SmtpService/Program.cs b/src/Services/AliasVault.SmtpService/Program.cs index 925f465f2..1198f726f 100644 --- a/src/Services/AliasVault.SmtpService/Program.cs +++ b/src/Services/AliasVault.SmtpService/Program.cs @@ -43,7 +43,7 @@ builder.Services.AddSingleton(container => builder.Services.AddDbContextFactory((container, options) => { var connection = container.GetRequiredService(); - options.UseSqlite(connection).UseLazyLoadingProxies(); + options.UseSqlite(connection); }); builder.Services.AddTransient(); diff --git a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs index 0433173f1..9c4b4f297 100644 --- a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs +++ b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs @@ -24,16 +24,16 @@ using Microsoft.Extensions.Hosting; public class WebApplicationApiFactoryFixture : WebApplicationFactory where TEntryPoint : class { - /// - /// The DbContext instance that is created for the test. - /// - private AliasServerDbContext? _dbContext; - /// /// The DbConnection instance that is created for the test. /// private DbConnection? _dbConnection; + /// + /// The DbContext instance that is created for the test. + /// + private AliasServerDbContext? _dbContext; + /// /// Gets or sets the URL the web application host will listen on. /// diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index 7ffb27837..1e343a709 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -21,13 +21,13 @@ public class SmtpServerTests { private IHost _testHost; + private TestHostBuilder _testHostBuilder; + [SetUp] public async Task Setup() { - _testHost = new TestHostBuilder().Build(services => - { - // Here you can override services or add mocks as needed - }); + _testHostBuilder = new TestHostBuilder(); + _testHost = _testHostBuilder.Build(); await _testHost.StartAsync(); } @@ -53,7 +53,7 @@ public class SmtpServerTests message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); message.To.Add(new MailboxAddress("Test Recipient", "recipient.to@example.tld")); message.Cc.Add(new MailboxAddress("Test Recipient 2", "recipient.cc@example.tld")); - message.Subject = "Test Email with multiple recipients."; + message.Subject = "Test Email"; message.Body = new TextPart("plain") { Text = "This is a test email." @@ -78,22 +78,10 @@ public class SmtpServerTests await client.DisconnectAsync(true); } - // Act - // Wait for the worker to process the email - await Task.Delay(1000); // Adjust as needed - - // Assert - using (var scope = _testHost.Services.CreateScope()) - { - var dbContextFactory = scope.ServiceProvider.GetRequiredService>(); - var dbContext = await dbContextFactory.CreateDbContextAsync(); - - // Check the database for the expected results - var processedEmail = await dbContext.Emails.FirstOrDefaultAsync(e => e.Subject == "Test Email"); - Assert.That(processedEmail, Is.Not.Null); - Assert.That(processedEmail.To, Is.EqualTo("test@test.com")); - // Add more assertions as needed - } + var dbContext = _testHostBuilder.GetDbContext(); + var processedEmail = await dbContext.Emails.FirstOrDefaultAsync(e => e.Subject == "Test Email"); + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.To, Is.EqualTo("recipient.to@example.tld")); } } diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs index 49687f57b..e5a97c353 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs @@ -19,29 +19,59 @@ using global::SmtpServer.Storage; public class TestHostBuilder { - public IHost Build(Action configureServices = null) + /// + /// The DbConnection instance that is created for the test. + /// + private DbConnection? _dbConnection; + + /// + /// The DbContext instance that is created for the test. + /// + private AliasServerDbContext? _dbContext; + + /// + /// Returns the DbContext instance for the test. This can be used to seed the database with test data. + /// + /// AliasServerDbContext instance. + public AliasServerDbContext GetDbContext() { + if (_dbContext == null) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_dbConnection!) + .Options; + + _dbContext = new AliasServerDbContext(options); + } + + return _dbContext; + } + + /// + /// Builds the SmtpService test host. + /// + /// + public IHost Build() + { + // Create a persistent in-memory database for the duration of the test. + _dbConnection = new SqliteConnection("DataSource=:memory:"); + _dbConnection.Open(); + var builder = Host.CreateDefaultBuilder() .ConfigureServices((context, services) => { - // Add your services here, similar to your Program.cs services.AddSingleton(new Config { AllowedToDomains = new List { "example.tld" }, SmtpTlsEnabled = "false" }); - services.AddSingleton(sp => - { - var connection = new SqliteConnection("DataSource=:memory:"); - connection.Open(); - return connection; - }); + services.AddSingleton(_dbConnection); services.AddDbContextFactory((sp, options) => { var connection = sp.GetRequiredService(); - options.UseSqlite(connection).UseLazyLoadingProxies(); + options.UseSqlite(connection); }); services.AddTransient(); @@ -76,9 +106,6 @@ public class TestHostBuilder var dbContext = dbContextFactory.CreateDbContext(); dbContext.Database.Migrate(); } - - // Allow additional service configuration from the test - configureServices.Invoke(services); }); return builder.Build(); From 2e6d5c87bcd41b8a0c6a4745470edcd34d159907 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 19 Jul 2024 16:31:39 +0200 Subject: [PATCH 4/6] Add SMTP service integration tests (#111) --- .github/workflows/dotnet-e2e-tests.yml | 29 ++++ .../workflows/dotnet-integration-tests.yml | 8 +- ...ld-run-tests.yml => dotnet-unit-tests.yml} | 8 +- .../AllowedDomainsFilter.cs | 45 ------ .../{ => Handlers}/DatabaseMessageStore.cs | 27 ++-- .../AliasVault.SmtpService/Program.cs | 6 +- .../SmtpServer/SmtpServerTests.cs | 150 +++++++++++++++--- .../SmtpServer/TestHostBuilder.cs | 6 +- 8 files changed, 181 insertions(+), 98 deletions(-) create mode 100644 .github/workflows/dotnet-e2e-tests.yml rename .github/workflows/{dotnet-build-run-tests.yml => dotnet-unit-tests.yml} (92%) delete mode 100644 src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs rename src/Services/AliasVault.SmtpService/{ => Handlers}/DatabaseMessageStore.cs (94%) diff --git a/.github/workflows/dotnet-e2e-tests.yml b/.github/workflows/dotnet-e2e-tests.yml new file mode 100644 index 000000000..0bf12ce37 --- /dev/null +++ b/.github/workflows/dotnet-e2e-tests.yml @@ -0,0 +1,29 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET E2E Tests (Playwright) + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Install dependencies + run: dotnet workload install wasm-tools + - name: Build + run: dotnet build + - name: Ensure browsers are installed + run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps + - name: Run E2E tests + run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml index 221052ab5..31330a2e3 100644 --- a/.github/workflows/dotnet-integration-tests.yml +++ b/.github/workflows/dotnet-integration-tests.yml @@ -1,7 +1,7 @@ # This workflow will build a .NET project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net -name: Playwright integration tests +name: .NET Integration Tests on: push: @@ -23,7 +23,5 @@ jobs: run: dotnet workload install wasm-tools - name: Build run: dotnet build - - name: Ensure browsers are installed - run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps - - name: Run your tests - run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal + - name: Run integration tests + run: dotnet test src/Tests/AliasVault.IntegrationTests --no-build --verbosity normal diff --git a/.github/workflows/dotnet-build-run-tests.yml b/.github/workflows/dotnet-unit-tests.yml similarity index 92% rename from .github/workflows/dotnet-build-run-tests.yml rename to .github/workflows/dotnet-unit-tests.yml index 79f2a4f6e..75314608b 100644 --- a/.github/workflows/dotnet-build-run-tests.yml +++ b/.github/workflows/dotnet-unit-tests.yml @@ -1,7 +1,7 @@ # This workflow will build a .NET project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net -name: .NET build and run tests +name: .NET Unit Tests on: push: @@ -10,10 +10,8 @@ on: branches: [ "main" ] jobs: - build: - + test: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - name: Setup .NET @@ -26,5 +24,5 @@ jobs: run: dotnet restore - name: Build run: dotnet build --no-restore - - name: Test + - name: Run unittests run: dotnet test src/Tests/AliasVault.UnitTests --no-build --verbosity normal diff --git a/src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs b/src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs deleted file mode 100644 index 50a55b854..000000000 --- a/src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs +++ /dev/null @@ -1,45 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) lanedirt. All rights reserved. -// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. -// -//----------------------------------------------------------------------- - -namespace AliasVault.SmtpService; - -using SmtpServer; -using SmtpServer.Mail; -using SmtpServer.Storage; - -/// -/// Filter to allow only emails from configured domains. -/// -public class AllowedDomainsFilter(Config config, ILogger logger) : IMailboxFilter, IMailboxFilterFactory -{ - private readonly TimeSpan _delay = TimeSpan.Zero; - - public async Task CanAcceptFromAsync(ISessionContext context, IMailbox from, int size, CancellationToken cancellationToken) - { - await Task.Delay(_delay, cancellationToken); - return true; - } - - public async Task CanDeliverToAsync(ISessionContext context, IMailbox to, IMailbox from, CancellationToken cancellationToken) - { - await Task.Delay(_delay, cancellationToken); - - if (!config.AllowedToDomains.Contains(to.Host.ToLowerInvariant())) - { - // ToAddress host is not allowed, return error to sender. - logger.LogWarning("Email to {ToAddress} is not allowed", to); - return false; - } - - return true; - } - - public IMailboxFilter CreateInstance(ISessionContext context) - { - return new AllowedDomainsFilter(context.ServiceProvider.GetRequiredService(), context.ServiceProvider.GetRequiredService>()); - } -} diff --git a/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs similarity index 94% rename from src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs rename to src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 6bbb12a93..3839cb375 100644 --- a/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -5,6 +5,8 @@ // //----------------------------------------------------------------------- +namespace AliasVault.SmtpService.Handlers; + using System.Buffers; using System.Net.Mail; using System.Text.RegularExpressions; @@ -16,8 +18,6 @@ using SmtpServer; using SmtpServer.Protocol; using SmtpServer.Storage; -namespace AliasVault.SmtpService; - /// /// Custom exception for when the email parsing fails to find the "to" address in the email. /// @@ -58,7 +58,7 @@ public class DatabaseMessageStore(ILogger logger, Config c } stream.Position = 0; - var message = await MimeKit.MimeMessage.LoadAsync(stream, cancellationToken); + var message = await MimeMessage.LoadAsync(stream, cancellationToken); // Retrieve all addresses from the SMTP transaction which should contain all recipients for this mail instance. var allAddresses = transaction.To .Distinct() @@ -76,19 +76,18 @@ public class DatabaseMessageStore(ILogger logger, Config c } if (!config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) { - // ToAddress domain is not allowed, return error to sender. + // ToAddress domain is not allowed. + if (toAddresses.Count > 1) + { + // If more recipients, silently skip this one. + continue; + } + + // If only one recipient, return error. logger.LogWarning("Email to {ToAddress} is not allowed", toAddress.User + "@" + toAddress.Host); return SmtpResponse.NoValidRecipientsGiven; } - // Remove existing x-receiver and x-sender headers to avoid duplication. - message.Headers.RemoveAll("x-receiver"); - message.Headers.RemoveAll("x-sender"); - - // Add new x-receiver and x-sender headers. - message.Headers.Add("x-receiver", toAddress.User + "@" + toAddress.Host); - message.Headers.Add("x-sender", transaction.From.User + "@" + transaction.From.Host); - var insertedId = await InsertEmailIntoDatabase(message); logger.LogInformation("Email saved into database with ID {insertedId}.", insertedId); } @@ -115,8 +114,8 @@ public class DatabaseMessageStore(ILogger logger, Config c /// /// Convert MimeMessage to Email database object. /// - /// - /// + /// MimeMessage object. + /// Email object. /// private static Email ConvertMimeMessageToEmail(MimeMessage message) { diff --git a/src/Services/AliasVault.SmtpService/Program.cs b/src/Services/AliasVault.SmtpService/Program.cs index 1198f726f..4aea2c4ab 100644 --- a/src/Services/AliasVault.SmtpService/Program.cs +++ b/src/Services/AliasVault.SmtpService/Program.cs @@ -6,12 +6,13 @@ //----------------------------------------------------------------------- using System.Data.Common; -using AliasVault.SmtpService; -using SmtpServer; using System.Security.Cryptography.X509Certificates; using AliasServerDb; +using AliasVault.SmtpService; +using AliasVault.SmtpService.Handlers; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using SmtpServer; using SmtpServer.Storage; var builder = Host.CreateApplicationBuilder(args); @@ -47,7 +48,6 @@ builder.Services.AddDbContextFactory((container, options) }); builder.Services.AddTransient(); -builder.Services.AddTransient(); builder.Services.AddSingleton( provider => diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index 1e343a709..1113d75e7 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -19,10 +19,19 @@ using MimeKit; [TestFixture] public class SmtpServerTests { - private IHost _testHost; + /// + /// The test host instance. + /// + private IHost _testHost = null!; - private TestHostBuilder _testHostBuilder; + /// + /// The test host builder instance. + /// + private TestHostBuilder _testHostBuilder = null!; + /// + /// Setup logic for every test. + /// [SetUp] public async Task Setup() { @@ -32,6 +41,9 @@ public class SmtpServerTests await _testHost.StartAsync(); } + /// + /// Tear down logic for every test. + /// [TearDown] public async Task TearDown() { @@ -42,46 +54,138 @@ public class SmtpServerTests } } + /// + /// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly. + /// [Test] - public async Task TestWorkerProcessesEmails() + public async Task SingleEmailPlain() + { + // Send an email to the SMTP server. + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "recipient@example.tld")); + message.Subject = "Test Email"; + const string textBody = "This is a test email plain."; + message.Body = new BodyBuilder { TextBody = textBody}.ToMessageBody(); + await SendMessageToSmtpServer(message); + + // Check if the email is in the database. + var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.From, Is.EqualTo("\"Test Sender\" ")); + Assert.That(processedEmail.FromLocal, Is.EqualTo("sender")); + Assert.That(processedEmail.FromDomain, Is.EqualTo("example.com")); + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email plain.")); + Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email plain.")); + Assert.That(processedEmail.MessageHtml, Is.Null); + } + + /// + /// Tests sending a single email in html format to the SMTP server to check if it is processed correctly. + /// + [Test] + public async Task SingleEmailHtml() { // Arrange - // Simulate sending an email to your SMTP server - // You might need to implement a method to do this in your test SMTP server + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "recipient@example.tld")); + message.Subject = "Test Email with HTML body."; + const string htmlBody = "

This is a test email html.

"; + message.Body = new BodyBuilder { HtmlBody = htmlBody }.ToMessageBody(); + await SendMessageToSmtpServer(message); + + // Check if the email is in the database. + var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email html.")); + Assert.That(processedEmail.MessagePlain, Is.Null); + Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); + } + + /// + /// Tests sending a single email in multipart format to the SMTP server to check if it is processed correctly. + /// + [Test] + public async Task SingleEmailMultipart() + { // Arrange var message = new MimeMessage(); message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "recipient@example.tld")); + message.Subject = "Test Email with multipart body."; + const string textBody = "This is a test email multipart."; + const string htmlBody = "

This is a test email multipart.

"; + message.Body = new BodyBuilder { TextBody = textBody, HtmlBody = htmlBody}.ToMessageBody(); + await SendMessageToSmtpServer(message); + + // Check if the email is in the database. + var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email multipart.")); + Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email multipart.")); + Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); + } + + /// + /// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly. + /// + [Test] + public async Task MultipleRecipientsEmail() + { + // Send an email to the SMTP server. + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); message.To.Add(new MailboxAddress("Test Recipient", "recipient.to@example.tld")); message.Cc.Add(new MailboxAddress("Test Recipient 2", "recipient.cc@example.tld")); - message.Subject = "Test Email"; - message.Body = new TextPart("plain") - { - Text = "This is a test email." - }; + message.Cc.Add(new MailboxAddress("Test Recipient 3 unknown domain", "recipient@unknowndomain.tld")); + message.Subject = "Test Email"; + const string textBody = "This is a test email plain."; + message.Body = new BodyBuilder { TextBody = textBody}.ToMessageBody(); + await SendMessageToSmtpServer(message); + + // Check that two emails are in the database, one for each allowed recipient. + Assert.That(await _testHostBuilder.GetDbContext().Emails.CountAsync(), Is.EqualTo(2)); + } + + /// + /// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly. + /// + [Test] + public void SingleEmailUnknownRecipient() + { + // Send an email to the SMTP server. + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "recipient@unknowndomain.tld")); + message.Subject = "Test Email"; + const string textBody = "This is a test email plain."; + message.Body = new BodyBuilder { TextBody = textBody}.ToMessageBody(); + + // Expect error from SmtpClient when sending email to unknown domain. + Assert.ThrowsAsync(async () => await SendMessageToSmtpServer(message)); + } + + /// + /// Sends a message to the SMTP server. + /// + /// + private async Task SendMessageToSmtpServer(MimeMessage message) + { using var client = new SmtpClient(); - // Send message to SMTP server await client.ConnectAsync("localhost", 25, SecureSocketOptions.None); try { await client.SendAsync(message); } - catch (Exception ex) - { - throw; - // Show failure message indicating that message was not sent: SMTP server issue? - Assert.Fail($"Failed to send email, check SMTP server receive logs: {ex}"); - } finally { await client.DisconnectAsync(true); } - - var dbContext = _testHostBuilder.GetDbContext(); - - var processedEmail = await dbContext.Emails.FirstOrDefaultAsync(e => e.Subject == "Test Email"); - Assert.That(processedEmail, Is.Not.Null); - Assert.That(processedEmail.To, Is.EqualTo("recipient.to@example.tld")); } } diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs index e5a97c353..97ca5fcae 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs @@ -5,11 +5,13 @@ // // ----------------------------------------------------------------------- +using AliasVault.SmtpService.Handlers; + namespace AliasVault.IntegrationTests.SmtpServer; using System.Data.Common; using AliasServerDb; -using AliasVault.SmtpService; +using SmtpService; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Data.Sqlite; @@ -75,8 +77,6 @@ public class TestHostBuilder }); services.AddTransient(); - services.AddTransient(); - services.AddSingleton( provider => { From 7bbf986c0932e2421e318067551078d2424f8a67 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 19 Jul 2024 16:44:28 +0200 Subject: [PATCH 5/6] Change integration test ports so it works with GitHub Actions (#111) --- .../SmtpServer/SmtpServerTests.cs | 2 +- .../SmtpServer/TestHostBuilder.cs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index 1113d75e7..b55a7d652 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -178,7 +178,7 @@ public class SmtpServerTests { using var client = new SmtpClient(); - await client.ConnectAsync("localhost", 25, SecureSocketOptions.None); + await client.ConnectAsync("localhost", 2525, SecureSocketOptions.None); try { await client.SendAsync(message); diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs index 97ca5fcae..c52e6c004 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs @@ -83,13 +83,15 @@ public class TestHostBuilder var options = new SmtpServerOptionsBuilder() .ServerName("aliasvault"); - // No TLS + // Note: port 25 doesn't work in GitHub actions so we use these instead for the integration tests: + // - 2525 for the SMTP server + // - 5870 for the submission server options.Endpoint(serverBuilder => serverBuilder - .Port(25, false)) + .Port(2525, false)) .Endpoint(serverBuilder => serverBuilder - .Port(587, false) + .Port(5870, false) ); return new SmtpServer(options.Build(), provider.GetRequiredService()); From d46e582c91a9aef71a3f4356c4f8c32ac23d8d17 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 19 Jul 2024 16:50:32 +0200 Subject: [PATCH 6/6] Add Assert.Multiple (#111) --- .../SmtpServer/SmtpServerTests.cs | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index b55a7d652..cb2469a2b 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -71,14 +71,17 @@ public class SmtpServerTests // Check if the email is in the database. var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); - Assert.That(processedEmail, Is.Not.Null); - Assert.That(processedEmail.From, Is.EqualTo("\"Test Sender\" ")); - Assert.That(processedEmail.FromLocal, Is.EqualTo("sender")); - Assert.That(processedEmail.FromDomain, Is.EqualTo("example.com")); - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); - Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email plain.")); - Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email plain.")); - Assert.That(processedEmail.MessageHtml, Is.Null); + Assert.Multiple(() => + { + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.From, Is.EqualTo("\"Test Sender\" ")); + Assert.That(processedEmail.FromLocal, Is.EqualTo("sender")); + Assert.That(processedEmail.FromDomain, Is.EqualTo("example.com")); + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email plain.")); + Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email plain.")); + Assert.That(processedEmail.MessageHtml, Is.Null); + }); } /// @@ -98,11 +101,14 @@ public class SmtpServerTests // Check if the email is in the database. var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); - Assert.That(processedEmail, Is.Not.Null); - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); - Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email html.")); - Assert.That(processedEmail.MessagePlain, Is.Null); - Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); + Assert.Multiple(() => + { + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email html.")); + Assert.That(processedEmail.MessagePlain, Is.Null); + Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); + }); } /// @@ -123,11 +129,14 @@ public class SmtpServerTests // Check if the email is in the database. var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); - Assert.That(processedEmail, Is.Not.Null); - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); - Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email multipart.")); - Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email multipart.")); - Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); + Assert.Multiple(() => + { + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email multipart.")); + Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email multipart.")); + Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); + }); } /// @@ -174,7 +183,7 @@ public class SmtpServerTests /// Sends a message to the SMTP server. /// /// - private async Task SendMessageToSmtpServer(MimeMessage message) + private static async Task SendMessageToSmtpServer(MimeMessage message) { using var client = new SmtpClient();