diff --git a/Testcontainers.sln b/Testcontainers.sln index 9595905ed..f020821b3 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 @@ -195,6 +195,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver.Tests", "tests\Testcontainers.WebDriver.Tests\Testcontainers.WebDriver.Tests.csproj", "{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Mailpit", "src\Testcontainers.Mailpit\Testcontainers.Mailpit.csproj", "{2FD800AA-7015-48B2-8A31-CB28E229F3F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Mailpit.Tests", "tests\Testcontainers.Mailpit.Tests\Testcontainers.Mailpit.Tests.csproj", "{A7F19A31-41A9-4934-8353-BF26446DA1FE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -568,6 +572,14 @@ Global {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.Build.0 = Release|Any CPU + {2FD800AA-7015-48B2-8A31-CB28E229F3F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2FD800AA-7015-48B2-8A31-CB28E229F3F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FD800AA-7015-48B2-8A31-CB28E229F3F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2FD800AA-7015-48B2-8A31-CB28E229F3F6}.Release|Any CPU.Build.0 = Release|Any CPU + {A7F19A31-41A9-4934-8353-BF26446DA1FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7F19A31-41A9-4934-8353-BF26446DA1FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7F19A31-41A9-4934-8353-BF26446DA1FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7F19A31-41A9-4934-8353-BF26446DA1FE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {5365F780-0E6C-41F0-B1B9-7DC34368F80C} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -661,5 +673,7 @@ Global {1A1983E6-5297-435F-B467-E8E1F11277D6} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {27CDB869-A150-4593-958F-6F26E5391E7C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {2FD800AA-7015-48B2-8A31-CB28E229F3F6} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {A7F19A31-41A9-4934-8353-BF26446DA1FE} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} EndGlobalSection EndGlobal diff --git a/docs/modules/index.md b/docs/modules/index.md index e533faecf..149c70391 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -46,6 +46,7 @@ await moduleNameContainer.StartAsync(); | Keycloak | `quay.io/keycloak/keycloak:21.1` | [NuGet](https://www.nuget.org/packages/Testcontainers.Keycloak) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Keycloak) | | Kusto emulator | `mcr.microsoft.com/azuredataexplorer/kustainer-linux:latest` | [NuGet](https://www.nuget.org/packages/Testcontainers.Kusto) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Kusto) | | LocalStack | `localstack/localstack:2.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.LocalStack) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.LocalStack) | +| Mailpit | `axllent/mailpit` | [NuGet](https://www.nuget.org/packages/Testcontainers.Mailpit) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Mailpit) | | MariaDB | `mariadb:10.10` | [NuGet](https://www.nuget.org/packages/Testcontainers.MariaDb) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.MariaDb) | | MinIO | `minio/minio:RELEASE.2023-01-31T02-24-19Z` | [NuGet](https://www.nuget.org/packages/Testcontainers.Minio) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Minio) | | MongoDB | `mongo:6.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.MongoDb) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.MongoDb) | diff --git a/src/Testcontainers.Mailpit/.editorconfig b/src/Testcontainers.Mailpit/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.Mailpit/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.Mailpit/Testcontainers.Mailpit.csproj b/src/Testcontainers.Mailpit/Testcontainers.Mailpit.csproj new file mode 100644 index 000000000..a108060b3 --- /dev/null +++ b/src/Testcontainers.Mailpit/Testcontainers.Mailpit.csproj @@ -0,0 +1,12 @@ + + + netstandard2.0;netstandard2.1 + latest + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.Mailpit/Testcontainers.MailpitBuilder.cs b/src/Testcontainers.Mailpit/Testcontainers.MailpitBuilder.cs new file mode 100644 index 000000000..e945856e2 --- /dev/null +++ b/src/Testcontainers.Mailpit/Testcontainers.MailpitBuilder.cs @@ -0,0 +1,148 @@ +using System.Net; + +namespace Testcontainers.Mailpit; + +/// +[PublicAPI] +public sealed class MailpitBuilder + : ContainerBuilder +{ + public const string MAILPIT_IMAGE = "axllent/mailpit"; + public const ushort MAILPIT_SMTP_PORT = 1025; + public const ushort MAILPIT_API_PORT = 8025; + + /// + /// Initializes a new instance of the class. + /// + public MailpitBuilder() + : this(new MailpitConfiguration()) + { + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private MailpitBuilder(MailpitConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override MailpitConfiguration DockerResourceConfiguration { get; } + + /// + /// Sets the Mailpit MP_SMTP_AUTH config. Also sets MP_SMTP_AUTH_ALLOW_INSECURE to true. + /// + /// List of credentials to be used in SMTP authentication + /// A configured instance of . + public MailpitBuilder WithSmtpAuthCredentials( + List authCredentials + ) + { + return Merge( + DockerResourceConfiguration, + new MailpitConfiguration( + smtpAuthCredentials: authCredentials, + smtpAuthAllowInsecure: true + ) + ) + .WithEnvironment( + "MP_SMTP_AUTH", + string.Join(" ", authCredentials.Select(e => $"{e.Username}:{e.Password}")) + ) + .WithEnvironment("MP_SMTP_AUTH_ALLOW_INSECURE", "1"); + } + + /// + /// Sets the Mailpit MP_SMTP_AUTH_ALLOW_INSECURE config. + /// Typically STARTTLS is enforced for all SMTP authentication. This option allows insecure PLAIN & LOGIN SMTP authentication. + /// + /// Whether or not to allow PLAIN & LOGIN SMTP authentication + /// A configured instance of . + public MailpitBuilder WithSmtpAuthAllowInsecure(bool allowInsecure) + { + return Merge( + DockerResourceConfiguration, + new MailpitConfiguration(smtpAuthAllowInsecure: allowInsecure) + ) + .WithEnvironment("MP_SMTP_AUTH_ALLOW_INSECURE", allowInsecure ? "1" : "0"); + } + + /// + /// Sets the Mailpit MP_MAX_MESSAGES config. + /// Maximum number of messages to store. Mailpit will periodically delete the oldest messages if greater than this. Set to 0 to disable auto-deletion. + /// + /// Maximum number to set + /// A configured instance of . + public MailpitBuilder WithMaxMessages(uint maxMessages) + { + return Merge( + DockerResourceConfiguration, + new MailpitConfiguration(maxMessages: maxMessages) + ) + .WithEnvironment("MP_MAX_MESSAGES", maxMessages.ToString()); + } + + /// + public override MailpitContainer Build() + { + Validate(); + return new MailpitContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + } + + /// + protected override MailpitBuilder Init() + { + return base.Init() + .WithImage(MAILPIT_IMAGE) + .WithPortBinding(MAILPIT_SMTP_PORT, true) + .WithPortBinding(MAILPIT_API_PORT, true) + .WithWaitStrategy( + Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => + r.ForPort(MAILPIT_API_PORT) + .ForPath("/api/v1/info") + .ForStatusCode(HttpStatusCode.OK) + ) + ); + } + + /// + protected override void Validate() + { + base.Validate(); + + _ = Guard + .Argument( + DockerResourceConfiguration.SmtpAuthCredentials, + nameof(DockerResourceConfiguration.SmtpAuthCredentials) + ) + .NotNull(); + } + + /// + protected override MailpitBuilder Clone( + IResourceConfiguration resourceConfiguration + ) + { + return Merge(DockerResourceConfiguration, new MailpitConfiguration(resourceConfiguration)); + } + + /// + protected override MailpitBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new MailpitConfiguration(resourceConfiguration)); + } + + /// + protected override MailpitBuilder Merge( + MailpitConfiguration oldValue, + MailpitConfiguration newValue + ) + { + return new MailpitBuilder(new MailpitConfiguration(oldValue, newValue)); + } +} diff --git a/src/Testcontainers.Mailpit/Testcontainers.MailpitConfiguration.cs b/src/Testcontainers.Mailpit/Testcontainers.MailpitConfiguration.cs new file mode 100644 index 000000000..117f937ea --- /dev/null +++ b/src/Testcontainers.Mailpit/Testcontainers.MailpitConfiguration.cs @@ -0,0 +1,99 @@ +namespace Testcontainers.Mailpit; + +/// +[PublicAPI] +public sealed class MailpitConfiguration : ContainerConfiguration +{ + public sealed class AuthCredentials + { + public string Username { get; } + public string Password { get; } + + public AuthCredentials(string username, string password) + { + Username = username; + Password = password; + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The Testcontainers.Mailpit config. + public MailpitConfiguration( + List smtpAuthCredentials = null, + bool smtpAuthAllowInsecure = true, + uint maxMessages = 100 + ) + { + SmtpAuthCredentials = smtpAuthCredentials; + SmtpAuthAllowInsecure = smtpAuthAllowInsecure; + MaxMessages = maxMessages; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public MailpitConfiguration( + 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 MailpitConfiguration(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 MailpitConfiguration(MailpitConfiguration resourceConfiguration) + : this(new MailpitConfiguration(), 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 MailpitConfiguration(MailpitConfiguration oldValue, MailpitConfiguration newValue) + : base(oldValue, newValue) + { + SmtpAuthCredentials = BuildConfiguration.Combine( + oldValue.SmtpAuthCredentials, + newValue.SmtpAuthCredentials + ); + SmtpAuthAllowInsecure = BuildConfiguration.Combine( + oldValue.SmtpAuthAllowInsecure, + newValue.SmtpAuthAllowInsecure + ); + } + + /// + /// A list of usernames and passwords for SMTP authentication. + /// See Mailpit docs for more information. + /// + public List SmtpAuthCredentials { get; } + + /// + /// Typically STARTTLS is enforced for all SMTP authentication. This option allows insecure PLAIN & LOGIN SMTP authentication. + /// + public bool SmtpAuthAllowInsecure { get; } + + /// + /// Maximum number of messages to store. Mailpit will periodically delete the oldest messages if greater than this. Set to 0 to disable auto-deletion. + /// + public uint MaxMessages { get; } +} diff --git a/src/Testcontainers.Mailpit/Testcontainers.MailpitContainer.cs b/src/Testcontainers.Mailpit/Testcontainers.MailpitContainer.cs new file mode 100644 index 000000000..151f5876a --- /dev/null +++ b/src/Testcontainers.Mailpit/Testcontainers.MailpitContainer.cs @@ -0,0 +1,30 @@ +namespace Testcontainers.Mailpit; + +/// +[PublicAPI] +public sealed class MailpitContainer : DockerContainer +{ + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + public MailpitContainer(MailpitConfiguration configuration, ILogger logger) + : base(configuration, logger) { } + + /// + /// SMTP server port. + /// + public ushort SmtpPort + { + get => GetMappedPublicPort(MailpitBuilder.MAILPIT_SMTP_PORT); + } + + /// + /// Web API server port. + /// + public ushort ApiPort + { + get => GetMappedPublicPort(MailpitBuilder.MAILPIT_API_PORT); + } +} diff --git a/src/Testcontainers.Mailpit/Usings.cs b/src/Testcontainers.Mailpit/Usings.cs new file mode 100644 index 000000000..f889bad0a --- /dev/null +++ b/src/Testcontainers.Mailpit/Usings.cs @@ -0,0 +1,10 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; +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; +global using Microsoft.Extensions.Logging; \ No newline at end of file diff --git a/tests/Testcontainers.Mailpit.Tests/.editorconfig b/tests/Testcontainers.Mailpit.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.Mailpit.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.Mailpit.Tests/MailpitContainerTest.cs b/tests/Testcontainers.Mailpit.Tests/MailpitContainerTest.cs new file mode 100644 index 000000000..76b77be7b --- /dev/null +++ b/tests/Testcontainers.Mailpit.Tests/MailpitContainerTest.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mail; +using System.Web; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Testcontainers.Mailpit; + +public sealed class MailpitContainerTest : IAsyncLifetime +{ + private readonly MailpitContainer _mailpitContainer = new MailpitBuilder() + .WithSmtpAuthCredentials( + new List([GetTestCredentials()]) + ) + .Build(); + + public Task InitializeAsync() + { + return _mailpitContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _mailpitContainer.DisposeAsync().AsTask(); + } + + private static MailpitConfiguration.AuthCredentials GetTestCredentials() => new("test", "test"); + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task MailSentAndApiReturnsSuccessful() + { + // Given + const string to = "receiver@mailpit-testcontainers.com"; + const string from = "sender@mailpit-testcontainers.com"; + var message = new MailMessage(from, to) + { + Subject = "Hey there from Mailpit!", + Body = + "This is just a test message, it doesn't have much going on.\n\nCheers,\n\nSender" + }; + var credentials = GetTestCredentials(); + var smtpClient = new SmtpClient(_mailpitContainer.Hostname, _mailpitContainer.SmtpPort) + { + Credentials = new NetworkCredential(credentials.Username, credentials.Password) + }; + + // When + await smtpClient.SendMailAsync(message); + + // Then + var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json") + ); + + var host = _mailpitContainer.Hostname; + var port = _mailpitContainer.ApiPort; + var queryParams = HttpUtility.ParseQueryString(string.Empty); + queryParams.Add("query", $"to:\"{to}\""); + var url = $"http://{host}:{port}/api/v1/search?{queryParams}"; + var response = await client.GetAsync(url); + + var jsonString = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var responseBody = JsonConvert.DeserializeObject(jsonString); + + Assert.Equal(1, responseBody["messages_count"]); + } +} diff --git a/tests/Testcontainers.Mailpit.Tests/Testcontainers.Mailpit.Tests.csproj b/tests/Testcontainers.Mailpit.Tests/Testcontainers.Mailpit.Tests.csproj new file mode 100644 index 000000000..3edb6b57d --- /dev/null +++ b/tests/Testcontainers.Mailpit.Tests/Testcontainers.Mailpit.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + false + false + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Testcontainers.Mailpit.Tests/Usings.cs b/tests/Testcontainers.Mailpit.Tests/Usings.cs new file mode 100644 index 000000000..8148d1e1f --- /dev/null +++ b/tests/Testcontainers.Mailpit.Tests/Usings.cs @@ -0,0 +1,5 @@ +global using System.Data; +global using System.Data.Common; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using Xunit;