From cc70a2f7f77def76d306f2b519cd912683eae907 Mon Sep 17 00:00:00 2001 From: Felipe <26683783+feslima@users.noreply.github.com> Date: Sat, 2 Mar 2024 15:01:38 -0300 Subject: [PATCH 1/2] feat: mailpit module configuration --- Testcontainers.slnx | 2 + docs/modules/index.md | 1 + src/Testcontainers.Mailpit/.editorconfig | 1 + src/Testcontainers.Mailpit/MailpitBuilder.cs | 148 ++++++++++++++++++ .../MailpitConfiguration.cs | 99 ++++++++++++ .../MailpitContainer.cs | 30 ++++ .../Testcontainers.Mailpit.csproj | 12 ++ src/Testcontainers.Mailpit/Usings.cs | 10 ++ .../.editorconfig | 1 + .../MailpitContainerTest.cs | 76 +++++++++ .../Testcontainers.Mailpit.Tests.csproj | 18 +++ tests/Testcontainers.Mailpit.Tests/Usings.cs | 5 + 12 files changed, 403 insertions(+) create mode 100644 src/Testcontainers.Mailpit/.editorconfig create mode 100644 src/Testcontainers.Mailpit/MailpitBuilder.cs create mode 100644 src/Testcontainers.Mailpit/MailpitConfiguration.cs create mode 100644 src/Testcontainers.Mailpit/MailpitContainer.cs create mode 100644 src/Testcontainers.Mailpit/Testcontainers.Mailpit.csproj create mode 100644 src/Testcontainers.Mailpit/Usings.cs create mode 100644 tests/Testcontainers.Mailpit.Tests/.editorconfig create mode 100644 tests/Testcontainers.Mailpit.Tests/MailpitContainerTest.cs create mode 100644 tests/Testcontainers.Mailpit.Tests/Testcontainers.Mailpit.Tests.csproj create mode 100644 tests/Testcontainers.Mailpit.Tests/Usings.cs diff --git a/Testcontainers.slnx b/Testcontainers.slnx index fd3853239..24e32d2d9 100644 --- a/Testcontainers.slnx +++ b/Testcontainers.slnx @@ -48,6 +48,7 @@ + @@ -116,6 +117,7 @@ + diff --git a/docs/modules/index.md b/docs/modules/index.md index 930012923..1b3fec695 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -52,6 +52,7 @@ await moduleNameContainer.StartAsync(); | 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) | | Lowkey Vault | `nagyesta/lowkey-vault:2.7.1-ubi9-minimal` | [NuGet](https://www.nuget.org/packages/Testcontainers.LowkeyVault) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.LowkeyVault) | +| Mailpit | `axllent/mailpit:v1.30` | [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) | | Milvus | `milvusdb/milvus:v2.3.10` | [NuGet](https://www.nuget.org/packages/Testcontainers.Milvus) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Milvus) | | 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) | 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/MailpitBuilder.cs b/src/Testcontainers.Mailpit/MailpitBuilder.cs new file mode 100644 index 000000000..e945856e2 --- /dev/null +++ b/src/Testcontainers.Mailpit/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/MailpitConfiguration.cs b/src/Testcontainers.Mailpit/MailpitConfiguration.cs new file mode 100644 index 000000000..117f937ea --- /dev/null +++ b/src/Testcontainers.Mailpit/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/MailpitContainer.cs b/src/Testcontainers.Mailpit/MailpitContainer.cs new file mode 100644 index 000000000..151f5876a --- /dev/null +++ b/src/Testcontainers.Mailpit/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/Testcontainers.Mailpit.csproj b/src/Testcontainers.Mailpit/Testcontainers.Mailpit.csproj new file mode 100644 index 000000000..6f204b739 --- /dev/null +++ b/src/Testcontainers.Mailpit/Testcontainers.Mailpit.csproj @@ -0,0 +1,12 @@ + + + net8.0;net9.0;net10.0;netstandard2.0;netstandard2.1 + latest + + + + + + + + \ No newline at end of file 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; From 24054308acb799c8b3315e92a051b481b5657ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Thu, 16 Jul 2026 10:18:56 +0200 Subject: [PATCH 2/2] Refine the Mailpit module API and documentation Also make sure that Mailpit can be used without authentication if not configured and improve SMTP authentication configuration by letting the user choose whether to use a self-signed certificate or to allow insecure PLAIN and LOGIN SMTP authentication. --- Directory.Packages.props | 1 + src/Testcontainers.Mailpit/MailpitBuilder.cs | 147 +++++++++-------- .../MailpitConfiguration.cs | 44 ++---- .../MailpitContainer.cs | 20 ++- src/Testcontainers.Mailpit/Usings.cs | 8 +- tests/Testcontainers.Mailpit.Tests/.runs-on | 1 + tests/Testcontainers.Mailpit.Tests/Dockerfile | 1 + .../MailpitContainerTest.cs | 149 +++++++++++------- .../Testcontainers.Mailpit.Tests.csproj | 39 +++-- tests/Testcontainers.Mailpit.Tests/Usings.cs | 16 +- 10 files changed, 227 insertions(+), 199 deletions(-) create mode 100644 tests/Testcontainers.Mailpit.Tests/.runs-on create mode 100644 tests/Testcontainers.Mailpit.Tests/Dockerfile diff --git a/Directory.Packages.props b/Directory.Packages.props index 99f45ab76..21b1373bd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -67,6 +67,7 @@ + diff --git a/src/Testcontainers.Mailpit/MailpitBuilder.cs b/src/Testcontainers.Mailpit/MailpitBuilder.cs index e945856e2..64a34f7d2 100644 --- a/src/Testcontainers.Mailpit/MailpitBuilder.cs +++ b/src/Testcontainers.Mailpit/MailpitBuilder.cs @@ -1,5 +1,3 @@ -using System.Net; - namespace Testcontainers.Mailpit; /// @@ -7,17 +5,45 @@ namespace Testcontainers.Mailpit; 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; + [Obsolete("This constant is obsolete and will be removed in the future. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.")] + public const string MailpitImage = "axllent/mailpit:v1.30"; + + public const ushort SmtpPort = 1025; + + public const ushort WebPort = 8025; /// /// Initializes a new instance of the class. /// + [Obsolete("This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.")] public MailpitBuilder() + :this(MailpitImage) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The full Docker image name, including the image repository and tag (e.g., axllent/mailpit:v1.30). + /// + /// Docker image tags available at . + /// + public MailpitBuilder(string image) + : this(new DockerImage(image)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// An instance that specifies the Docker image to be used for the container builder configuration. + /// + /// Docker image tags available at . + /// + public MailpitBuilder(IImage image) : this(new MailpitConfiguration()) { - DockerResourceConfiguration = Init().DockerResourceConfiguration; + DockerResourceConfiguration = Init().WithImage(image).DockerResourceConfiguration; } /// @@ -34,55 +60,48 @@ private MailpitBuilder(MailpitConfiguration resourceConfiguration) protected override MailpitConfiguration DockerResourceConfiguration { get; } /// - /// Sets the Mailpit MP_SMTP_AUTH config. Also sets MP_SMTP_AUTH_ALLOW_INSECURE to true. + /// Sets the Mailpit MP_SMTP_AUTH config. /// - /// List of credentials to be used in SMTP authentication - /// A configured instance of . - public MailpitBuilder WithSmtpAuthCredentials( - List authCredentials - ) + /// The credentials to be used in SMTP authentication. + /// + /// When , the MP_SMTP_AUTH_ALLOW_INSECURE config is set to true to allow insecure PLAIN and LOGIN SMTP authentication. + /// When , a self-signed certificate is used. Its subject and issuer are CN=localhost, O=Mailpit self-signed certificate. + /// + /// A configured instance of . + public MailpitBuilder WithSmtpAuthCredentials(NetworkCredential credentials, bool allowInsecure) { - 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"); - } + if (credentials == null) + { + throw new ArgumentNullException(nameof(credentials)); + } - /// - /// 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"); + if (credentials.UserName.Contains(":")) + { + throw new ArgumentException("The UserName cannot contain a colon (:) character.", nameof(credentials)); + } + + // https://mailpit.axllent.org/docs/configuration/smtp/#adding-smtp-authentication + var builder = Merge(DockerResourceConfiguration, new MailpitConfiguration(smtpAuthCredentials: credentials, smtpAuthAllowInsecure: allowInsecure)) + .WithEnvironment("MP_SMTP_AUTH", $"{credentials.UserName}:{credentials.Password}"); + + return allowInsecure + ? builder + .WithEnvironment("MP_SMTP_AUTH_ALLOW_INSECURE", "1") + : builder + // https://mailpit.axllent.org/docs/configuration/certificates/#auto-generate-self-signed-certificates + .WithEnvironment("MP_SMTP_TLS_CERT", "sans:localhost") + .WithEnvironment("MP_SMTP_TLS_KEY", "sans:localhost"); } /// /// 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 . + /// The maximum number of messages to set. + /// A configured instance of . public MailpitBuilder WithMaxMessages(uint maxMessages) { - return Merge( - DockerResourceConfiguration, - new MailpitConfiguration(maxMessages: maxMessages) - ) + return Merge(DockerResourceConfiguration, new MailpitConfiguration(maxMessages: maxMessages)) .WithEnvironment("MP_MAX_MESSAGES", maxMessages.ToString()); } @@ -90,43 +109,22 @@ public MailpitBuilder WithMaxMessages(uint maxMessages) public override MailpitContainer Build() { Validate(); - return new MailpitContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + return new MailpitContainer(DockerResourceConfiguration); } /// 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(); + .WithPortBinding(SmtpPort, true) + .WithPortBinding(WebPort, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => + // https://mailpit.axllent.org/docs/integration/healthcheck/ + request.ForPort(WebPort).ForPath("/readyz"))); } /// - protected override MailpitBuilder Clone( - IResourceConfiguration resourceConfiguration - ) + protected override MailpitBuilder Clone(IResourceConfiguration resourceConfiguration) { return Merge(DockerResourceConfiguration, new MailpitConfiguration(resourceConfiguration)); } @@ -138,10 +136,7 @@ protected override MailpitBuilder Clone(IContainerConfiguration resourceConfigur } /// - protected override MailpitBuilder Merge( - MailpitConfiguration oldValue, - MailpitConfiguration newValue - ) + protected override MailpitBuilder Merge(MailpitConfiguration oldValue, MailpitConfiguration newValue) { return new MailpitBuilder(new MailpitConfiguration(oldValue, newValue)); } diff --git a/src/Testcontainers.Mailpit/MailpitConfiguration.cs b/src/Testcontainers.Mailpit/MailpitConfiguration.cs index 117f937ea..b8f15b760 100644 --- a/src/Testcontainers.Mailpit/MailpitConfiguration.cs +++ b/src/Testcontainers.Mailpit/MailpitConfiguration.cs @@ -4,27 +4,13 @@ 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 - ) + /// Username and password for SMTP authentication. The username must not contain a : character. + /// Typically, STARTTLS is enforced for all SMTP authentication. This option allows insecure PLAIN & LOGIN SMTP authentication. + /// 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 MailpitConfiguration(NetworkCredential smtpAuthCredentials = null, bool smtpAuthAllowInsecure = true, uint maxMessages = 100) { SmtpAuthCredentials = smtpAuthCredentials; SmtpAuthAllowInsecure = smtpAuthAllowInsecure; @@ -35,9 +21,7 @@ public MailpitConfiguration( /// Initializes a new instance of the class. /// /// The Docker resource configuration. - public MailpitConfiguration( - IResourceConfiguration resourceConfiguration - ) + public MailpitConfiguration(IResourceConfiguration resourceConfiguration) : base(resourceConfiguration) { // Passes the configuration upwards to the base implementations to create an updated immutable copy. @@ -71,24 +55,18 @@ public MailpitConfiguration(MailpitConfiguration resourceConfiguration) public MailpitConfiguration(MailpitConfiguration oldValue, MailpitConfiguration newValue) : base(oldValue, newValue) { - SmtpAuthCredentials = BuildConfiguration.Combine( - oldValue.SmtpAuthCredentials, - newValue.SmtpAuthCredentials - ); - SmtpAuthAllowInsecure = BuildConfiguration.Combine( - oldValue.SmtpAuthAllowInsecure, - newValue.SmtpAuthAllowInsecure - ); + SmtpAuthCredentials = BuildConfiguration.Combine(oldValue.SmtpAuthCredentials, newValue.SmtpAuthCredentials); + SmtpAuthAllowInsecure = BuildConfiguration.Combine(oldValue.SmtpAuthAllowInsecure, newValue.SmtpAuthAllowInsecure); + MaxMessages = BuildConfiguration.Combine(oldValue.MaxMessages, newValue.MaxMessages); } /// - /// A list of usernames and passwords for SMTP authentication. - /// See Mailpit docs for more information. + /// Username and password for SMTP authentication. The username must not contain a : character. /// - public List SmtpAuthCredentials { get; } + public NetworkCredential SmtpAuthCredentials { get; } /// - /// Typically STARTTLS is enforced for all SMTP authentication. This option allows insecure PLAIN & LOGIN SMTP authentication. + /// Typically, STARTTLS is enforced for all SMTP authentication. This option allows insecure PLAIN & LOGIN SMTP authentication. /// public bool SmtpAuthAllowInsecure { get; } diff --git a/src/Testcontainers.Mailpit/MailpitContainer.cs b/src/Testcontainers.Mailpit/MailpitContainer.cs index 151f5876a..c9747354c 100644 --- a/src/Testcontainers.Mailpit/MailpitContainer.cs +++ b/src/Testcontainers.Mailpit/MailpitContainer.cs @@ -8,23 +8,21 @@ 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) { } + public MailpitContainer(MailpitConfiguration configuration) + : base(configuration) + { + } /// - /// SMTP server port. + /// The SMTP server port. /// - public ushort SmtpPort - { - get => GetMappedPublicPort(MailpitBuilder.MAILPIT_SMTP_PORT); - } + public ushort SmtpPort => GetMappedPublicPort(MailpitBuilder.SmtpPort); /// - /// Web API server port. + /// Gets the web server address of the user interface. Can also be used as the base URL for the REST API. /// - public ushort ApiPort + public string GetWebAddress() { - get => GetMappedPublicPort(MailpitBuilder.MAILPIT_API_PORT); + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(MailpitBuilder.WebPort)).ToString(); } } diff --git a/src/Testcontainers.Mailpit/Usings.cs b/src/Testcontainers.Mailpit/Usings.cs index f889bad0a..539e5bac3 100644 --- a/src/Testcontainers.Mailpit/Usings.cs +++ b/src/Testcontainers.Mailpit/Usings.cs @@ -1,10 +1,8 @@ global using System; -global using System.Collections.Generic; -global using System.Linq; +global using System.Net; 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 +global using DotNet.Testcontainers.Images; +global using JetBrains.Annotations; \ No newline at end of file diff --git a/tests/Testcontainers.Mailpit.Tests/.runs-on b/tests/Testcontainers.Mailpit.Tests/.runs-on new file mode 100644 index 000000000..d0395e498 --- /dev/null +++ b/tests/Testcontainers.Mailpit.Tests/.runs-on @@ -0,0 +1 @@ +ubuntu-24.04 \ No newline at end of file diff --git a/tests/Testcontainers.Mailpit.Tests/Dockerfile b/tests/Testcontainers.Mailpit.Tests/Dockerfile new file mode 100644 index 000000000..4ae3087e8 --- /dev/null +++ b/tests/Testcontainers.Mailpit.Tests/Dockerfile @@ -0,0 +1 @@ +FROM axllent/mailpit:v1.30@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6 \ No newline at end of file diff --git a/tests/Testcontainers.Mailpit.Tests/MailpitContainerTest.cs b/tests/Testcontainers.Mailpit.Tests/MailpitContainerTest.cs index 76b77be7b..bc7258729 100644 --- a/tests/Testcontainers.Mailpit.Tests/MailpitContainerTest.cs +++ b/tests/Testcontainers.Mailpit.Tests/MailpitContainerTest.cs @@ -1,76 +1,115 @@ -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 +public abstract partial class MailpitContainerTest(MailpitContainerTest.MailpitFixture fixture) { - 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) + var from = new MailboxAddress("The Sender", "sender@mailpit-testcontainers.com"); + var to = new MailboxAddress("The Receiver", "receiver@mailpit-testcontainers.com"); + var message = new MimeMessage { + From = { from }, + To = { 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) + Body = new TextPart { Text = "This is just a test message, it doesn't have much going on.\n\nCheers,\n\nSender" }, }; // When - await smtpClient.SendMailAsync(message); + var messageId = await fixture.SendMessageAsync(message, TestContext.Current.CancellationToken); // Then - var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; - client.DefaultRequestHeaders.Accept.Clear(); - client.DefaultRequestHeaders.Accept.Add( - new MediaTypeWithQualityHeaderValue("application/json") - ); + var response = await fixture.GetMessageAsync(messageId, TestContext.Current.CancellationToken); + Assert.Equal(message.Subject, response.Subject); + Assert.Equal(from.Address, response.From.Address); + Assert.Equal(from.Name, response.From.Name); + Assert.Equal(to.Address, response.To[0].Address); + Assert.Equal(to.Name, response.To[0].Name); + } - 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); + public partial class MailpitFixture(IMessageSink messageSink) + : ContainerFixture(messageSink) + { + protected override MailpitBuilder Configure() + => new(TestSession.GetImageFromDockerfile()); - var jsonString = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + [GeneratedRegex("queued as (.+)")] + private static partial Regex QueuedMessage(); - var responseBody = JsonConvert.DeserializeObject(jsonString); + [CanBeNull] + protected virtual ICredentials Credentials => null; - Assert.Equal(1, responseBody["messages_count"]); + public async Task SendMessageAsync(MimeMessage message, CancellationToken cancellationToken) + { + using var smtpClient = new SmtpClient(); + smtpClient.ServerCertificateValidationCallback = (_, certificate, _, _) => certificate?.Issuer == "CN=localhost, O=Mailpit self-signed certificate"; + await smtpClient.ConnectAsync(Container.Hostname, Container.SmtpPort, SecureSocketOptions.Auto, cancellationToken); + if (Credentials != null) + { + await smtpClient.AuthenticateAsync(Credentials, cancellationToken); + } + + var result = await smtpClient.SendAsync(message, cancellationToken); + + var messageId = QueuedMessage().Match(result).Groups[1].Value; + Assert.NotEmpty(messageId); + return messageId; + } + + public async Task GetMessageAsync(string messageId, CancellationToken cancellationToken) + { + using var client = new HttpClient(); + client.BaseAddress = new Uri(Container.GetWebAddress()); + return await client.GetFromJsonAsync($"/api/v1/message/{messageId}", cancellationToken); + } + + public class MailpitMessage + { + public string Subject { get; init; } + public Mailbox From { get; init; } + public Mailbox[] To { get; init; } = []; + + public class Mailbox + { + public string Address { get; init; } + public string Name { get; init; } + } + } } + + public abstract class AuthenticationMailpitFixture(IMessageSink messageSink) : MailpitFixture(messageSink) + { + protected override NetworkCredential Credentials { get; } = new NetworkCredential("user", "p@ssw0rd"); + + protected abstract bool AllowInsecure { get; } + + protected override MailpitBuilder Configure() + => base.Configure().WithSmtpAuthCredentials(Credentials, AllowInsecure); + } + + [UsedImplicitly] + public class AuthenticationSecureMailpitFixture(IMessageSink messageSink) : AuthenticationMailpitFixture(messageSink) + { + protected override bool AllowInsecure => false; + } + + [UsedImplicitly] + public class AuthenticationInsecureMailpitFixture(IMessageSink messageSink) : AuthenticationMailpitFixture(messageSink) + { + protected override bool AllowInsecure => true; + } + + [UsedImplicitly] + public sealed class DefaultConfiguration(MailpitFixture fixture) + : MailpitContainerTest(fixture), IClassFixture; + + [UsedImplicitly] + public sealed class SmtpAuthSecure(AuthenticationSecureMailpitFixture fixture) + : MailpitContainerTest(fixture), IClassFixture; + + [UsedImplicitly] + public sealed class SmtpAuthInsecure(AuthenticationInsecureMailpitFixture fixture) + : MailpitContainerTest(fixture), IClassFixture; } diff --git a/tests/Testcontainers.Mailpit.Tests/Testcontainers.Mailpit.Tests.csproj b/tests/Testcontainers.Mailpit.Tests/Testcontainers.Mailpit.Tests.csproj index 3edb6b57d..a87d7d856 100644 --- a/tests/Testcontainers.Mailpit.Tests/Testcontainers.Mailpit.Tests.csproj +++ b/tests/Testcontainers.Mailpit.Tests/Testcontainers.Mailpit.Tests.csproj @@ -1,18 +1,25 @@ - - net8.0 - false - false - - - - - - - - - - - - + + net10.0 + false + false + Exe + + + + + + + + + + + + + + + + PreserveNewest + + \ No newline at end of file diff --git a/tests/Testcontainers.Mailpit.Tests/Usings.cs b/tests/Testcontainers.Mailpit.Tests/Usings.cs index 8148d1e1f..2013ea5ac 100644 --- a/tests/Testcontainers.Mailpit.Tests/Usings.cs +++ b/tests/Testcontainers.Mailpit.Tests/Usings.cs @@ -1,5 +1,15 @@ -global using System.Data; -global using System.Data.Common; -global using System.Threading.Tasks; global using DotNet.Testcontainers.Commons; +global using JetBrains.Annotations; +global using MailKit.Net.Smtp; +global using MailKit.Security; +global using MimeKit; +global using System; +global using System.Net; +global using System.Net.Http; +global using System.Net.Http.Json; +global using System.Text.RegularExpressions; +global using System.Threading; +global using System.Threading.Tasks; +global using Testcontainers.Xunit; global using Xunit; +global using Xunit.Sdk; \ No newline at end of file