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/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..64a34f7d2 --- /dev/null +++ b/src/Testcontainers.Mailpit/MailpitBuilder.cs @@ -0,0 +1,143 @@ +namespace Testcontainers.Mailpit; + +/// +[PublicAPI] +public sealed class MailpitBuilder + : ContainerBuilder +{ + [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().WithImage(image).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. + /// + /// 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) + { + if (credentials == null) + { + throw new ArgumentNullException(nameof(credentials)); + } + + 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. + /// + /// The maximum number of messages 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); + } + + /// + protected override MailpitBuilder Init() + { + return base.Init() + .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) + { + 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..b8f15b760 --- /dev/null +++ b/src/Testcontainers.Mailpit/MailpitConfiguration.cs @@ -0,0 +1,77 @@ +namespace Testcontainers.Mailpit; + +/// +[PublicAPI] +public sealed class MailpitConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + /// 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; + 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); + MaxMessages = BuildConfiguration.Combine(oldValue.MaxMessages, newValue.MaxMessages); + } + + /// + /// Username and password for SMTP authentication. The username must not contain a : character. + /// + public NetworkCredential 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..c9747354c --- /dev/null +++ b/src/Testcontainers.Mailpit/MailpitContainer.cs @@ -0,0 +1,28 @@ +namespace Testcontainers.Mailpit; + +/// +[PublicAPI] +public sealed class MailpitContainer : DockerContainer +{ + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public MailpitContainer(MailpitConfiguration configuration) + : base(configuration) + { + } + + /// + /// The SMTP server port. + /// + public ushort SmtpPort => GetMappedPublicPort(MailpitBuilder.SmtpPort); + + /// + /// Gets the web server address of the user interface. Can also be used as the base URL for the REST API. + /// + public string GetWebAddress() + { + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(MailpitBuilder.WebPort)).ToString(); + } +} 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..539e5bac3 --- /dev/null +++ b/src/Testcontainers.Mailpit/Usings.cs @@ -0,0 +1,8 @@ +global using System; +global using System.Net; +global using Docker.DotNet.Models; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using DotNet.Testcontainers.Images; +global using JetBrains.Annotations; \ 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/.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 new file mode 100644 index 000000000..bc7258729 --- /dev/null +++ b/tests/Testcontainers.Mailpit.Tests/MailpitContainerTest.cs @@ -0,0 +1,115 @@ +namespace Testcontainers.Mailpit; + +public abstract partial class MailpitContainerTest(MailpitContainerTest.MailpitFixture fixture) +{ + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task MailSentAndApiReturnsSuccessful() + { + // Given + 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 = new TextPart { Text = "This is just a test message, it doesn't have much going on.\n\nCheers,\n\nSender" }, + }; + + // When + var messageId = await fixture.SendMessageAsync(message, TestContext.Current.CancellationToken); + + // Then + 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); + } + + public partial class MailpitFixture(IMessageSink messageSink) + : ContainerFixture(messageSink) + { + protected override MailpitBuilder Configure() + => new(TestSession.GetImageFromDockerfile()); + + [GeneratedRegex("queued as (.+)")] + private static partial Regex QueuedMessage(); + + [CanBeNull] + protected virtual ICredentials Credentials => null; + + 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 new file mode 100644 index 000000000..a87d7d856 --- /dev/null +++ b/tests/Testcontainers.Mailpit.Tests/Testcontainers.Mailpit.Tests.csproj @@ -0,0 +1,25 @@ + + + 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 new file mode 100644 index 000000000..2013ea5ac --- /dev/null +++ b/tests/Testcontainers.Mailpit.Tests/Usings.cs @@ -0,0 +1,15 @@ +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