diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 87dc5d3b1..c92d62fd6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -87,6 +87,7 @@ jobs: { name: "Testcontainers.Redpanda", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.ServiceBus", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Sftp", runs-on: "ubuntu-22.04" }, + { name: "Testcontainers.Smtp4Dev", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Weaviate", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.WebDriver", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Xunit", runs-on: "ubuntu-22.04" } diff --git a/Testcontainers.sln b/Testcontainers.sln index 14af32b18..f0cbf9fff 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -105,6 +105,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.ServiceBus", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Sftp", "src\Testcontainers.Sftp\Testcontainers.Sftp.csproj", "{7D5C6816-0DD2-4E13-A585-033B5D3C80D5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Smtp4Dev", "src\Testcontainers.Smtp4Dev\Testcontainers.Smtp4Dev.csproj", "{DA635A41-3448-4DF3-8A1E-D3CF9C7F4B70}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Weaviate", "src\Testcontainers.Weaviate\Testcontainers.Weaviate.csproj", "{68F8600D-24E9-4E03-9E25-5F6EB338EAC1}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver", "src\Testcontainers.WebDriver\Testcontainers.WebDriver.csproj", "{64A87DE5-29B0-4A54-9E74-560484D8C7C0}" @@ -225,6 +227,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.ServiceBus.T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Sftp.Tests", "tests\Testcontainers.Sftp.Tests\Testcontainers.Sftp.Tests.csproj", "{B73C3CC0-9F16-4B34-92BE-6EC0853912C5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Smtp4Dev.Tests", "tests\Testcontainers.Smtp4Dev.Tests\Testcontainers.Smtp4Dev.Tests.csproj", "{F7387519-8EB0-4B87-B817-A09CA8CE369A}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tests\Testcontainers.Tests\Testcontainers.Tests.csproj", "{27CDB869-A150-4593-958F-6F26E5391E7C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Weaviate.Tests", "tests\Testcontainers.Weaviate.Tests\Testcontainers.Weaviate.Tests.csproj", "{DDB41BC8-5826-4D97-9C5F-001151E3FFD6}" @@ -682,6 +686,14 @@ Global {E901DF14-6F05-4FC2-825A-3055FAD33561}.Debug|Any CPU.Build.0 = Debug|Any CPU {E901DF14-6F05-4FC2-825A-3055FAD33561}.Release|Any CPU.ActiveCfg = Release|Any CPU {E901DF14-6F05-4FC2-825A-3055FAD33561}.Release|Any CPU.Build.0 = Release|Any CPU + {DA635A41-3448-4DF3-8A1E-D3CF9C7F4B70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA635A41-3448-4DF3-8A1E-D3CF9C7F4B70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA635A41-3448-4DF3-8A1E-D3CF9C7F4B70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA635A41-3448-4DF3-8A1E-D3CF9C7F4B70}.Release|Any CPU.Build.0 = Release|Any CPU + {F7387519-8EB0-4B87-B817-A09CA8CE369A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7387519-8EB0-4B87-B817-A09CA8CE369A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7387519-8EB0-4B87-B817-A09CA8CE369A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7387519-8EB0-4B87-B817-A09CA8CE369A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {5365F780-0E6C-41F0-B1B9-7DC34368F80C} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -794,5 +806,7 @@ Global {DDB41BC8-5826-4D97-9C5F-001151E3FFD6} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {E901DF14-6F05-4FC2-825A-3055FAD33561} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {DA635A41-3448-4DF3-8A1E-D3CF9C7F4B70} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {F7387519-8EB0-4B87-B817-A09CA8CE369A} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} EndGlobalSection EndGlobal diff --git a/src/Testcontainers.Smtp4Dev/.editorconfig b/src/Testcontainers.Smtp4Dev/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.Smtp4Dev/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.Smtp4Dev/Smtp4DevBuilder.cs b/src/Testcontainers.Smtp4Dev/Smtp4DevBuilder.cs new file mode 100644 index 000000000..8c3da3ea4 --- /dev/null +++ b/src/Testcontainers.Smtp4Dev/Smtp4DevBuilder.cs @@ -0,0 +1,160 @@ +using TestContainers.Smtp4Dev; + +namespace Testcontainers.Smtp4Dev; + +/// +[PublicAPI] +public sealed class Smtp4DevBuilder : ContainerBuilder +{ + public const string Smtp4DevImage = "rnwood/smtp4dev:latest"; + + public const ushort WebInterfacePort = 80; + public const ushort SmtpPort = 25; + public const ushort ImapPort = 143; + + public const bool DefaultLockSettings = false; + public const string DefaultBasePath = "/"; + public const string DefaultDatabase = "database.db"; + public const ushort DefaultNumberOfMessagesToKeep = 100; + public const ushort DefaultNumberOfSessionsToKeep = 100; + public const bool DefaultDisableMessageSanitisation = false; + + /// + /// Initializes a new instance of the class. + /// + public Smtp4DevBuilder() + : this(new Smtp4DevConfiguration()) + { + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private Smtp4DevBuilder(Smtp4DevConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override Smtp4DevConfiguration DockerResourceConfiguration { get; } + + /// + /// Sets whether settings can be changed by user via web interface. + /// + /// Locks settings form being changed by user via web interface. + /// A configured instance of . + public Smtp4DevBuilder WithLockSettings(bool lockSettings) + { + return Merge(DockerResourceConfiguration, new Smtp4DevConfiguration(lockSettings: lockSettings)) + .WithEnvironment("LockSettings", lockSettings.ToString()); + } + + /// + /// Sets the virtual path from web server root where SMTP4DEV web interface will be hosted. e.g. "/" or + /// "/smtp4dev". + /// + /// Locks settings form being changed by user via web interface. + /// A configured instance of . + public Smtp4DevBuilder WithBasePath(string basePath) + { + return Merge(DockerResourceConfiguration, new Smtp4DevConfiguration(basePath: basePath)) + .WithEnvironment("BasePath", basePath); + } + + /// + /// Sets the path where the database will be stored relative to APPDATA env var on Windows or XDG_CONFIG_HOME + /// on non-Windows. Specify "" to use an in memory database. + /// + /// + /// The path where the database will be stored relative to APPDATA env var on Windows or XDG_CONFIG_HOME + /// on non-Windows. Specify "" to use an in memory database. + /// + /// A configured instance of . + public Smtp4DevBuilder WithDatabase(string database) + { + return Merge(DockerResourceConfiguration, new Smtp4DevConfiguration(database: database)) + .WithEnvironment("Latabase", database); + } + + /// + /// Sets the number of messages to keep per mailbox. + /// + /// The number of messages to keep per mailbox. + /// A configured instance of . + public Smtp4DevBuilder WithNumberOfMessagesToKeep(int numberOfMessagesToKeep) + { + return Merge(DockerResourceConfiguration, + new Smtp4DevConfiguration(numberOfMessagesToKeep: numberOfMessagesToKeep)) + .WithEnvironment("NumberOfMessagesToKeep", numberOfMessagesToKeep.ToString()); + } + + /// + /// Sets the number of sessions to keep. + /// + /// The number of sessions to keep. + /// A configured instance of . + public Smtp4DevBuilder WithNumberOfSessionsToKeep(int numberOfSessionsToKeep) + { + return Merge(DockerResourceConfiguration, + new Smtp4DevConfiguration(numberOfSessionsToKeep: numberOfSessionsToKeep)) + .WithEnvironment("NumberOfSessionsToKeep", numberOfSessionsToKeep.ToString()); + } + + /// + /// Sets whether message HTML sanitisation should be enabled. Dangerous if your messages are not generated by you + /// and not reflective of how messages might render in most email clients. + /// + /// + /// Whether message HTML sanitisation should be enabled. Dangerous if your messages are not generated by you + /// and not reflective of how messages might render in most email clients. + /// + /// A configured instance of . + public Smtp4DevBuilder WithDisableMessageSanitisations(bool disableMessageSanitisations) + { + return Merge(DockerResourceConfiguration, + new Smtp4DevConfiguration(disableMessageSanitisations: disableMessageSanitisations)) + .WithEnvironment("DisableMessageSanitisations", disableMessageSanitisations.ToString()); + } + + /// + public override Smtp4DevContainer Build() + { + Validate(); + return new Smtp4DevContainer(DockerResourceConfiguration); + } + + /// + protected override Smtp4DevBuilder Init() + { + return base.Init() + .WithImage(Smtp4DevImage) + .WithPortBinding(SmtpPort, true) + .WithPortBinding(ImapPort, true) + .WithPortBinding(WebInterfacePort, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilMessageIsLogged("Now listening on: .+") + .UntilMessageIsLogged("SMTP Server is listening on port") + .UntilMessageIsLogged("IMAP Server is listening on port") ); + } + + /// + protected override Smtp4DevBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new Smtp4DevConfiguration(resourceConfiguration)); + } + + /// + protected override Smtp4DevBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new Smtp4DevConfiguration(resourceConfiguration)); + } + + /// + protected override Smtp4DevBuilder Merge(Smtp4DevConfiguration oldValue, Smtp4DevConfiguration newValue) + { + return new Smtp4DevBuilder(new Smtp4DevConfiguration(oldValue, newValue)); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Smtp4Dev/Smtp4DevConfiguration.cs b/src/Testcontainers.Smtp4Dev/Smtp4DevConfiguration.cs new file mode 100644 index 000000000..5e33daf70 --- /dev/null +++ b/src/Testcontainers.Smtp4Dev/Smtp4DevConfiguration.cs @@ -0,0 +1,118 @@ +namespace TestContainers.Smtp4Dev; + +/// +[PublicAPI] +public class Smtp4DevConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + /// Locks settings form being changed by user via web interface. + /// + /// Specifies the virtual path from web server root where SMTP4DEV web interface will be hosted. e.g. "/" or + /// "/smtp4dev". + /// + /// + /// Specifies the path where the database will be stored relative to APPDATA env var on Windows or XDG_CONFIG_HOME + /// on non-Windows. Specify "" to use an in memory database. + /// + /// Specifies the number of messages to keep per mailbox. + /// Specifies the number of sessions to keep. + /// + /// Disables message HTML sanitisation. Dangerous if your messages are not generated by you and not reflective of + /// how messages might render in most email clients. + /// + public Smtp4DevConfiguration( + bool? lockSettings = null, + string basePath = null, + string database = null, + int? numberOfMessagesToKeep = null, + int? numberOfSessionsToKeep = null, + bool? disableMessageSanitisations = null) + { + LockSettings = lockSettings; + BasePath = basePath; + Database = database; + NumberOfMessagesToKeep = numberOfMessagesToKeep; + NumberOfSessionsToKeep = numberOfSessionsToKeep; + DisableMessageSanitisations = disableMessageSanitisations; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public Smtp4DevConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public Smtp4DevConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public Smtp4DevConfiguration(Smtp4DevConfiguration resourceConfiguration) + : this(new Smtp4DevConfiguration(), resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public Smtp4DevConfiguration(Smtp4DevConfiguration oldValue, Smtp4DevConfiguration newValue) + : base(oldValue, newValue) + { + LockSettings = BuildConfiguration.Combine(oldValue.LockSettings, newValue.LockSettings); + BasePath = BuildConfiguration.Combine(oldValue.BasePath, newValue.BasePath); + Database = BuildConfiguration.Combine(oldValue.Database, newValue.Database); + NumberOfMessagesToKeep = BuildConfiguration.Combine(oldValue.NumberOfMessagesToKeep, newValue.NumberOfMessagesToKeep); + NumberOfSessionsToKeep = BuildConfiguration.Combine(oldValue.NumberOfSessionsToKeep, newValue.NumberOfSessionsToKeep); + DisableMessageSanitisations = BuildConfiguration.Combine(oldValue.DisableMessageSanitisations, newValue.DisableMessageSanitisations); + } + + /// + /// Gets whether settings can be changed by user via web interface. + /// + public bool? LockSettings { get; } + + /// + /// Gets the virtual path from web server root where SMTP4DEV web interface will be hosted. e.g. "/" or + /// "/smtp4dev". + /// + public string BasePath { get; } + + /// + /// Gets the path where the database will be stored relative to APPDATA env var on Windows or XDG_CONFIG_HOME + /// on non-Windows. Specify "" to use an in memory database. + /// + public string Database { get; } + + /// + /// Gets the number of messages to keep per mailbox. + /// + public int? NumberOfMessagesToKeep { get; } + + /// + /// Gets the number of sessions to keep. + /// + public int? NumberOfSessionsToKeep { get; } + + /// + /// Gets whether message HTML sanitisation is disabled. + /// + public bool? DisableMessageSanitisations { get; } +} \ No newline at end of file diff --git a/src/Testcontainers.Smtp4Dev/Smtp4DevContainer.cs b/src/Testcontainers.Smtp4Dev/Smtp4DevContainer.cs new file mode 100644 index 000000000..e8d9fbae7 --- /dev/null +++ b/src/Testcontainers.Smtp4Dev/Smtp4DevContainer.cs @@ -0,0 +1,15 @@ +namespace TestContainers.Smtp4Dev; + +/// +[PublicAPI] +public sealed class Smtp4DevContainer : DockerContainer +{ + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public Smtp4DevContainer(Smtp4DevConfiguration configuration) + : base(configuration) + { + } +} \ No newline at end of file diff --git a/src/Testcontainers.Smtp4Dev/Testcontainers.Smtp4Dev.csproj b/src/Testcontainers.Smtp4Dev/Testcontainers.Smtp4Dev.csproj new file mode 100644 index 000000000..de9b9e234 --- /dev/null +++ b/src/Testcontainers.Smtp4Dev/Testcontainers.Smtp4Dev.csproj @@ -0,0 +1,13 @@ + + + net8.0;net9.0;netstandard2.0;netstandard2.1 + latest + TestContainers.Smtp4Dev + + + + + + + + diff --git a/src/Testcontainers.Smtp4Dev/Usings.cs b/src/Testcontainers.Smtp4Dev/Usings.cs new file mode 100644 index 000000000..fa3a104a1 --- /dev/null +++ b/src/Testcontainers.Smtp4Dev/Usings.cs @@ -0,0 +1,6 @@ +global using Docker.DotNet.Models; +global using DotNet.Testcontainers; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using JetBrains.Annotations; \ No newline at end of file diff --git a/tests/Testcontainers.Smtp4Dev.Tests/.editorconfig b/tests/Testcontainers.Smtp4Dev.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.Smtp4Dev.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.Smtp4Dev.Tests/Smtp4DevContainerTest.cs b/tests/Testcontainers.Smtp4Dev.Tests/Smtp4DevContainerTest.cs new file mode 100644 index 000000000..14dc9ed05 --- /dev/null +++ b/tests/Testcontainers.Smtp4Dev.Tests/Smtp4DevContainerTest.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.Mail; +using TestContainers.Smtp4Dev; + +namespace Testcontainers.Smtp4Dev; + +public sealed class Smtp4DevContainerTest : IAsyncLifetime +{ + private readonly Smtp4DevContainer _smtp4DevContainer = new Smtp4DevBuilder().Build(); + + public Task InitializeAsync() + { + return _smtp4DevContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _smtp4DevContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task IsConnectedReturnsTrue() + { + // Given + var host = _smtp4DevContainer.Hostname; + + var smtpPort = _smtp4DevContainer.GetMappedPublicPort(Smtp4DevBuilder.SmtpPort); + var httpPort = _smtp4DevContainer.GetMappedPublicPort(Smtp4DevBuilder.WebInterfacePort); + + const string senderEmail = "sender@example.com"; + const string receiverEmail = "receiver@example.com"; + const string subject = "Test mail"; + const string body = "This is a test mail"; + + using var smtpClient = new SmtpClient(host, smtpPort); + using var httpClient = new HttpClient(); + + // When + await smtpClient.SendMailAsync(new MailMessage + { + From = new MailAddress(senderEmail), + To = { new MailAddress(receiverEmail) }, + Subject = subject, + Body = body, + }); + + // Then + var result = await httpClient.GetFromJsonAsync($"http://{host}:{httpPort}/api/Messages"); + + Assert.Contains(result.Results, message => message.From == senderEmail && + message.To.Length == 1 && message.To[0] == receiverEmail && + message.Subject == subject); + } + + public record PagedMessageResult(List Results); + + public record Message(string From, string[] To, string Subject); +} \ No newline at end of file diff --git a/tests/Testcontainers.Smtp4Dev.Tests/Testcontainers.Smtp4Dev.Tests.csproj b/tests/Testcontainers.Smtp4Dev.Tests/Testcontainers.Smtp4Dev.Tests.csproj new file mode 100644 index 000000000..faf10e23e --- /dev/null +++ b/tests/Testcontainers.Smtp4Dev.Tests/Testcontainers.Smtp4Dev.Tests.csproj @@ -0,0 +1,18 @@ + + + net9.0 + false + false + TestContainers.Smtp4Dev.Tests + + + + + + + + + + + + diff --git a/tests/Testcontainers.Smtp4Dev.Tests/Usings.cs b/tests/Testcontainers.Smtp4Dev.Tests/Usings.cs new file mode 100644 index 000000000..db5ab3498 --- /dev/null +++ b/tests/Testcontainers.Smtp4Dev.Tests/Usings.cs @@ -0,0 +1,4 @@ +global using System.Threading; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using Xunit;