From ec696371b0c47c089353bfc12633c02ebf7d3866 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 14:01:32 +0300 Subject: [PATCH 01/24] Add Posta with PostgreSQL and Redis integration - Introduced `CommunityToolkit.Aspire.Hosting.Posta` project for integrating Posta with PostgreSQL and Redis. - Added extension methods for configuring Posta containers, health checks, and resource dependencies. - Created test projects for validating Posta container configuration and health checks. - Added example projects under `examples/posta` showcasing Posta integration with PostgreSQL and Redis. - Updated solution file and package references to include the new Posta project. --- CommunityToolkit.Aspire.slnx | 7 +- README.md | 6 + ...oolkit.Aspire.Hosting.Posta.AppHost.csproj | 17 ++ .../Program.cs | 12 + ...mmunityToolkit.Aspire.Hosting.Posta.csproj | 19 ++ .../PostaResource.cs | 61 +++++ .../README.md | 63 +++++ .../AppHostTests.cs | 26 ++ ...yToolkit.Aspire.Hosting.Posta.Tests.csproj | 14 + .../ContainerResourceCreationTests.cs | 257 ++++++++++++++++++ 10 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/CommunityToolkit.Aspire.Hosting.Posta.AppHost.csproj create mode 100644 examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/CommunityToolkit.Aspire.Hosting.Posta.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/README.md create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/AppHostTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/CommunityToolkit.Aspire.Hosting.Posta.Tests.csproj create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index f05008d80..3c8374f7c 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -152,6 +152,9 @@ + + + @@ -265,6 +268,7 @@ + @@ -339,6 +343,7 @@ + @@ -379,4 +384,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 3abed65fe..5c309b91d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ This repository contains the source code for the Aspire Community Toolkit, a col | - **Learn More**: [`Hosting.Bitwarden.SecretManager`][bitwarden-secret-manager-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager][bitwarden-secret-manager-hosting-shields]][bitwarden-secret-manager-hosting-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager][bitwarden-secret-manager-hosting-shields-preview]][bitwarden-secret-manager-hosting-nuget-preview] | A hosting integration for Bitwarden Secrets Manager projects and managed secrets. | | - **Learn More**: [`Bitwarden.SecretManager`][bitwarden-secret-manager-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Bitwarden.SecretManager][bitwarden-secret-manager-client-shields]][bitwarden-secret-manager-client-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Bitwarden.SecretManager][bitwarden-secret-manager-client-shields-preview]][bitwarden-secret-manager-client-nuget-preview] | A client integration for authenticating and using the Bitwarden Secrets Manager SDK from Aspire applications. | | - **Learn More**: [`Hosting.Perl`][perl-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Perl][perl-shields]][perl-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Perl][perl-shields-preview]][perl-nuget-preview] | A hosting integration for Perl scripts and APIs. | +| - **Learn More**: [`Hosting.Posta`][posta-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Posta][posta-shields]][posta-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Posta][posta-shields-preview]][posta-nuget-preview] | An Aspire hosting integration for the [Posta](https://docs.goposta.dev/) self-hosted email delivery platform. | | - **Learn More**: [`Hosting.Python.Extensions`][python-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Python.Extensions][python-ext-shields]][python-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Python.Extensions][python-ext-shields-preview]][python-ext-nuget-preview] | An integration that contains some additional extensions for running python applications | | - **Learn More**: [`Hosting.KurrentDB`][kurrentdb-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.KurrentDB][kurrentdb-shields]][kurrentdb-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.KurrentDB][kurrentdb-shields-preview]][kurrentdb-nuget-preview] | An Aspire hosting integration leveraging the [KurrentDB](https://www.kurrent.io) container. | | - **Learn More**: [`KurrentDB`][kurrentdb-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.KurrentDB][kurrentdb-client-shields]][kurrentdb-client-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.KurrentDB][kurrentdb-client-shields-preview]][kurrentdb-client-nuget-preview] | An Aspire client integration for the [KurrentDB](https://github.com/kurrent-io/KurrentDB-Client-Dotnet) package. | @@ -178,6 +179,11 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) [perl-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Perl/ [perl-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Perl?label=nuget%20(preview) [perl-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Perl/absoluteLatest +[posta-integration-docs]: ./src/CommunityToolkit.Aspire.Hosting.Posta/README.md +[posta-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.Posta +[posta-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Posta/ +[posta-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Posta?label=nuget%20(preview) +[posta-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Posta/absoluteLatest [python-ext-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-python-extensions [python-ext-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.Python.Extensions [python-ext-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Python.Extensions/ diff --git a/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/CommunityToolkit.Aspire.Hosting.Posta.AppHost.csproj b/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/CommunityToolkit.Aspire.Hosting.Posta.AppHost.csproj new file mode 100644 index 000000000..8ab6b6f05 --- /dev/null +++ b/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/CommunityToolkit.Aspire.Hosting.Posta.AppHost.csproj @@ -0,0 +1,17 @@ + + + + Exe + + + + + + + + + + + + + diff --git a/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs b/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs new file mode 100644 index 000000000..d514c965a --- /dev/null +++ b/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs @@ -0,0 +1,12 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var postgresPassword = builder.AddParameter("postgres-password", "posta"); +var postgres = builder.AddPostgres("postgres", password: postgresPassword); +var database = postgres.AddDatabase("posta-db", "posta"); +var redis = builder.AddRedis("redis"); + +builder.AddPosta("posta") + .WithReference(database) + .WithReference(redis); + +builder.Build().Run(); diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/CommunityToolkit.Aspire.Hosting.Posta.csproj b/src/CommunityToolkit.Aspire.Hosting.Posta/CommunityToolkit.Aspire.Hosting.Posta.csproj new file mode 100644 index 000000000..cc997daa8 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/CommunityToolkit.Aspire.Hosting.Posta.csproj @@ -0,0 +1,19 @@ + + + + An Aspire hosting integration for Posta. + hosting posta email smtp delivery + true + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaResource.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaResource.cs new file mode 100644 index 000000000..7ab14ed98 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaResource.cs @@ -0,0 +1,61 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Resource for the Posta email delivery platform. +/// +/// The name of the resource. +/// A parameter that contains the JWT signing secret. +/// A parameter that contains the initial admin password. +[AspireExport(ExposeProperties = true)] +public sealed class PostaResource(string name, ParameterResource jwtSecret, ParameterResource adminPassword) + : ContainerResource(name), IResourceWithConnectionString +{ + internal const int HttpEndpointPort = 9000; + internal const string HttpEndpointName = "http"; + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the primary HTTP endpoint for the Posta server. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, HttpEndpointName); + + /// + /// Gets the host endpoint reference for the HTTP endpoint. + /// + public EndpointReferenceExpression Host => PrimaryEndpoint.Property(EndpointProperty.Host); + + /// + /// Gets the port endpoint reference for the HTTP endpoint. + /// + public EndpointReferenceExpression Port => PrimaryEndpoint.Property(EndpointProperty.Port); + + /// + /// Gets the parameter that contains the JWT signing secret. + /// + public ParameterResource JwtSecretParameter { get; } = jwtSecret ?? throw new ArgumentNullException(nameof(jwtSecret)); + + /// + /// Gets the parameter that contains the initial admin password. + /// + public ParameterResource AdminPasswordParameter { get; } = adminPassword ?? throw new ArgumentNullException(nameof(adminPassword)); + + /// + /// Connection string for the Posta HTTP API in the form of Endpoint=http://host:port. + /// + public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create( + $"Endpoint={PrimaryEndpoint.Property(EndpointProperty.Url)}"); + + /// + /// Gets the connection URI expression for the Posta HTTP API. + /// + public ReferenceExpression UriExpression => ReferenceExpression.Create($"{PrimaryEndpoint.Property(EndpointProperty.Url)}"); + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() + { + yield return new("Host", ReferenceExpression.Create($"{Host}")); + yield return new("Port", ReferenceExpression.Create($"{Port}")); + yield return new("Uri", UriExpression); + } +} + diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md new file mode 100644 index 000000000..27e16b022 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md @@ -0,0 +1,63 @@ +# Posta hosting integration + +Use this integration to model, configure, and orchestrate the [Posta](https://docs.goposta.dev/) self-hosted email delivery platform in an Aspire AppHost. + +## Getting started + +Install the package in your AppHost project: + +```bash +aspire add CommunityToolkit.Aspire.Hosting.Posta +``` + +## Usage example + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var postgres = builder.AddPostgres("postgres"); +var database = postgres.AddDatabase("posta-db", "posta"); +var redis = builder.AddRedis("redis"); + +var posta = builder.AddPosta("posta", options => + { + options.Environment = "production"; + options.WebUrl = "https://posta.example.com"; + options.CorsOrigins = "https://app.example.com"; + options.MetricsEnabled = true; + }) + .WithReference(database) + .WithReference(redis); + +builder.AddProject("api") + .WithReference(posta) + .WaitFor(posta); + +builder.Build().Run(); +``` + +Posta stores data in PostgreSQL, uses Redis for queueing, and runs the embedded worker in the main container by default. Use `WithReference` to connect Posta to Aspire-managed PostgreSQL and Redis resources. For externally managed infrastructure, configure `PostaOptions.DatabaseUrl`, `PostaOptions.RedisAddress`, and `PostaOptions.RedisPassword`. + +Use the `PostaOptions` callback to configure Posta environment variables such as rate limits, web URLs, OAuth, blob storage, system SMTP, inbound email, email verification, and advanced switches. Secret values such as SMTP passwords, S3 keys, OAuth client secrets, encryption keys, inbound webhook secrets, `POSTA_DB_URL`, and Redis password are represented as Aspire parameters. + +## Connection Properties + +The Posta resource exposes the following connection properties: + +| Name | Format | +| --- | --- | +| `Host` | Posta HTTP API host | +| `Port` | Posta HTTP API port | +| `Uri` | `http://{host}:{port}` | + +These properties become environment variables named `[RESOURCE]__HOST`, `[RESOURCE]__PORT`, and `[RESOURCE]__URI` when referenced by another resource. The connection string uses `Endpoint=http://{host}:{port}`. + +## Additional documentation + +* [Posta documentation](https://docs.goposta.dev/) +* [Posta installation guide](https://docs.goposta.dev/docs/getting-started/installation/) +* [Posta configuration](https://docs.goposta.dev/docs/getting-started/configuration/) + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/AppHostTests.cs new file mode 100644 index 000000000..ef1f4505c --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/AppHostTests.cs @@ -0,0 +1,26 @@ +using Aspire.Components.Common.Tests; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Posta.Tests; + +[RequiresDocker] +public class AppHostTests(AspireIntegrationTestFixture fixture) + : IClassFixture> +{ + private const string ResourceName = "posta"; + + [Fact] + public async Task ResourceStartsAndHealthEndpointResponds() + { + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(ResourceName) + .WaitAsync(TimeSpan.FromMinutes(3)); + + var httpEndpoint = fixture.GetEndpoint(ResourceName, "http"); + using var httpClient = new HttpClient { BaseAddress = httpEndpoint }; + + using var response = await httpClient.GetAsync("/healthz", TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/CommunityToolkit.Aspire.Hosting.Posta.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/CommunityToolkit.Aspire.Hosting.Posta.Tests.csproj new file mode 100644 index 000000000..49fdce0d8 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/CommunityToolkit.Aspire.Hosting.Posta.Tests.csproj @@ -0,0 +1,14 @@ + + + + false + true + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs new file mode 100644 index 000000000..5e445b6ad --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs @@ -0,0 +1,257 @@ +using Aspire.Hosting; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Posta.Tests; + +public class ContainerResourceCreationTests +{ + [Fact] + public void AddPostaThrowsWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + + var exception = Assert.Throws(() => builder.AddPosta("posta")); + Assert.Equal("builder", exception.ParamName); + } + + [Fact] + public void AddPostaThrowsWhenNameIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddPosta(null!)); + } + + [Fact] + public void WithReferenceThrowsWhenDatabaseIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + var posta = builder.AddPosta("posta"); + + Assert.Throws(() => posta.WithReference((IResourceBuilder)null!)); + } + + [Fact] + public void WithReferenceThrowsWhenRedisIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + var posta = builder.AddPosta("posta"); + + Assert.Throws(() => posta.WithReference((IResourceBuilder)null!)); + } + + [Fact] + public void AddPostaSetsContainerDetailsOnResource() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddPosta("posta"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("posta", resource.Name); + Assert.True(resource.TryGetLastAnnotation(out ContainerImageAnnotation? imageAnnotations)); + Assert.Equal(PostaContainerImageTags.Tag, imageAnnotations.Tag); + Assert.NotEqual("latest", imageAnnotations.Tag); + Assert.Equal(PostaContainerImageTags.Image, imageAnnotations.Image); + Assert.Equal(PostaContainerImageTags.Registry, imageAnnotations.Registry); + } + + [Fact] + public void AddPostaSetsEndpointDetailsOnResource() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddPosta("posta", port: 9001); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var endpoint = Assert.Single(resource.Annotations.OfType(), x => x.Name == PostaResource.HttpEndpointName); + Assert.Equal("http", endpoint.UriScheme); + Assert.Equal(PostaResource.HttpEndpointPort, endpoint.TargetPort); + Assert.Equal(9001, endpoint.Port); + } + + [Fact] + public void AddPostaRegistersHealthChecks() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddPosta("posta"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out var annotations)); + Assert.Equal(2, annotations.Count()); + } + + [Fact] + public async Task AddPostaConfiguresPostaEnvironmentVariables() + { + var builder = DistributedApplication.CreateBuilder(); + var postgres = builder.AddPostgres("postgres"); + var database = postgres.AddDatabase("posta-db", "posta"); + var redis = builder.AddRedis("redis"); + var databaseUrl = builder.AddParameter("posta-db-url", "postgres://example"); + var redisPassword = builder.AddParameter("posta-redis-password", "redis-secret"); + var googleSecret = builder.AddParameter("posta-google-secret", "google-secret"); + var s3AccessKey = builder.AddParameter("posta-s3-access-key", "s3-access"); + var s3SecretKey = builder.AddParameter("posta-s3-secret-key", "s3-secret"); + var encryptionKey = builder.AddParameter("posta-encryption-key", "encryption-secret"); + var smtpPassword = builder.AddParameter("posta-smtp-password", "smtp-secret"); + var inboundWebhookSecret = builder.AddParameter("posta-inbound-secret", "inbound-secret"); + + var posta = builder.AddPosta("posta", options => + { + options.Environment = "dev"; + options.DevMode = true; + options.AuthRateLimitEnabled = false; + options.RateLimitHourly = 500; + options.RateLimitDaily = 5000; + options.OpenApiDocs = false; + options.MetricsEnabled = true; + options.WebDir = "web/dist"; + options.WebUrl = "https://posta.example.com"; + options.ApiUrl = "https://api.posta.example.com"; + options.CorsOrigins = "https://app.example.com"; + options.EmbeddedWorker = false; + options.WorkerConcurrency = 20; + options.WorkerMaxRetries = 7; + options.WebhookMaxRetries = 4; + options.WebhookTimeoutSeconds = 30; + options.WebhookProxyUrl = "http://proxy.example.com"; + options.GoogleOAuthClientId = "google-client-id"; + options.GoogleOAuthClientSecret = googleSecret; + options.OAuthCallbackUrl = "https://posta.example.com/oauth/callback"; + options.BlobProvider = "s3"; + options.BlobS3Endpoint = "https://s3.example.com"; + options.BlobS3Region = "eu-central-1"; + options.BlobS3Bucket = "posta"; + options.BlobS3AccessKey = s3AccessKey; + options.BlobS3SecretKey = s3SecretKey; + options.BlobS3UseSsl = false; + options.BlobS3PathStyle = true; + options.BlobFileSystemPath = "/posta/attachments"; + options.EncryptionKey = encryptionKey; + options.SystemSmtpHost = "smtp.example.com"; + options.SystemSmtpPort = 2525; + options.SystemSmtpUsername = "notifications@example.com"; + options.SystemSmtpPassword = smtpPassword; + options.SystemSmtpFrom = "notifications@example.com"; + options.SystemSmtpEncryption = "ssl"; + options.InboundEnabled = true; + options.InboundSmtpHost = "127.0.0.1"; + options.InboundSmtpPort = 2526; + options.InboundHostname = "mx.example.com"; + options.InboundMaxMessageSize = 12345; + options.InboundMaxAttachSize = 6789; + options.InboundWebhookSecret = inboundWebhookSecret; + options.InboundTlsMode = "starttls"; + options.InboundTlsCertFile = "/certs/fullchain.pem"; + options.InboundTlsKeyFile = "/certs/privkey.pem"; + options.InboundSmtpRateLimit = 10; + options.InboundSmtpRateWindow = 20; + options.EmailVerificationRequired = true; + options.AutoSuppressOnReject = false; + options.EmailVerifyEnabled = false; + options.EmailVerifyCacheTtlHours = 12; + options.EmailVerifyMxCacheTtlHours = 6; + options.EmailVerifyRateHourly = 42; + options.AllowDowngrade = true; + options.PlanEnforcement = true; + options.DatabaseUrl = databaseUrl; + options.RedisPassword = redisPassword; + options.RedisAddress = "redis.example.com:6379"; + }) + .WithReference(database) + .WithReference(redis, redisPassword); + + Assert.True(posta.Resource.TryGetAnnotationsOfType(out var annotations)); + + var context = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext( + new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run))); + + foreach (var annotation in annotations) + { + await annotation.Callback(context); + } + + var env = context.EnvironmentVariables; + + Assert.Equal("9000", env["POSTA_PORT"]); + Assert.Equal("dev", env["POSTA_ENV"]); + Assert.Equal("true", env["POSTA_DEV_MODE"]); + Assert.Equal("false", env["POSTA_AUTH_RATE_LIMIT_ENABLED"]); + Assert.Equal("500", env["POSTA_RATE_LIMIT_HOURLY"]); + Assert.Equal("5000", env["POSTA_RATE_LIMIT_DAILY"]); + Assert.Equal("false", env["POSTA_OPENAPI_DOCS"]); + Assert.Equal("true", env["POSTA_METRICS_ENABLED"]); + Assert.Equal("web/dist", env["POSTA_WEB_DIR"]); + Assert.Equal("https://posta.example.com", env["POSTA_WEB_URL"]); + Assert.Equal("https://api.posta.example.com", env["POSTA_API_URL"]); + Assert.Equal("https://app.example.com", env["POSTA_CORS_ORIGINS"]); + Assert.Equal("false", env["POSTA_EMBEDDED_WORKER"]); + Assert.Equal("20", env["POSTA_WORKER_CONCURRENCY"]); + Assert.Equal("7", env["POSTA_WORKER_MAX_RETRIES"]); + Assert.Equal("4", env["POSTA_WEBHOOK_MAX_RETRIES"]); + Assert.Equal("30", env["POSTA_WEBHOOK_TIMEOUT_SECS"]); + Assert.Equal("http://proxy.example.com", env["POSTA_WEBHOOK_PROXY_URL"]); + Assert.Equal("google-client-id", env["POSTA_GOOGLE_OAUTH_CLIENT_ID"]); + Assert.Same(googleSecret.Resource, env["POSTA_GOOGLE_OAUTH_CLIENT_SECRET"]); + Assert.Equal("https://posta.example.com/oauth/callback", env["POSTA_OAUTH_CALLBACK_URL"]); + Assert.Equal("s3", env["POSTA_BLOB_PROVIDER"]); + Assert.Equal("https://s3.example.com", env["POSTA_BLOB_S3_ENDPOINT"]); + Assert.Equal("eu-central-1", env["POSTA_BLOB_S3_REGION"]); + Assert.Equal("posta", env["POSTA_BLOB_S3_BUCKET"]); + Assert.Same(s3AccessKey.Resource, env["POSTA_BLOB_S3_ACCESS_KEY"]); + Assert.Same(s3SecretKey.Resource, env["POSTA_BLOB_S3_SECRET_KEY"]); + Assert.Equal("false", env["POSTA_BLOB_S3_USE_SSL"]); + Assert.Equal("true", env["POSTA_BLOB_S3_PATH_STYLE"]); + Assert.Equal("/posta/attachments", env["POSTA_BLOB_FS_PATH"]); + Assert.Same(encryptionKey.Resource, env["POSTA_ENCRYPTION_KEY"]); + Assert.Equal("smtp.example.com", env["POSTA_SYSTEM_SMTP_HOST"]); + Assert.Equal("2525", env["POSTA_SYSTEM_SMTP_PORT"]); + Assert.Equal("notifications@example.com", env["POSTA_SYSTEM_SMTP_USERNAME"]); + Assert.Same(smtpPassword.Resource, env["POSTA_SYSTEM_SMTP_PASSWORD"]); + Assert.Equal("notifications@example.com", env["POSTA_SYSTEM_SMTP_FROM"]); + Assert.Equal("ssl", env["POSTA_SYSTEM_SMTP_ENCRYPTION"]); + Assert.Equal("true", env["POSTA_INBOUND_ENABLED"]); + Assert.Equal("127.0.0.1", env["POSTA_INBOUND_SMTP_HOST"]); + Assert.Equal("2526", env["POSTA_INBOUND_SMTP_PORT"]); + Assert.Equal("mx.example.com", env["POSTA_INBOUND_HOSTNAME"]); + Assert.Equal("12345", env["POSTA_INBOUND_MAX_MESSAGE_SIZE"]); + Assert.Equal("6789", env["POSTA_INBOUND_MAX_ATTACH_SIZE"]); + Assert.Same(inboundWebhookSecret.Resource, env["POSTA_INBOUND_WEBHOOK_SECRET"]); + Assert.Equal("starttls", env["POSTA_INBOUND_TLS_MODE"]); + Assert.Equal("/certs/fullchain.pem", env["POSTA_INBOUND_TLS_CERT_FILE"]); + Assert.Equal("/certs/privkey.pem", env["POSTA_INBOUND_TLS_KEY_FILE"]); + Assert.Equal("10", env["POSTA_INBOUND_SMTP_RATE_LIMIT"]); + Assert.Equal("20", env["POSTA_INBOUND_SMTP_RATE_WINDOW"]); + Assert.Equal("true", env["POSTA_EMAIL_VERIFICATION_REQUIRED"]); + Assert.Equal("false", env["POSTA_AUTO_SUPPRESS_ON_REJECT"]); + Assert.Equal("false", env["POSTA_EMAIL_VERIFY_ENABLED"]); + Assert.Equal("12", env["POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS"]); + Assert.Equal("6", env["POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS"]); + Assert.Equal("42", env["POSTA_EMAIL_VERIFY_RATE_HOURLY"]); + Assert.Equal("true", env["POSTA_ALLOW_DOWNGRADE"]); + Assert.Equal("true", env["POSTA_PLAN_ENFORCEMENT"]); + Assert.Same(databaseUrl.Resource, env["POSTA_DB_URL"]); + Assert.True(env.ContainsKey("POSTA_DB_HOST")); + Assert.True(env.ContainsKey("POSTA_DB_PORT")); + Assert.True(env.ContainsKey("POSTA_DB_USER")); + Assert.True(env.ContainsKey("POSTA_DB_PASSWORD")); + Assert.Equal("posta", env["POSTA_DB_NAME"]); + Assert.Equal("disable", env["POSTA_DB_SSL_MODE"]); + Assert.True(env.ContainsKey("POSTA_REDIS_ADDR")); + Assert.NotEqual("redis.example.com:6379", env["POSTA_REDIS_ADDR"].ToString()); + Assert.Same(redisPassword.Resource, env["POSTA_REDIS_PASSWORD"]); + } +} \ No newline at end of file From aebb0b449acc194ac044614c28731690f45a1250 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 14:02:01 +0300 Subject: [PATCH 02/24] Add Posta with hosting extensions - Introduced `CommunityToolkit.Aspire.Hosting.Posta` project for integrating Posta as a containerized resource. - Added extension methods for configuring Posta containers, including support for PostgreSQL, Redis, and advanced environment options. - Defined Posta resource options, container image tags, and health check configurations. --- .../PostaContainerImageTags.cs | 13 + .../PostaHostingExtensions.cs | 271 ++++++++++++++++ .../PostaOptions.cs | 304 ++++++++++++++++++ 3 files changed, 588 insertions(+) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaContainerImageTags.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaContainerImageTags.cs new file mode 100644 index 000000000..8c5478d1e --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaContainerImageTags.cs @@ -0,0 +1,13 @@ +namespace CommunityToolkit.Aspire.Hosting.Posta; + +internal static class PostaContainerImageTags +{ + /// docker.io + public const string Registry = "docker.io"; + + /// jkaninda/posta + public const string Image = "jkaninda/posta"; + + /// 0.11.0 + public const string Tag = "0.11.0"; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs new file mode 100644 index 000000000..af2552344 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -0,0 +1,271 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Posta; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Posta to an . +/// +public static class PostaHostingExtensions +{ + /// + /// Adds a Posta container resource to the . + /// + /// Adds a Posta container resource + /// The to which the Posta resource will be added. + /// The name of the Posta container resource. + /// Optional parameter used as the Posta JWT signing secret. + /// Optional parameter used as the initial Posta admin password. + /// The initial Posta admin account email. + /// Optional host port for the Posta HTTP API and dashboard. + /// A reference to the for further resource configuration. + [AspireExport] + public static IResourceBuilder AddPosta( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder? jwtSecret = null, + IResourceBuilder? adminPassword = null, + string adminEmail = "admin@example.com", + int? port = null) + { + return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions: null); + } + + /// + /// Adds a Posta container resource to the with additional Posta environment configuration. + /// + /// The to which the Posta resource will be added. + /// The name of the Posta container resource. + /// A delegate that configures Posta environment variables. + /// Optional parameter used as the Posta JWT signing secret. + /// Optional parameter used as the initial Posta admin password. + /// The initial Posta admin account email. + /// Optional host port for the Posta HTTP API and dashboard. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the parameter-based overload instead.")] + public static IResourceBuilder AddPosta( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + Action configureOptions, + IResourceBuilder? jwtSecret = null, + IResourceBuilder? adminPassword = null, + string adminEmail = "admin@example.com", + int? port = null) + { + ArgumentNullException.ThrowIfNull(configureOptions); + + return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions); + } + + /// + /// Configures the PostgreSQL database used by Posta. + /// + /// The Posta resource builder. + /// The PostgreSQL database resource used by Posta. + /// A reference to the for further resource configuration. + [AspireExport("withPostgresReference", MethodName = "withPostgresReference")] + public static IResourceBuilder WithReference( + this IResourceBuilder builder, + IResourceBuilder database) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(database); + + return builder + .WithEnvironment(context => + { + var postgres = database.Resource.Parent; + + context.EnvironmentVariables["POSTA_DB_HOST"] = postgres.PrimaryEndpoint.Property(EndpointProperty.Host); + context.EnvironmentVariables["POSTA_DB_PORT"] = postgres.PrimaryEndpoint.Property(EndpointProperty.Port); + context.EnvironmentVariables["POSTA_DB_USER"] = postgres.UserNameReference; + context.EnvironmentVariables["POSTA_DB_PASSWORD"] = postgres.PasswordParameter; + context.EnvironmentVariables["POSTA_DB_NAME"] = database.Resource.DatabaseName; + context.EnvironmentVariables["POSTA_DB_SSL_MODE"] = "disable"; + }) + .WaitFor(database); + } + + /// + /// Configures the Redis server used by Posta. + /// + /// The Posta resource builder. + /// The Redis resource used by Posta. + /// Optional Redis password parameter that overrides the referenced Redis resource password. + /// A reference to the for further resource configuration. + [AspireExport("withRedisReference", MethodName = "withRedisReference")] + public static IResourceBuilder WithReference( + this IResourceBuilder builder, + IResourceBuilder redis, + IResourceBuilder? redisPassword = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(redis); + + return builder + .WithEnvironment(context => + { + var redisResource = redis.Resource; + var redisEndpoint = redisResource.GetEndpoint("secondary"); + + context.EnvironmentVariables["POSTA_REDIS_ADDR"] = ReferenceExpression.Create($"{redisEndpoint.Property(EndpointProperty.Host)}:{redisEndpoint.Property(EndpointProperty.Port)}"); + if (redisPassword is not null) + { + context.EnvironmentVariables["POSTA_REDIS_PASSWORD"] = redisPassword.Resource; + } + else if (redisResource.PasswordParameter is not null) + { + context.EnvironmentVariables["POSTA_REDIS_PASSWORD"] = redisResource.PasswordParameter; + } + }) + .WaitFor(redis); + } + + private static IResourceBuilder AddPostaCore( + IDistributedApplicationBuilder builder, + string name, + IResourceBuilder? jwtSecret, + IResourceBuilder? adminPassword, + string adminEmail, + int? port, + Action? configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(adminEmail); + + var options = new PostaOptions(); + configureOptions?.Invoke(options); + + var jwtSecretParameter = jwtSecret?.Resource ?? + ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-jwt-secret", minLower: 1, minUpper: 1, minNumeric: 1); + var adminPasswordParameter = adminPassword?.Resource ?? + ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-admin-password", minLower: 1, minUpper: 1, minNumeric: 1); + + var resource = new PostaResource(name, jwtSecretParameter, adminPasswordParameter); + + return builder.AddResource(resource) + .WithImage(PostaContainerImageTags.Image) + .WithImageTag(PostaContainerImageTags.Tag) + .WithImageRegistry(PostaContainerImageTags.Registry) + .WithHttpEndpoint( + targetPort: PostaResource.HttpEndpointPort, + port: port, + name: PostaResource.HttpEndpointName) + .WithEnvironment(context => + { + context.EnvironmentVariables["POSTA_PORT"] = PostaResource.HttpEndpointPort.ToString(); + SetParameter(context, "POSTA_DB_URL", options.DatabaseUrl); + SetIfNotNull(context, "POSTA_REDIS_ADDR", options.RedisAddress); + if (options.RedisPassword is not null) + { + context.EnvironmentVariables["POSTA_REDIS_PASSWORD"] = options.RedisPassword.Resource; + } + context.EnvironmentVariables["POSTA_JWT_SECRET"] = resource.JwtSecretParameter; + context.EnvironmentVariables["POSTA_ADMIN_EMAIL"] = adminEmail; + context.EnvironmentVariables["POSTA_ADMIN_PASSWORD"] = resource.AdminPasswordParameter; + ConfigurePostaEnvironment(context, options); + }) + .WithHttpHealthCheck( + path: "/healthz", + statusCode: 200, + endpointName: PostaResource.HttpEndpointName) + .WithHttpHealthCheck( + path: "/readyz", + statusCode: 200, + endpointName: PostaResource.HttpEndpointName) + .WithUrlForEndpoint(PostaResource.HttpEndpointName, url => url.DisplayText = "Posta Dashboard"); + } + + private static void ConfigurePostaEnvironment(EnvironmentCallbackContext context, PostaOptions options) + { + Set(context, "POSTA_ENV", options.Environment); + Set(context, "POSTA_DEV_MODE", options.DevMode); + Set(context, "POSTA_AUTH_RATE_LIMIT_ENABLED", options.AuthRateLimitEnabled); + Set(context, "POSTA_RATE_LIMIT_HOURLY", options.RateLimitHourly); + Set(context, "POSTA_RATE_LIMIT_DAILY", options.RateLimitDaily); + Set(context, "POSTA_OPENAPI_DOCS", options.OpenApiDocs); + Set(context, "POSTA_METRICS_ENABLED", options.MetricsEnabled); + SetIfNotNull(context, "POSTA_WEB_DIR", options.WebDir); + SetIfNotNull(context, "POSTA_WEB_URL", options.WebUrl); + SetIfNotNull(context, "POSTA_API_URL", options.ApiUrl); + Set(context, "POSTA_CORS_ORIGINS", options.CorsOrigins); + Set(context, "POSTA_EMBEDDED_WORKER", options.EmbeddedWorker); + Set(context, "POSTA_WORKER_CONCURRENCY", options.WorkerConcurrency); + Set(context, "POSTA_WORKER_MAX_RETRIES", options.WorkerMaxRetries); + Set(context, "POSTA_WEBHOOK_MAX_RETRIES", options.WebhookMaxRetries); + Set(context, "POSTA_WEBHOOK_TIMEOUT_SECS", options.WebhookTimeoutSeconds); + SetIfNotNull(context, "POSTA_WEBHOOK_PROXY_URL", options.WebhookProxyUrl); + SetIfNotNull(context, "POSTA_GOOGLE_OAUTH_CLIENT_ID", options.GoogleOAuthClientId); + SetParameter(context, "POSTA_GOOGLE_OAUTH_CLIENT_SECRET", options.GoogleOAuthClientSecret); + SetIfNotNull(context, "POSTA_OAUTH_CALLBACK_URL", options.OAuthCallbackUrl); + SetIfNotNull(context, "POSTA_BLOB_PROVIDER", options.BlobProvider); + SetIfNotNull(context, "POSTA_BLOB_S3_ENDPOINT", options.BlobS3Endpoint); + Set(context, "POSTA_BLOB_S3_REGION", options.BlobS3Region); + SetIfNotNull(context, "POSTA_BLOB_S3_BUCKET", options.BlobS3Bucket); + SetParameter(context, "POSTA_BLOB_S3_ACCESS_KEY", options.BlobS3AccessKey); + SetParameter(context, "POSTA_BLOB_S3_SECRET_KEY", options.BlobS3SecretKey); + Set(context, "POSTA_BLOB_S3_USE_SSL", options.BlobS3UseSsl); + Set(context, "POSTA_BLOB_S3_PATH_STYLE", options.BlobS3PathStyle); + Set(context, "POSTA_BLOB_FS_PATH", options.BlobFileSystemPath); + SetParameter(context, "POSTA_ENCRYPTION_KEY", options.EncryptionKey); + SetIfNotNull(context, "POSTA_SYSTEM_SMTP_HOST", options.SystemSmtpHost); + Set(context, "POSTA_SYSTEM_SMTP_PORT", options.SystemSmtpPort); + SetIfNotNull(context, "POSTA_SYSTEM_SMTP_USERNAME", options.SystemSmtpUsername); + SetParameter(context, "POSTA_SYSTEM_SMTP_PASSWORD", options.SystemSmtpPassword); + SetIfNotNull(context, "POSTA_SYSTEM_SMTP_FROM", options.SystemSmtpFrom); + Set(context, "POSTA_SYSTEM_SMTP_ENCRYPTION", options.SystemSmtpEncryption); + Set(context, "POSTA_INBOUND_ENABLED", options.InboundEnabled); + Set(context, "POSTA_INBOUND_SMTP_HOST", options.InboundSmtpHost); + Set(context, "POSTA_INBOUND_SMTP_PORT", options.InboundSmtpPort); + Set(context, "POSTA_INBOUND_HOSTNAME", options.InboundHostname); + Set(context, "POSTA_INBOUND_MAX_MESSAGE_SIZE", options.InboundMaxMessageSize); + Set(context, "POSTA_INBOUND_MAX_ATTACH_SIZE", options.InboundMaxAttachSize); + SetParameter(context, "POSTA_INBOUND_WEBHOOK_SECRET", options.InboundWebhookSecret); + Set(context, "POSTA_INBOUND_TLS_MODE", options.InboundTlsMode); + SetIfNotNull(context, "POSTA_INBOUND_TLS_CERT_FILE", options.InboundTlsCertFile); + SetIfNotNull(context, "POSTA_INBOUND_TLS_KEY_FILE", options.InboundTlsKeyFile); + Set(context, "POSTA_INBOUND_SMTP_RATE_LIMIT", options.InboundSmtpRateLimit); + Set(context, "POSTA_INBOUND_SMTP_RATE_WINDOW", options.InboundSmtpRateWindow); + Set(context, "POSTA_EMAIL_VERIFICATION_REQUIRED", options.EmailVerificationRequired); + Set(context, "POSTA_AUTO_SUPPRESS_ON_REJECT", options.AutoSuppressOnReject); + Set(context, "POSTA_EMAIL_VERIFY_ENABLED", options.EmailVerifyEnabled); + Set(context, "POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS", options.EmailVerifyCacheTtlHours); + Set(context, "POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS", options.EmailVerifyMxCacheTtlHours); + Set(context, "POSTA_EMAIL_VERIFY_RATE_HOURLY", options.EmailVerifyRateHourly); + Set(context, "POSTA_ALLOW_DOWNGRADE", options.AllowDowngrade); + Set(context, "POSTA_PLAN_ENFORCEMENT", options.PlanEnforcement); + } + + private static void Set(EnvironmentCallbackContext context, string name, string value) + { + context.EnvironmentVariables[name] = value; + } + + private static void Set(EnvironmentCallbackContext context, string name, bool value) + { + context.EnvironmentVariables[name] = value ? "true" : "false"; + } + + private static void Set(EnvironmentCallbackContext context, string name, int value) + { + context.EnvironmentVariables[name] = value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + private static void SetIfNotNull(EnvironmentCallbackContext context, string name, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + context.EnvironmentVariables[name] = value; + } + } + + private static void SetParameter(EnvironmentCallbackContext context, string name, IResourceBuilder? parameter) + { + if (parameter is not null) + { + context.EnvironmentVariables[name] = parameter.Resource; + } + } +} + diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs new file mode 100644 index 000000000..6ef26516a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs @@ -0,0 +1,304 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Posta; + +/// +/// Configuration options for the Posta container. +/// +public sealed class PostaOptions +{ + /// + /// Gets or sets the Posta environment name. + /// + public string Environment { get; set; } = "production"; + + /// + /// Gets or sets a value indicating whether Posta stores emails without sending them. + /// + public bool DevMode { get; set; } + + /// + /// Gets or sets a value indicating whether auth rate limiting is enabled. + /// + public bool AuthRateLimitEnabled { get; set; } = true; + + /// + /// Gets or sets the maximum emails per hour per user. + /// + public int RateLimitHourly { get; set; } = 100; + + /// + /// Gets or sets the maximum emails per day per user. + /// + public int RateLimitDaily { get; set; } = 1000; + + /// + /// Gets or sets a value indicating whether Swagger UI and ReDoc are enabled. + /// + public bool OpenApiDocs { get; set; } = true; + + /// + /// Gets or sets a value indicating whether Prometheus metrics are enabled. + /// + public bool MetricsEnabled { get; set; } + + /// + /// Gets or sets the Vue dashboard build directory. + /// + public string? WebDir { get; set; } + + /// + /// Gets or sets the public dashboard URL. + /// + public string? WebUrl { get; set; } + + /// + /// Gets or sets the public API base URL advertised in the OpenAPI servers list. + /// + public string? ApiUrl { get; set; } + + /// + /// Gets or sets the comma-separated allowed CORS origins. + /// + public string CorsOrigins { get; set; } = "*"; + + /// + /// Gets or sets a value indicating whether the worker runs in the server process. + /// + public bool EmbeddedWorker { get; set; } = true; + + /// + /// Gets or sets the number of worker goroutines. + /// + public int WorkerConcurrency { get; set; } = 10; + + /// + /// Gets or sets the maximum email retry attempts. + /// + public int WorkerMaxRetries { get; set; } = 5; + + /// + /// Gets or sets the maximum webhook delivery retry attempts. + /// + public int WebhookMaxRetries { get; set; } = 3; + + /// + /// Gets or sets the webhook HTTP request timeout in seconds. + /// + public int WebhookTimeoutSeconds { get; set; } = 10; + + /// + /// Gets or sets the optional HTTP, HTTPS, or SOCKS5 proxy for webhook delivery. + /// + public string? WebhookProxyUrl { get; set; } + + /// + /// Gets or sets the Google OAuth client ID for SSO login. + /// + public string? GoogleOAuthClientId { get; set; } + + /// + /// Gets or sets the Google OAuth client secret for SSO login. + /// + public IResourceBuilder? GoogleOAuthClientSecret { get; set; } + + /// + /// Gets or sets the OAuth callback base URL. + /// + public string? OAuthCallbackUrl { get; set; } + + /// + /// Gets or sets the storage backend for attachments, either s3 or filesystem. + /// + public string? BlobProvider { get; set; } + + /// + /// Gets or sets the S3-compatible endpoint. + /// + public string? BlobS3Endpoint { get; set; } + + /// + /// Gets or sets the S3 region. + /// + public string BlobS3Region { get; set; } = "us-east-1"; + + /// + /// Gets or sets the S3 bucket name. + /// + public string? BlobS3Bucket { get; set; } + + /// + /// Gets or sets the S3 access key. + /// + public IResourceBuilder? BlobS3AccessKey { get; set; } + + /// + /// Gets or sets the S3 secret key. + /// + public IResourceBuilder? BlobS3SecretKey { get; set; } + + /// + /// Gets or sets a value indicating whether S3 storage uses TLS. + /// + public bool BlobS3UseSsl { get; set; } = true; + + /// + /// Gets or sets a value indicating whether S3 path-style addressing is used. + /// + public bool BlobS3PathStyle { get; set; } + + /// + /// Gets or sets the filesystem storage path. + /// + public string BlobFileSystemPath { get; set; } = "/data/attachments"; + + /// + /// Gets or sets the AES-256-GCM encryption key for stored SMTP passwords. + /// + public IResourceBuilder? EncryptionKey { get; set; } + + /// + /// Gets or sets the system SMTP server host used for platform notifications. + /// + public string? SystemSmtpHost { get; set; } + + /// + /// Gets or sets the system SMTP server port. + /// + public int SystemSmtpPort { get; set; } = 587; + + /// + /// Gets or sets the system SMTP username. + /// + public string? SystemSmtpUsername { get; set; } + + /// + /// Gets or sets the system SMTP password. + /// + public IResourceBuilder? SystemSmtpPassword { get; set; } + + /// + /// Gets or sets the system SMTP from address. + /// + public string? SystemSmtpFrom { get; set; } + + /// + /// Gets or sets the system SMTP encryption mode: none, ssl, or starttls. + /// + public string SystemSmtpEncryption { get; set; } = "starttls"; + + /// + /// Gets or sets a value indicating whether inbound email processing is enabled. + /// + public bool InboundEnabled { get; set; } + + /// + /// Gets or sets the bind address for the built-in SMTP receiver. + /// + public string InboundSmtpHost { get; set; } = "0.0.0.0"; + + /// + /// Gets or sets the SMTP listener port. + /// + public int InboundSmtpPort { get; set; } = 2525; + + /// + /// Gets or sets the hostname announced in EHLO and used as TLS SNI. + /// + public string InboundHostname { get; set; } = "posta.local"; + + /// + /// Gets or sets the maximum raw inbound message size in bytes. + /// + public int InboundMaxMessageSize { get; set; } = 26214400; + + /// + /// Gets or sets the maximum inbound attachment size in bytes. + /// + public int InboundMaxAttachSize { get; set; } = 10485760; + + /// + /// Gets or sets the shared secret for inbound webhooks. + /// + public IResourceBuilder? InboundWebhookSecret { get; set; } + + /// + /// Gets or sets the inbound SMTP TLS mode: none or starttls. + /// + public string InboundTlsMode { get; set; } = "none"; + + /// + /// Gets or sets the PEM certificate path for inbound TLS. + /// + public string? InboundTlsCertFile { get; set; } + + /// + /// Gets or sets the PEM key path for inbound TLS. + /// + public string? InboundTlsKeyFile { get; set; } + + /// + /// Gets or sets the per-IP SMTP session rate limit. + /// + public int InboundSmtpRateLimit { get; set; } = 60; + + /// + /// Gets or sets the SMTP rate-limit window in seconds. + /// + public int InboundSmtpRateWindow { get; set; } = 60; + + /// + /// Gets or sets a value indicating whether users must verify their email address before sign-in. + /// + public bool EmailVerificationRequired { get; set; } + + /// + /// Gets or sets a value indicating whether permanently rejected recipients are added to the suppression list. + /// + public bool AutoSuppressOnReject { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the email verification endpoint is enabled. + /// + public bool EmailVerifyEnabled { get; set; } = true; + + /// + /// Gets or sets how long address-level verification results are cached, in hours. + /// + public int EmailVerifyCacheTtlHours { get; set; } = 168; + + /// + /// Gets or sets how long domain MX lookups are cached, in hours. + /// + public int EmailVerifyMxCacheTtlHours { get; set; } = 24; + + /// + /// Gets or sets the per-user hourly cap on verification requests. + /// + public int EmailVerifyRateHourly { get; set; } = 1000; + + /// + /// Gets or sets a value indicating whether older binaries can boot against a newer database schema. + /// + public bool AllowDowngrade { get; set; } + + /// + /// Gets or sets a value indicating whether hosted plan limits and quotas are enforced. + /// + public bool PlanEnforcement { get; set; } + + /// + /// Gets or sets a PostgreSQL connection string parameter that overrides individual database settings. + /// + public IResourceBuilder? DatabaseUrl { get; set; } + + /// + /// Gets or sets a Redis password parameter that overrides the referenced Redis resource password. + /// + public IResourceBuilder? RedisPassword { get; set; } + + /// + /// Gets or sets an external Redis address in the form host:port. + /// + public string? RedisAddress { get; set; } +} \ No newline at end of file From 31bac3ccdfa79a097ebbb4c0bc34a7bfadc55614 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 15:59:04 +0300 Subject: [PATCH 03/24] Simplify Posta integration by directly passing database and Redis resources to `AddPosta`. Refine README to reflect updated API and usage. --- src/CommunityToolkit.Aspire.Hosting.Posta/README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md index 27e16b022..db82005ad 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md @@ -19,15 +19,13 @@ var postgres = builder.AddPostgres("postgres"); var database = postgres.AddDatabase("posta-db", "posta"); var redis = builder.AddRedis("redis"); -var posta = builder.AddPosta("posta", options => +var posta = builder.AddPosta("posta", database, redis, options => { options.Environment = "production"; options.WebUrl = "https://posta.example.com"; options.CorsOrigins = "https://app.example.com"; options.MetricsEnabled = true; - }) - .WithReference(database) - .WithReference(redis); + }); builder.AddProject("api") .WithReference(posta) @@ -36,7 +34,7 @@ builder.AddProject("api") builder.Build().Run(); ``` -Posta stores data in PostgreSQL, uses Redis for queueing, and runs the embedded worker in the main container by default. Use `WithReference` to connect Posta to Aspire-managed PostgreSQL and Redis resources. For externally managed infrastructure, configure `PostaOptions.DatabaseUrl`, `PostaOptions.RedisAddress`, and `PostaOptions.RedisPassword`. +Posta stores data in PostgreSQL, uses Redis for queueing, and runs the embedded worker in the main container by default. Pass Aspire-managed PostgreSQL and Redis resources to `AddPosta`, or use `WithReference` to attach them fluently. For externally managed infrastructure, configure `PostaOptions.DatabaseUrl`, `PostaOptions.RedisAddress`, and `PostaOptions.RedisPassword`. Use the `PostaOptions` callback to configure Posta environment variables such as rate limits, web URLs, OAuth, blob storage, system SMTP, inbound email, email verification, and advanced switches. Secret values such as SMTP passwords, S3 keys, OAuth client secrets, encryption keys, inbound webhook secrets, `POSTA_DB_URL`, and Redis password are represented as Aspire parameters. From e59b9aeb0257e1cdd9805905059c7b2285aba88c Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 15:59:13 +0300 Subject: [PATCH 04/24] Add comprehensive tests for `AddPosta` with database and Redis references - Introduced tests to validate `AddPosta` behavior with null database and Redis inputs, ensuring proper exception handling. - Added test to confirm PostgreSQL and Redis configuration via environment variables. --- .../ContainerResourceCreationTests.cs | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs index 5e445b6ad..19ee38960 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs @@ -40,6 +40,25 @@ public void WithReferenceThrowsWhenRedisIsNull() Assert.Throws(() => posta.WithReference((IResourceBuilder)null!)); } + [Fact] + public void AddPostaWithReferencesThrowsWhenDatabaseIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + var redis = builder.AddRedis("redis"); + + Assert.Throws(() => builder.AddPosta("posta", null!, redis)); + } + + [Fact] + public void AddPostaWithReferencesThrowsWhenRedisIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + var postgres = builder.AddPostgres("postgres"); + var database = postgres.AddDatabase("posta-db", "posta"); + + Assert.Throws(() => builder.AddPosta("posta", database, null!)); + } + [Fact] public void AddPostaSetsContainerDetailsOnResource() { @@ -92,6 +111,38 @@ public void AddPostaRegistersHealthChecks() Assert.Equal(2, annotations.Count()); } + [Fact] + public async Task AddPostaWithReferencesConfiguresPostgreSqlAndRedis() + { + var builder = DistributedApplication.CreateBuilder(); + var postgres = builder.AddPostgres("postgres"); + var database = postgres.AddDatabase("posta-db", "posta"); + var redis = builder.AddRedis("redis"); + + var posta = builder.AddPosta("posta", database, redis); + + Assert.True(posta.Resource.TryGetAnnotationsOfType(out var annotations)); + + var context = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext( + new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run))); + + foreach (var annotation in annotations) + { + await annotation.Callback(context); + } + + var env = context.EnvironmentVariables; + + Assert.True(env.ContainsKey("POSTA_DB_HOST")); + Assert.True(env.ContainsKey("POSTA_DB_PORT")); + Assert.True(env.ContainsKey("POSTA_DB_USER")); + Assert.True(env.ContainsKey("POSTA_DB_PASSWORD")); + Assert.Equal("posta", env["POSTA_DB_NAME"]); + Assert.Equal("disable", env["POSTA_DB_SSL_MODE"]); + Assert.True(env.ContainsKey("POSTA_REDIS_ADDR")); + } + [Fact] public async Task AddPostaConfiguresPostaEnvironmentVariables() { @@ -254,4 +305,4 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() Assert.NotEqual("redis.example.com:6379", env["POSTA_REDIS_ADDR"].ToString()); Assert.Same(redisPassword.Resource, env["POSTA_REDIS_PASSWORD"]); } -} \ No newline at end of file +} From aed268bbea5d4edadf36c09cc98ee8cc82b89294 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 15:59:20 +0300 Subject: [PATCH 05/24] Simplify `AddPosta` API by directly passing database and Redis references. --- .../CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs b/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs index d514c965a..50c3cce45 100644 --- a/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs +++ b/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs @@ -5,8 +5,6 @@ var database = postgres.AddDatabase("posta-db", "posta"); var redis = builder.AddRedis("redis"); -builder.AddPosta("posta") - .WithReference(database) - .WithReference(redis); +builder.AddPosta("posta", database, redis); builder.Build().Run(); From 63d69007cb297824f7b87e849d0387448990dec2 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 15:59:26 +0300 Subject: [PATCH 06/24] Add null checks for database, Redis, and configuration options in `AddPosta` methods. Extend methods to support chained references. --- .../PostaHostingExtensions.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index af2552344..8452a9dc7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -31,6 +31,15 @@ public static IResourceBuilder AddPosta( return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions: null); } + { + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(redis); + + return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions: null) + .WithReference(database) + .WithReference(redis); + } + /// /// Adds a Posta container resource to the with additional Posta environment configuration. /// @@ -57,6 +66,16 @@ public static IResourceBuilder AddPosta( return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions); } + { + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(redis); + ArgumentNullException.ThrowIfNull(configureOptions); + + return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions) + .WithReference(database) + .WithReference(redis); + } + /// /// Configures the PostgreSQL database used by Posta. /// From cc5353d08bd924fd6199e77fc7f6018ff514b86c Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 15:59:31 +0300 Subject: [PATCH 07/24] Add `AddPosta` overloads for chaining PostgreSQL and Redis references with improved configuration options. --- .../PostaHostingExtensions.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index 8452a9dc7..fb8b8d222 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -31,6 +31,29 @@ public static IResourceBuilder AddPosta( return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions: null); } + /// + /// Adds a Posta container resource to the and configures PostgreSQL and Redis references. + /// + /// Adds a Posta container resource with PostgreSQL and Redis references + /// The to which the Posta resource will be added. + /// The name of the Posta container resource. + /// The PostgreSQL database resource used by Posta. + /// The Redis resource used by Posta. + /// Optional parameter used as the Posta JWT signing secret. + /// Optional parameter used as the initial Posta admin password. + /// The initial Posta admin account email. + /// Optional host port for the Posta HTTP API and dashboard. + /// A reference to the for further resource configuration. + [AspireExport("addPostaWithReferences", MethodName = "addPostaWithReferences")] + public static IResourceBuilder AddPosta( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder database, + IResourceBuilder redis, + IResourceBuilder? jwtSecret = null, + IResourceBuilder? adminPassword = null, + string adminEmail = "admin@example.com", + int? port = null) { ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(redis); @@ -66,6 +89,30 @@ public static IResourceBuilder AddPosta( return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions); } + /// + /// Adds a Posta container resource to the with PostgreSQL, Redis, and additional Posta environment configuration. + /// + /// The to which the Posta resource will be added. + /// The name of the Posta container resource. + /// The PostgreSQL database resource used by Posta. + /// The Redis resource used by Posta. + /// A delegate that configures Posta environment variables. + /// Optional parameter used as the Posta JWT signing secret. + /// Optional parameter used as the initial Posta admin password. + /// The initial Posta admin account email. + /// Optional host port for the Posta HTTP API and dashboard. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the parameter-based overload instead.")] + public static IResourceBuilder AddPosta( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder database, + IResourceBuilder redis, + Action configureOptions, + IResourceBuilder? jwtSecret = null, + IResourceBuilder? adminPassword = null, + string adminEmail = "admin@example.com", + int? port = null) { ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(redis); From dfc273089b944a3eb430d9cc562519ddf80eff8b Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 16:07:59 +0300 Subject: [PATCH 08/24] Refactor `AddPosta` tests to include database and Redis dependencies. Remove redundant overloads in `PostaHostingExtensions`. --- .../PostaHostingExtensions.cs | 57 +------------------ .../ContainerResourceCreationTests.cs | 38 ++++++++----- 2 files changed, 25 insertions(+), 70 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index fb8b8d222..e10588dec 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -8,29 +8,6 @@ namespace Aspire.Hosting; /// public static class PostaHostingExtensions { - /// - /// Adds a Posta container resource to the . - /// - /// Adds a Posta container resource - /// The to which the Posta resource will be added. - /// The name of the Posta container resource. - /// Optional parameter used as the Posta JWT signing secret. - /// Optional parameter used as the initial Posta admin password. - /// The initial Posta admin account email. - /// Optional host port for the Posta HTTP API and dashboard. - /// A reference to the for further resource configuration. - [AspireExport] - public static IResourceBuilder AddPosta( - this IDistributedApplicationBuilder builder, - [ResourceName] string name, - IResourceBuilder? jwtSecret = null, - IResourceBuilder? adminPassword = null, - string adminEmail = "admin@example.com", - int? port = null) - { - return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions: null); - } - /// /// Adds a Posta container resource to the and configures PostgreSQL and Redis references. /// @@ -55,6 +32,7 @@ public static IResourceBuilder AddPosta( string adminEmail = "admin@example.com", int? port = null) { + ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(redis); @@ -63,32 +41,6 @@ public static IResourceBuilder AddPosta( .WithReference(redis); } - /// - /// Adds a Posta container resource to the with additional Posta environment configuration. - /// - /// The to which the Posta resource will be added. - /// The name of the Posta container resource. - /// A delegate that configures Posta environment variables. - /// Optional parameter used as the Posta JWT signing secret. - /// Optional parameter used as the initial Posta admin password. - /// The initial Posta admin account email. - /// Optional host port for the Posta HTTP API and dashboard. - /// A reference to the for further resource configuration. - [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the parameter-based overload instead.")] - public static IResourceBuilder AddPosta( - this IDistributedApplicationBuilder builder, - [ResourceName] string name, - Action configureOptions, - IResourceBuilder? jwtSecret = null, - IResourceBuilder? adminPassword = null, - string adminEmail = "admin@example.com", - int? port = null) - { - ArgumentNullException.ThrowIfNull(configureOptions); - - return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions); - } - /// /// Adds a Posta container resource to the with PostgreSQL, Redis, and additional Posta environment configuration. /// @@ -114,6 +66,7 @@ public static IResourceBuilder AddPosta( string adminEmail = "admin@example.com", int? port = null) { + ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(redis); ArgumentNullException.ThrowIfNull(configureOptions); @@ -221,12 +174,6 @@ private static IResourceBuilder AddPostaCore( .WithEnvironment(context => { context.EnvironmentVariables["POSTA_PORT"] = PostaResource.HttpEndpointPort.ToString(); - SetParameter(context, "POSTA_DB_URL", options.DatabaseUrl); - SetIfNotNull(context, "POSTA_REDIS_ADDR", options.RedisAddress); - if (options.RedisPassword is not null) - { - context.EnvironmentVariables["POSTA_REDIS_PASSWORD"] = options.RedisPassword.Resource; - } context.EnvironmentVariables["POSTA_JWT_SECRET"] = resource.JwtSecretParameter; context.EnvironmentVariables["POSTA_ADMIN_EMAIL"] = adminEmail; context.EnvironmentVariables["POSTA_ADMIN_PASSWORD"] = resource.AdminPasswordParameter; diff --git a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs index 19ee38960..697cc5f10 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs @@ -10,7 +10,7 @@ public void AddPostaThrowsWhenBuilderIsNull() { IDistributedApplicationBuilder builder = null!; - var exception = Assert.Throws(() => builder.AddPosta("posta")); + var exception = Assert.Throws(() => builder.AddPosta("posta", null!, null!)); Assert.Equal("builder", exception.ParamName); } @@ -18,15 +18,17 @@ public void AddPostaThrowsWhenBuilderIsNull() public void AddPostaThrowsWhenNameIsNull() { var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); - Assert.Throws(() => builder.AddPosta(null!)); + Assert.Throws(() => builder.AddPosta(null!, database, redis)); } [Fact] public void WithReferenceThrowsWhenDatabaseIsNull() { var builder = DistributedApplication.CreateBuilder(); - var posta = builder.AddPosta("posta"); + var (database, redis) = AddPostaDependencies(builder); + var posta = builder.AddPosta("posta", database, redis); Assert.Throws(() => posta.WithReference((IResourceBuilder)null!)); } @@ -35,7 +37,8 @@ public void WithReferenceThrowsWhenDatabaseIsNull() public void WithReferenceThrowsWhenRedisIsNull() { var builder = DistributedApplication.CreateBuilder(); - var posta = builder.AddPosta("posta"); + var (database, redis) = AddPostaDependencies(builder); + var posta = builder.AddPosta("posta", database, redis); Assert.Throws(() => posta.WithReference((IResourceBuilder)null!)); } @@ -63,8 +66,9 @@ public void AddPostaWithReferencesThrowsWhenRedisIsNull() public void AddPostaSetsContainerDetailsOnResource() { var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); - builder.AddPosta("posta"); + builder.AddPosta("posta", database, redis); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); @@ -83,8 +87,9 @@ public void AddPostaSetsContainerDetailsOnResource() public void AddPostaSetsEndpointDetailsOnResource() { var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); - builder.AddPosta("posta", port: 9001); + builder.AddPosta("posta", database, redis, port: 9001); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); @@ -100,8 +105,9 @@ public void AddPostaSetsEndpointDetailsOnResource() public void AddPostaRegistersHealthChecks() { var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); - builder.AddPosta("posta"); + builder.AddPosta("posta", database, redis); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); @@ -150,7 +156,6 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() var postgres = builder.AddPostgres("postgres"); var database = postgres.AddDatabase("posta-db", "posta"); var redis = builder.AddRedis("redis"); - var databaseUrl = builder.AddParameter("posta-db-url", "postgres://example"); var redisPassword = builder.AddParameter("posta-redis-password", "redis-secret"); var googleSecret = builder.AddParameter("posta-google-secret", "google-secret"); var s3AccessKey = builder.AddParameter("posta-s3-access-key", "s3-access"); @@ -159,7 +164,7 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() var smtpPassword = builder.AddParameter("posta-smtp-password", "smtp-secret"); var inboundWebhookSecret = builder.AddParameter("posta-inbound-secret", "inbound-secret"); - var posta = builder.AddPosta("posta", options => + var posta = builder.AddPosta("posta", database, redis, options => { options.Environment = "dev"; options.DevMode = true; @@ -217,11 +222,7 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() options.EmailVerifyRateHourly = 42; options.AllowDowngrade = true; options.PlanEnforcement = true; - options.DatabaseUrl = databaseUrl; - options.RedisPassword = redisPassword; - options.RedisAddress = "redis.example.com:6379"; }) - .WithReference(database) .WithReference(redis, redisPassword); Assert.True(posta.Resource.TryGetAnnotationsOfType(out var annotations)); @@ -294,7 +295,6 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() Assert.Equal("42", env["POSTA_EMAIL_VERIFY_RATE_HOURLY"]); Assert.Equal("true", env["POSTA_ALLOW_DOWNGRADE"]); Assert.Equal("true", env["POSTA_PLAN_ENFORCEMENT"]); - Assert.Same(databaseUrl.Resource, env["POSTA_DB_URL"]); Assert.True(env.ContainsKey("POSTA_DB_HOST")); Assert.True(env.ContainsKey("POSTA_DB_PORT")); Assert.True(env.ContainsKey("POSTA_DB_USER")); @@ -302,7 +302,15 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() Assert.Equal("posta", env["POSTA_DB_NAME"]); Assert.Equal("disable", env["POSTA_DB_SSL_MODE"]); Assert.True(env.ContainsKey("POSTA_REDIS_ADDR")); - Assert.NotEqual("redis.example.com:6379", env["POSTA_REDIS_ADDR"].ToString()); Assert.Same(redisPassword.Resource, env["POSTA_REDIS_PASSWORD"]); } + + private static (IResourceBuilder Database, IResourceBuilder Redis) AddPostaDependencies(IDistributedApplicationBuilder builder) + { + var postgres = builder.AddPostgres("postgres"); + var database = postgres.AddDatabase("posta-db", "posta"); + var redis = builder.AddRedis("redis"); + + return (database, redis); + } } From 83641b7254b10d6ca96d6d29a470ab98117e116a Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 16:14:38 +0300 Subject: [PATCH 09/24] Enhance `AddPosta` tests with support for overriding `DatabaseUrl` and `RedisAddress`; refine README to clarify environment configuration usage. --- src/CommunityToolkit.Aspire.Hosting.Posta/README.md | 2 +- .../ContainerResourceCreationTests.cs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md index db82005ad..b298dd76b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md @@ -34,7 +34,7 @@ builder.AddProject("api") builder.Build().Run(); ``` -Posta stores data in PostgreSQL, uses Redis for queueing, and runs the embedded worker in the main container by default. Pass Aspire-managed PostgreSQL and Redis resources to `AddPosta`, or use `WithReference` to attach them fluently. For externally managed infrastructure, configure `PostaOptions.DatabaseUrl`, `PostaOptions.RedisAddress`, and `PostaOptions.RedisPassword`. +Posta stores data in PostgreSQL, uses Redis for queueing, and runs the embedded worker in the main container by default. `AddPosta` requires PostgreSQL database and Redis resources so the container starts with the dependencies it needs. Use `PostaOptions.DatabaseUrl`, `PostaOptions.RedisAddress`, and `PostaOptions.RedisPassword` only when you need to override the generated Posta environment values. Use the `PostaOptions` callback to configure Posta environment variables such as rate limits, web URLs, OAuth, blob storage, system SMTP, inbound email, email verification, and advanced switches. Secret values such as SMTP passwords, S3 keys, OAuth client secrets, encryption keys, inbound webhook secrets, `POSTA_DB_URL`, and Redis password are represented as Aspire parameters. diff --git a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs index 697cc5f10..be6545eb3 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs @@ -156,6 +156,7 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() var postgres = builder.AddPostgres("postgres"); var database = postgres.AddDatabase("posta-db", "posta"); var redis = builder.AddRedis("redis"); + var databaseUrl = builder.AddParameter("posta-db-url", "postgres://example"); var redisPassword = builder.AddParameter("posta-redis-password", "redis-secret"); var googleSecret = builder.AddParameter("posta-google-secret", "google-secret"); var s3AccessKey = builder.AddParameter("posta-s3-access-key", "s3-access"); @@ -222,6 +223,9 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() options.EmailVerifyRateHourly = 42; options.AllowDowngrade = true; options.PlanEnforcement = true; + options.DatabaseUrl = databaseUrl; + options.RedisPassword = redisPassword; + options.RedisAddress = "redis.example.com:6379"; }) .WithReference(redis, redisPassword); @@ -295,6 +299,7 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() Assert.Equal("42", env["POSTA_EMAIL_VERIFY_RATE_HOURLY"]); Assert.Equal("true", env["POSTA_ALLOW_DOWNGRADE"]); Assert.Equal("true", env["POSTA_PLAN_ENFORCEMENT"]); + Assert.Same(databaseUrl.Resource, env["POSTA_DB_URL"]); Assert.True(env.ContainsKey("POSTA_DB_HOST")); Assert.True(env.ContainsKey("POSTA_DB_PORT")); Assert.True(env.ContainsKey("POSTA_DB_USER")); @@ -302,6 +307,7 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() Assert.Equal("posta", env["POSTA_DB_NAME"]); Assert.Equal("disable", env["POSTA_DB_SSL_MODE"]); Assert.True(env.ContainsKey("POSTA_REDIS_ADDR")); + Assert.NotEqual("redis.example.com:6379", env["POSTA_REDIS_ADDR"].ToString()); Assert.Same(redisPassword.Resource, env["POSTA_REDIS_PASSWORD"]); } From 423af759b50ce2b4750eefe9d01d817f0a49de10 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 16:14:45 +0300 Subject: [PATCH 10/24] Add overloads to `Set` method in `PostaHostingExtensions` for handling additional expression types --- .../PostaHostingExtensions.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index e10588dec..7934535e9 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -255,6 +255,21 @@ private static void Set(EnvironmentCallbackContext context, string name, string context.EnvironmentVariables[name] = value; } + private static void Set(EnvironmentCallbackContext context, string name, ParameterResource value) + { + context.EnvironmentVariables[name] = value; + } + + private static void Set(EnvironmentCallbackContext context, string name, EndpointReferenceExpression value) + { + context.EnvironmentVariables[name] = value; + } + + private static void Set(EnvironmentCallbackContext context, string name, ReferenceExpression value) + { + context.EnvironmentVariables[name] = value; + } + private static void Set(EnvironmentCallbackContext context, string name, bool value) { context.EnvironmentVariables[name] = value ? "true" : "false"; From fd0be69bc49ded3719a42f0ad57ecb20f914bf45 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 16:14:53 +0300 Subject: [PATCH 11/24] Refactor `PostaHostingExtensions` to replace direct environment variable assignments with `Set` and `SetIfNotNull` methods for improved readability and consistency. --- .../PostaHostingExtensions.cs | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index 7934535e9..7d296c512 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -95,12 +95,12 @@ public static IResourceBuilder WithReference( { var postgres = database.Resource.Parent; - context.EnvironmentVariables["POSTA_DB_HOST"] = postgres.PrimaryEndpoint.Property(EndpointProperty.Host); - context.EnvironmentVariables["POSTA_DB_PORT"] = postgres.PrimaryEndpoint.Property(EndpointProperty.Port); - context.EnvironmentVariables["POSTA_DB_USER"] = postgres.UserNameReference; - context.EnvironmentVariables["POSTA_DB_PASSWORD"] = postgres.PasswordParameter; - context.EnvironmentVariables["POSTA_DB_NAME"] = database.Resource.DatabaseName; - context.EnvironmentVariables["POSTA_DB_SSL_MODE"] = "disable"; + Set(context, "POSTA_DB_HOST", postgres.PrimaryEndpoint.Property(EndpointProperty.Host)); + Set(context, "POSTA_DB_PORT", postgres.PrimaryEndpoint.Property(EndpointProperty.Port)); + Set(context, "POSTA_DB_USER", postgres.UserNameReference); + Set(context, "POSTA_DB_PASSWORD", postgres.PasswordParameter); + Set(context, "POSTA_DB_NAME", database.Resource.DatabaseName); + Set(context, "POSTA_DB_SSL_MODE", "disable"); }) .WaitFor(database); } @@ -127,14 +127,14 @@ public static IResourceBuilder WithReference( var redisResource = redis.Resource; var redisEndpoint = redisResource.GetEndpoint("secondary"); - context.EnvironmentVariables["POSTA_REDIS_ADDR"] = ReferenceExpression.Create($"{redisEndpoint.Property(EndpointProperty.Host)}:{redisEndpoint.Property(EndpointProperty.Port)}"); + Set(context, "POSTA_REDIS_ADDR", ReferenceExpression.Create($"{redisEndpoint.Property(EndpointProperty.Host)}:{redisEndpoint.Property(EndpointProperty.Port)}")); if (redisPassword is not null) { - context.EnvironmentVariables["POSTA_REDIS_PASSWORD"] = redisPassword.Resource; + Set(context, "POSTA_REDIS_PASSWORD", redisPassword.Resource); } else if (redisResource.PasswordParameter is not null) { - context.EnvironmentVariables["POSTA_REDIS_PASSWORD"] = redisResource.PasswordParameter; + Set(context, "POSTA_REDIS_PASSWORD", redisResource.PasswordParameter); } }) .WaitFor(redis); @@ -173,10 +173,13 @@ private static IResourceBuilder AddPostaCore( name: PostaResource.HttpEndpointName) .WithEnvironment(context => { - context.EnvironmentVariables["POSTA_PORT"] = PostaResource.HttpEndpointPort.ToString(); - context.EnvironmentVariables["POSTA_JWT_SECRET"] = resource.JwtSecretParameter; - context.EnvironmentVariables["POSTA_ADMIN_EMAIL"] = adminEmail; - context.EnvironmentVariables["POSTA_ADMIN_PASSWORD"] = resource.AdminPasswordParameter; + Set(context, "POSTA_PORT", PostaResource.HttpEndpointPort); + SetParameter(context, "POSTA_DB_URL", options.DatabaseUrl); + SetIfNotNull(context, "POSTA_REDIS_ADDR", options.RedisAddress); + if (options.RedisPassword is not null) + Set(context, "POSTA_JWT_SECRET", resource.JwtSecretParameter); + Set(context, "POSTA_ADMIN_EMAIL", adminEmail); + Set(context, "POSTA_ADMIN_PASSWORD", resource.AdminPasswordParameter); ConfigurePostaEnvironment(context, options); }) .WithHttpHealthCheck( From 0115e17546b282174edef26d35e7818bc9412218 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 16:15:01 +0300 Subject: [PATCH 12/24] Inject `POSTA_REDIS_PASSWORD` configuration into environment setup for Redis authentication support. --- .../PostaHostingExtensions.cs | 3 +++ src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index 7d296c512..90c3e6a26 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -177,6 +177,9 @@ private static IResourceBuilder AddPostaCore( SetParameter(context, "POSTA_DB_URL", options.DatabaseUrl); SetIfNotNull(context, "POSTA_REDIS_ADDR", options.RedisAddress); if (options.RedisPassword is not null) + { + Set(context, "POSTA_REDIS_PASSWORD", options.RedisPassword.Resource); + } Set(context, "POSTA_JWT_SECRET", resource.JwtSecretParameter); Set(context, "POSTA_ADMIN_EMAIL", adminEmail); Set(context, "POSTA_ADMIN_PASSWORD", resource.AdminPasswordParameter); diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs index 6ef26516a..f174491eb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs @@ -301,4 +301,4 @@ public sealed class PostaOptions /// Gets or sets an external Redis address in the form host:port. /// public string? RedisAddress { get; set; } -} \ No newline at end of file +} From 9daab4537c70c12b1b14bed1dac66ec896a2dd7d Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 01:34:45 +0300 Subject: [PATCH 13/24] Refactor README to use Aspire parameters for `PostaOptions` configuration values, ensuring flexibility and enhanced security. --- src/CommunityToolkit.Aspire.Hosting.Posta/README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md index b298dd76b..d442aa0d9 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md @@ -19,12 +19,15 @@ var postgres = builder.AddPostgres("postgres"); var database = postgres.AddDatabase("posta-db", "posta"); var redis = builder.AddRedis("redis"); +var postaWebUrl = builder.AddParameter("posta-web-url"); +var postaCorsOrigins = builder.AddParameter("posta-cors-origins"); +var postaMetricsEnabled = builder.AddParameter("posta-metrics-enabled"); + var posta = builder.AddPosta("posta", database, redis, options => { - options.Environment = "production"; - options.WebUrl = "https://posta.example.com"; - options.CorsOrigins = "https://app.example.com"; - options.MetricsEnabled = true; + options.WebUrl = postaWebUrl; + options.CorsOrigins = postaCorsOrigins; + options.MetricsEnabled = postaMetricsEnabled; }); builder.AddProject("api") @@ -36,7 +39,7 @@ builder.Build().Run(); Posta stores data in PostgreSQL, uses Redis for queueing, and runs the embedded worker in the main container by default. `AddPosta` requires PostgreSQL database and Redis resources so the container starts with the dependencies it needs. Use `PostaOptions.DatabaseUrl`, `PostaOptions.RedisAddress`, and `PostaOptions.RedisPassword` only when you need to override the generated Posta environment values. -Use the `PostaOptions` callback to configure Posta environment variables such as rate limits, web URLs, OAuth, blob storage, system SMTP, inbound email, email verification, and advanced switches. Secret values such as SMTP passwords, S3 keys, OAuth client secrets, encryption keys, inbound webhook secrets, `POSTA_DB_URL`, and Redis password are represented as Aspire parameters. +Use the `PostaOptions` callback to configure Posta environment variables such as rate limits, web URLs, OAuth, blob storage, system SMTP, inbound email, email verification, and advanced switches. `PostaOptions` values are Aspire parameter builders so values can come from configuration, environment variables, user secrets, or publish-time parameters instead of being materialized in the AppHost model. Sensitive values such as SMTP passwords, S3 keys, OAuth client secrets, encryption keys, inbound webhook secrets, `POSTA_DB_URL`, and Redis password should be created as secret parameters. ## Connection Properties From f0655405ca057565f0928d78c75f3fbae0c4134b Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 01:34:50 +0300 Subject: [PATCH 14/24] Refactor `AddPosta` test configuration to use parameterized resources and improve environment variable assertions. --- .../ContainerResourceCreationTests.cs | 266 +++++++++--------- 1 file changed, 139 insertions(+), 127 deletions(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs index be6545eb3..e8478951b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs @@ -156,78 +156,78 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() var postgres = builder.AddPostgres("postgres"); var database = postgres.AddDatabase("posta-db", "posta"); var redis = builder.AddRedis("redis"); - var databaseUrl = builder.AddParameter("posta-db-url", "postgres://example"); - var redisPassword = builder.AddParameter("posta-redis-password", "redis-secret"); - var googleSecret = builder.AddParameter("posta-google-secret", "google-secret"); - var s3AccessKey = builder.AddParameter("posta-s3-access-key", "s3-access"); - var s3SecretKey = builder.AddParameter("posta-s3-secret-key", "s3-secret"); - var encryptionKey = builder.AddParameter("posta-encryption-key", "encryption-secret"); - var smtpPassword = builder.AddParameter("posta-smtp-password", "smtp-secret"); - var inboundWebhookSecret = builder.AddParameter("posta-inbound-secret", "inbound-secret"); + var parameters = new Dictionary>(); + + IResourceBuilder Parameter(string name, string value) + { + var parameter = builder.AddParameter($"posta-{name}", value); + parameters[name] = parameter; + return parameter; + } var posta = builder.AddPosta("posta", database, redis, options => { - options.Environment = "dev"; - options.DevMode = true; - options.AuthRateLimitEnabled = false; - options.RateLimitHourly = 500; - options.RateLimitDaily = 5000; - options.OpenApiDocs = false; - options.MetricsEnabled = true; - options.WebDir = "web/dist"; - options.WebUrl = "https://posta.example.com"; - options.ApiUrl = "https://api.posta.example.com"; - options.CorsOrigins = "https://app.example.com"; - options.EmbeddedWorker = false; - options.WorkerConcurrency = 20; - options.WorkerMaxRetries = 7; - options.WebhookMaxRetries = 4; - options.WebhookTimeoutSeconds = 30; - options.WebhookProxyUrl = "http://proxy.example.com"; - options.GoogleOAuthClientId = "google-client-id"; - options.GoogleOAuthClientSecret = googleSecret; - options.OAuthCallbackUrl = "https://posta.example.com/oauth/callback"; - options.BlobProvider = "s3"; - options.BlobS3Endpoint = "https://s3.example.com"; - options.BlobS3Region = "eu-central-1"; - options.BlobS3Bucket = "posta"; - options.BlobS3AccessKey = s3AccessKey; - options.BlobS3SecretKey = s3SecretKey; - options.BlobS3UseSsl = false; - options.BlobS3PathStyle = true; - options.BlobFileSystemPath = "/posta/attachments"; - options.EncryptionKey = encryptionKey; - options.SystemSmtpHost = "smtp.example.com"; - options.SystemSmtpPort = 2525; - options.SystemSmtpUsername = "notifications@example.com"; - options.SystemSmtpPassword = smtpPassword; - options.SystemSmtpFrom = "notifications@example.com"; - options.SystemSmtpEncryption = "ssl"; - options.InboundEnabled = true; - options.InboundSmtpHost = "127.0.0.1"; - options.InboundSmtpPort = 2526; - options.InboundHostname = "mx.example.com"; - options.InboundMaxMessageSize = 12345; - options.InboundMaxAttachSize = 6789; - options.InboundWebhookSecret = inboundWebhookSecret; - options.InboundTlsMode = "starttls"; - options.InboundTlsCertFile = "/certs/fullchain.pem"; - options.InboundTlsKeyFile = "/certs/privkey.pem"; - options.InboundSmtpRateLimit = 10; - options.InboundSmtpRateWindow = 20; - options.EmailVerificationRequired = true; - options.AutoSuppressOnReject = false; - options.EmailVerifyEnabled = false; - options.EmailVerifyCacheTtlHours = 12; - options.EmailVerifyMxCacheTtlHours = 6; - options.EmailVerifyRateHourly = 42; - options.AllowDowngrade = true; - options.PlanEnforcement = true; - options.DatabaseUrl = databaseUrl; - options.RedisPassword = redisPassword; - options.RedisAddress = "redis.example.com:6379"; + options.Environment = Parameter("environment", "dev"); + options.DevMode = Parameter("dev-mode", "true"); + options.AuthRateLimitEnabled = Parameter("auth-rate-limit-enabled", "false"); + options.RateLimitHourly = Parameter("rate-limit-hourly", "500"); + options.RateLimitDaily = Parameter("rate-limit-daily", "5000"); + options.OpenApiDocs = Parameter("openapi-docs", "false"); + options.MetricsEnabled = Parameter("metrics-enabled", "true"); + options.WebDir = Parameter("web-dir", "web/dist"); + options.WebUrl = Parameter("web-url", "https://posta.example.com"); + options.ApiUrl = Parameter("api-url", "https://api.posta.example.com"); + options.CorsOrigins = Parameter("cors-origins", "https://app.example.com"); + options.EmbeddedWorker = Parameter("embedded-worker", "false"); + options.WorkerConcurrency = Parameter("worker-concurrency", "20"); + options.WorkerMaxRetries = Parameter("worker-max-retries", "7"); + options.WebhookMaxRetries = Parameter("webhook-max-retries", "4"); + options.WebhookTimeoutSeconds = Parameter("webhook-timeout-seconds", "30"); + options.WebhookProxyUrl = Parameter("webhook-proxy-url", "http://proxy.example.com"); + options.GoogleOAuthClientId = Parameter("google-oauth-client-id", "google-client-id"); + options.GoogleOAuthClientSecret = Parameter("google-oauth-client-secret", "google-secret"); + options.OAuthCallbackUrl = Parameter("oauth-callback-url", "https://posta.example.com/oauth/callback"); + options.BlobProvider = Parameter("blob-provider", "s3"); + options.BlobS3Endpoint = Parameter("blob-s3-endpoint", "https://s3.example.com"); + options.BlobS3Region = Parameter("blob-s3-region", "eu-central-1"); + options.BlobS3Bucket = Parameter("blob-s3-bucket", "posta"); + options.BlobS3AccessKey = Parameter("blob-s3-access-key", "s3-access"); + options.BlobS3SecretKey = Parameter("blob-s3-secret-key", "s3-secret"); + options.BlobS3UseSsl = Parameter("blob-s3-use-ssl", "false"); + options.BlobS3PathStyle = Parameter("blob-s3-path-style", "true"); + options.BlobFileSystemPath = Parameter("blob-file-system-path", "/posta/attachments"); + options.EncryptionKey = Parameter("encryption-key", "encryption-secret"); + options.SystemSmtpHost = Parameter("system-smtp-host", "smtp.example.com"); + options.SystemSmtpPort = Parameter("system-smtp-port", "2525"); + options.SystemSmtpUsername = Parameter("system-smtp-username", "notifications@example.com"); + options.SystemSmtpPassword = Parameter("system-smtp-password", "smtp-secret"); + options.SystemSmtpFrom = Parameter("system-smtp-from", "notifications@example.com"); + options.SystemSmtpEncryption = Parameter("system-smtp-encryption", "ssl"); + options.InboundEnabled = Parameter("inbound-enabled", "true"); + options.InboundSmtpHost = Parameter("inbound-smtp-host", "127.0.0.1"); + options.InboundSmtpPort = Parameter("inbound-smtp-port", "2526"); + options.InboundHostname = Parameter("inbound-hostname", "mx.example.com"); + options.InboundMaxMessageSize = Parameter("inbound-max-message-size", "12345"); + options.InboundMaxAttachSize = Parameter("inbound-max-attach-size", "6789"); + options.InboundWebhookSecret = Parameter("inbound-webhook-secret", "inbound-secret"); + options.InboundTlsMode = Parameter("inbound-tls-mode", "starttls"); + options.InboundTlsCertFile = Parameter("inbound-tls-cert-file", "/certs/fullchain.pem"); + options.InboundTlsKeyFile = Parameter("inbound-tls-key-file", "/certs/privkey.pem"); + options.InboundSmtpRateLimit = Parameter("inbound-smtp-rate-limit", "10"); + options.InboundSmtpRateWindow = Parameter("inbound-smtp-rate-window", "20"); + options.EmailVerificationRequired = Parameter("email-verification-required", "true"); + options.AutoSuppressOnReject = Parameter("auto-suppress-on-reject", "false"); + options.EmailVerifyEnabled = Parameter("email-verify-enabled", "false"); + options.EmailVerifyCacheTtlHours = Parameter("email-verify-cache-ttl-hours", "12"); + options.EmailVerifyMxCacheTtlHours = Parameter("email-verify-mx-cache-ttl-hours", "6"); + options.EmailVerifyRateHourly = Parameter("email-verify-rate-hourly", "42"); + options.AllowDowngrade = Parameter("allow-downgrade", "true"); + options.PlanEnforcement = Parameter("plan-enforcement", "true"); + options.DatabaseUrl = Parameter("db-url", "postgres://example"); + options.RedisPassword = Parameter("redis-password", "redis-secret"); + options.RedisAddress = Parameter("redis-address", "redis.example.com:6379"); }) - .WithReference(redis, redisPassword); + .WithReference(redis, parameters["redis-password"]); Assert.True(posta.Resource.TryGetAnnotationsOfType(out var annotations)); @@ -243,63 +243,70 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() var env = context.EnvironmentVariables; Assert.Equal("9000", env["POSTA_PORT"]); - Assert.Equal("dev", env["POSTA_ENV"]); - Assert.Equal("true", env["POSTA_DEV_MODE"]); - Assert.Equal("false", env["POSTA_AUTH_RATE_LIMIT_ENABLED"]); - Assert.Equal("500", env["POSTA_RATE_LIMIT_HOURLY"]); - Assert.Equal("5000", env["POSTA_RATE_LIMIT_DAILY"]); - Assert.Equal("false", env["POSTA_OPENAPI_DOCS"]); - Assert.Equal("true", env["POSTA_METRICS_ENABLED"]); - Assert.Equal("web/dist", env["POSTA_WEB_DIR"]); - Assert.Equal("https://posta.example.com", env["POSTA_WEB_URL"]); - Assert.Equal("https://api.posta.example.com", env["POSTA_API_URL"]); - Assert.Equal("https://app.example.com", env["POSTA_CORS_ORIGINS"]); - Assert.Equal("false", env["POSTA_EMBEDDED_WORKER"]); - Assert.Equal("20", env["POSTA_WORKER_CONCURRENCY"]); - Assert.Equal("7", env["POSTA_WORKER_MAX_RETRIES"]); - Assert.Equal("4", env["POSTA_WEBHOOK_MAX_RETRIES"]); - Assert.Equal("30", env["POSTA_WEBHOOK_TIMEOUT_SECS"]); - Assert.Equal("http://proxy.example.com", env["POSTA_WEBHOOK_PROXY_URL"]); - Assert.Equal("google-client-id", env["POSTA_GOOGLE_OAUTH_CLIENT_ID"]); - Assert.Same(googleSecret.Resource, env["POSTA_GOOGLE_OAUTH_CLIENT_SECRET"]); - Assert.Equal("https://posta.example.com/oauth/callback", env["POSTA_OAUTH_CALLBACK_URL"]); - Assert.Equal("s3", env["POSTA_BLOB_PROVIDER"]); - Assert.Equal("https://s3.example.com", env["POSTA_BLOB_S3_ENDPOINT"]); - Assert.Equal("eu-central-1", env["POSTA_BLOB_S3_REGION"]); - Assert.Equal("posta", env["POSTA_BLOB_S3_BUCKET"]); - Assert.Same(s3AccessKey.Resource, env["POSTA_BLOB_S3_ACCESS_KEY"]); - Assert.Same(s3SecretKey.Resource, env["POSTA_BLOB_S3_SECRET_KEY"]); - Assert.Equal("false", env["POSTA_BLOB_S3_USE_SSL"]); - Assert.Equal("true", env["POSTA_BLOB_S3_PATH_STYLE"]); - Assert.Equal("/posta/attachments", env["POSTA_BLOB_FS_PATH"]); - Assert.Same(encryptionKey.Resource, env["POSTA_ENCRYPTION_KEY"]); - Assert.Equal("smtp.example.com", env["POSTA_SYSTEM_SMTP_HOST"]); - Assert.Equal("2525", env["POSTA_SYSTEM_SMTP_PORT"]); - Assert.Equal("notifications@example.com", env["POSTA_SYSTEM_SMTP_USERNAME"]); - Assert.Same(smtpPassword.Resource, env["POSTA_SYSTEM_SMTP_PASSWORD"]); - Assert.Equal("notifications@example.com", env["POSTA_SYSTEM_SMTP_FROM"]); - Assert.Equal("ssl", env["POSTA_SYSTEM_SMTP_ENCRYPTION"]); - Assert.Equal("true", env["POSTA_INBOUND_ENABLED"]); - Assert.Equal("127.0.0.1", env["POSTA_INBOUND_SMTP_HOST"]); - Assert.Equal("2526", env["POSTA_INBOUND_SMTP_PORT"]); - Assert.Equal("mx.example.com", env["POSTA_INBOUND_HOSTNAME"]); - Assert.Equal("12345", env["POSTA_INBOUND_MAX_MESSAGE_SIZE"]); - Assert.Equal("6789", env["POSTA_INBOUND_MAX_ATTACH_SIZE"]); - Assert.Same(inboundWebhookSecret.Resource, env["POSTA_INBOUND_WEBHOOK_SECRET"]); - Assert.Equal("starttls", env["POSTA_INBOUND_TLS_MODE"]); - Assert.Equal("/certs/fullchain.pem", env["POSTA_INBOUND_TLS_CERT_FILE"]); - Assert.Equal("/certs/privkey.pem", env["POSTA_INBOUND_TLS_KEY_FILE"]); - Assert.Equal("10", env["POSTA_INBOUND_SMTP_RATE_LIMIT"]); - Assert.Equal("20", env["POSTA_INBOUND_SMTP_RATE_WINDOW"]); - Assert.Equal("true", env["POSTA_EMAIL_VERIFICATION_REQUIRED"]); - Assert.Equal("false", env["POSTA_AUTO_SUPPRESS_ON_REJECT"]); - Assert.Equal("false", env["POSTA_EMAIL_VERIFY_ENABLED"]); - Assert.Equal("12", env["POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS"]); - Assert.Equal("6", env["POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS"]); - Assert.Equal("42", env["POSTA_EMAIL_VERIFY_RATE_HOURLY"]); - Assert.Equal("true", env["POSTA_ALLOW_DOWNGRADE"]); - Assert.Equal("true", env["POSTA_PLAN_ENFORCEMENT"]); - Assert.Same(databaseUrl.Resource, env["POSTA_DB_URL"]); + foreach (var (parameterName, environmentVariableName) in new (string ParameterName, string EnvironmentVariableName)[] + { + ("environment", "POSTA_ENV"), + ("dev-mode", "POSTA_DEV_MODE"), + ("auth-rate-limit-enabled", "POSTA_AUTH_RATE_LIMIT_ENABLED"), + ("rate-limit-hourly", "POSTA_RATE_LIMIT_HOURLY"), + ("rate-limit-daily", "POSTA_RATE_LIMIT_DAILY"), + ("openapi-docs", "POSTA_OPENAPI_DOCS"), + ("metrics-enabled", "POSTA_METRICS_ENABLED"), + ("web-dir", "POSTA_WEB_DIR"), + ("web-url", "POSTA_WEB_URL"), + ("api-url", "POSTA_API_URL"), + ("cors-origins", "POSTA_CORS_ORIGINS"), + ("embedded-worker", "POSTA_EMBEDDED_WORKER"), + ("worker-concurrency", "POSTA_WORKER_CONCURRENCY"), + ("worker-max-retries", "POSTA_WORKER_MAX_RETRIES"), + ("webhook-max-retries", "POSTA_WEBHOOK_MAX_RETRIES"), + ("webhook-timeout-seconds", "POSTA_WEBHOOK_TIMEOUT_SECS"), + ("webhook-proxy-url", "POSTA_WEBHOOK_PROXY_URL"), + ("google-oauth-client-id", "POSTA_GOOGLE_OAUTH_CLIENT_ID"), + ("google-oauth-client-secret", "POSTA_GOOGLE_OAUTH_CLIENT_SECRET"), + ("oauth-callback-url", "POSTA_OAUTH_CALLBACK_URL"), + ("blob-provider", "POSTA_BLOB_PROVIDER"), + ("blob-s3-endpoint", "POSTA_BLOB_S3_ENDPOINT"), + ("blob-s3-region", "POSTA_BLOB_S3_REGION"), + ("blob-s3-bucket", "POSTA_BLOB_S3_BUCKET"), + ("blob-s3-access-key", "POSTA_BLOB_S3_ACCESS_KEY"), + ("blob-s3-secret-key", "POSTA_BLOB_S3_SECRET_KEY"), + ("blob-s3-use-ssl", "POSTA_BLOB_S3_USE_SSL"), + ("blob-s3-path-style", "POSTA_BLOB_S3_PATH_STYLE"), + ("blob-file-system-path", "POSTA_BLOB_FS_PATH"), + ("encryption-key", "POSTA_ENCRYPTION_KEY"), + ("system-smtp-host", "POSTA_SYSTEM_SMTP_HOST"), + ("system-smtp-port", "POSTA_SYSTEM_SMTP_PORT"), + ("system-smtp-username", "POSTA_SYSTEM_SMTP_USERNAME"), + ("system-smtp-password", "POSTA_SYSTEM_SMTP_PASSWORD"), + ("system-smtp-from", "POSTA_SYSTEM_SMTP_FROM"), + ("system-smtp-encryption", "POSTA_SYSTEM_SMTP_ENCRYPTION"), + ("inbound-enabled", "POSTA_INBOUND_ENABLED"), + ("inbound-smtp-host", "POSTA_INBOUND_SMTP_HOST"), + ("inbound-smtp-port", "POSTA_INBOUND_SMTP_PORT"), + ("inbound-hostname", "POSTA_INBOUND_HOSTNAME"), + ("inbound-max-message-size", "POSTA_INBOUND_MAX_MESSAGE_SIZE"), + ("inbound-max-attach-size", "POSTA_INBOUND_MAX_ATTACH_SIZE"), + ("inbound-webhook-secret", "POSTA_INBOUND_WEBHOOK_SECRET"), + ("inbound-tls-mode", "POSTA_INBOUND_TLS_MODE"), + ("inbound-tls-cert-file", "POSTA_INBOUND_TLS_CERT_FILE"), + ("inbound-tls-key-file", "POSTA_INBOUND_TLS_KEY_FILE"), + ("inbound-smtp-rate-limit", "POSTA_INBOUND_SMTP_RATE_LIMIT"), + ("inbound-smtp-rate-window", "POSTA_INBOUND_SMTP_RATE_WINDOW"), + ("email-verification-required", "POSTA_EMAIL_VERIFICATION_REQUIRED"), + ("auto-suppress-on-reject", "POSTA_AUTO_SUPPRESS_ON_REJECT"), + ("email-verify-enabled", "POSTA_EMAIL_VERIFY_ENABLED"), + ("email-verify-cache-ttl-hours", "POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS"), + ("email-verify-mx-cache-ttl-hours", "POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS"), + ("email-verify-rate-hourly", "POSTA_EMAIL_VERIFY_RATE_HOURLY"), + ("allow-downgrade", "POSTA_ALLOW_DOWNGRADE"), + ("plan-enforcement", "POSTA_PLAN_ENFORCEMENT"), + ("db-url", "POSTA_DB_URL") + }) + { + AssertParameter(parameterName, environmentVariableName); + } + Assert.True(env.ContainsKey("POSTA_DB_HOST")); Assert.True(env.ContainsKey("POSTA_DB_PORT")); Assert.True(env.ContainsKey("POSTA_DB_USER")); @@ -307,8 +314,13 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() Assert.Equal("posta", env["POSTA_DB_NAME"]); Assert.Equal("disable", env["POSTA_DB_SSL_MODE"]); Assert.True(env.ContainsKey("POSTA_REDIS_ADDR")); - Assert.NotEqual("redis.example.com:6379", env["POSTA_REDIS_ADDR"].ToString()); - Assert.Same(redisPassword.Resource, env["POSTA_REDIS_PASSWORD"]); + AssertParameter("redis-address", "POSTA_REDIS_ADDR"); + AssertParameter("redis-password", "POSTA_REDIS_PASSWORD"); + + void AssertParameter(string parameterName, string environmentVariableName) + { + Assert.Same(parameters[parameterName].Resource, env[environmentVariableName]); + } } private static (IResourceBuilder Database, IResourceBuilder Redis) AddPostaDependencies(IDistributedApplicationBuilder builder) From eb0a90f8e0cc6b5dd7c5fa5a4783414ccc04d7c0 Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 01:35:02 +0300 Subject: [PATCH 15/24] Add parameter-based `AddPosta` overload with `PostaOptions` and improve method null-checks and chaining logic. --- .../PostaHostingExtensions.cs | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index 90c3e6a26..d80a255d1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -36,7 +36,42 @@ public static IResourceBuilder AddPosta( ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(redis); - return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions: null) + return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, options: null) + .WithReference(database) + .WithReference(redis); + } + + /// + /// Adds a Posta container resource to the with PostgreSQL, Redis, and additional Posta environment configuration. + /// + /// The to which the Posta resource will be added. + /// The name of the Posta container resource. + /// The PostgreSQL database resource used by Posta. + /// The Redis resource used by Posta. + /// The parameter-based Posta environment configuration. + /// Optional parameter used as the Posta JWT signing secret. + /// Optional parameter used as the initial Posta admin password. + /// The initial Posta admin account email. + /// Optional host port for the Posta HTTP API and dashboard. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "PostaOptions contains parameter builders and is not supported in polyglot app hosts.")] + public static IResourceBuilder AddPosta( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder database, + IResourceBuilder redis, + PostaOptions options, + IResourceBuilder? jwtSecret = null, + IResourceBuilder? adminPassword = null, + string adminEmail = "admin@example.com", + int? port = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(redis); + ArgumentNullException.ThrowIfNull(options); + + return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, options) .WithReference(database) .WithReference(redis); } @@ -54,7 +89,7 @@ public static IResourceBuilder AddPosta( /// The initial Posta admin account email. /// Optional host port for the Posta HTTP API and dashboard. /// A reference to the for further resource configuration. - [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the parameter-based overload instead.")] + [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the options object overload instead.")] public static IResourceBuilder AddPosta( this IDistributedApplicationBuilder builder, [ResourceName] string name, @@ -71,9 +106,10 @@ public static IResourceBuilder AddPosta( ArgumentNullException.ThrowIfNull(redis); ArgumentNullException.ThrowIfNull(configureOptions); - return AddPostaCore(builder, name, jwtSecret, adminPassword, adminEmail, port, configureOptions) - .WithReference(database) - .WithReference(redis); + var options = new PostaOptions(); + configureOptions.Invoke(options); + + return builder.AddPosta(name, database, redis, options, jwtSecret, adminPassword, adminEmail, port); } /// From eb2705a6e7547f5cfa5c30fcc08f18c8f7e49487 Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 01:35:14 +0300 Subject: [PATCH 16/24] Refactor `PostaHostingExtensions` to improve environment variable setup by replacing `SetIfNotNull` with `SetParameter`, adding default values, and introducing `SetIfAbsent` to prevent overwrites. --- .../PostaHostingExtensions.cs | 135 ++++++++++-------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index d80a255d1..896a73120 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -163,14 +163,14 @@ public static IResourceBuilder WithReference( var redisResource = redis.Resource; var redisEndpoint = redisResource.GetEndpoint("secondary"); - Set(context, "POSTA_REDIS_ADDR", ReferenceExpression.Create($"{redisEndpoint.Property(EndpointProperty.Host)}:{redisEndpoint.Property(EndpointProperty.Port)}")); + SetIfAbsent(context, "POSTA_REDIS_ADDR", ReferenceExpression.Create($"{redisEndpoint.Property(EndpointProperty.Host)}:{redisEndpoint.Property(EndpointProperty.Port)}")); if (redisPassword is not null) { Set(context, "POSTA_REDIS_PASSWORD", redisPassword.Resource); } else if (redisResource.PasswordParameter is not null) { - Set(context, "POSTA_REDIS_PASSWORD", redisResource.PasswordParameter); + SetIfAbsent(context, "POSTA_REDIS_PASSWORD", redisResource.PasswordParameter); } }) .WaitFor(redis); @@ -183,14 +183,13 @@ private static IResourceBuilder AddPostaCore( IResourceBuilder? adminPassword, string adminEmail, int? port, - Action? configureOptions) + PostaOptions? options) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrWhiteSpace(adminEmail); - var options = new PostaOptions(); - configureOptions?.Invoke(options); + options ??= new PostaOptions(); var jwtSecretParameter = jwtSecret?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-jwt-secret", minLower: 1, minUpper: 1, minNumeric: 1); @@ -211,7 +210,7 @@ private static IResourceBuilder AddPostaCore( { Set(context, "POSTA_PORT", PostaResource.HttpEndpointPort); SetParameter(context, "POSTA_DB_URL", options.DatabaseUrl); - SetIfNotNull(context, "POSTA_REDIS_ADDR", options.RedisAddress); + SetParameter(context, "POSTA_REDIS_ADDR", options.RedisAddress); if (options.RedisPassword is not null) { Set(context, "POSTA_REDIS_PASSWORD", options.RedisPassword.Resource); @@ -234,62 +233,62 @@ private static IResourceBuilder AddPostaCore( private static void ConfigurePostaEnvironment(EnvironmentCallbackContext context, PostaOptions options) { - Set(context, "POSTA_ENV", options.Environment); - Set(context, "POSTA_DEV_MODE", options.DevMode); - Set(context, "POSTA_AUTH_RATE_LIMIT_ENABLED", options.AuthRateLimitEnabled); - Set(context, "POSTA_RATE_LIMIT_HOURLY", options.RateLimitHourly); - Set(context, "POSTA_RATE_LIMIT_DAILY", options.RateLimitDaily); - Set(context, "POSTA_OPENAPI_DOCS", options.OpenApiDocs); - Set(context, "POSTA_METRICS_ENABLED", options.MetricsEnabled); - SetIfNotNull(context, "POSTA_WEB_DIR", options.WebDir); - SetIfNotNull(context, "POSTA_WEB_URL", options.WebUrl); - SetIfNotNull(context, "POSTA_API_URL", options.ApiUrl); - Set(context, "POSTA_CORS_ORIGINS", options.CorsOrigins); - Set(context, "POSTA_EMBEDDED_WORKER", options.EmbeddedWorker); - Set(context, "POSTA_WORKER_CONCURRENCY", options.WorkerConcurrency); - Set(context, "POSTA_WORKER_MAX_RETRIES", options.WorkerMaxRetries); - Set(context, "POSTA_WEBHOOK_MAX_RETRIES", options.WebhookMaxRetries); - Set(context, "POSTA_WEBHOOK_TIMEOUT_SECS", options.WebhookTimeoutSeconds); - SetIfNotNull(context, "POSTA_WEBHOOK_PROXY_URL", options.WebhookProxyUrl); - SetIfNotNull(context, "POSTA_GOOGLE_OAUTH_CLIENT_ID", options.GoogleOAuthClientId); + SetParameter(context, "POSTA_ENV", options.Environment, "production"); + SetParameter(context, "POSTA_DEV_MODE", options.DevMode, "false"); + SetParameter(context, "POSTA_AUTH_RATE_LIMIT_ENABLED", options.AuthRateLimitEnabled, "true"); + SetParameter(context, "POSTA_RATE_LIMIT_HOURLY", options.RateLimitHourly, "100"); + SetParameter(context, "POSTA_RATE_LIMIT_DAILY", options.RateLimitDaily, "1000"); + SetParameter(context, "POSTA_OPENAPI_DOCS", options.OpenApiDocs, "true"); + SetParameter(context, "POSTA_METRICS_ENABLED", options.MetricsEnabled, "false"); + SetParameter(context, "POSTA_WEB_DIR", options.WebDir); + SetParameter(context, "POSTA_WEB_URL", options.WebUrl); + SetParameter(context, "POSTA_API_URL", options.ApiUrl); + SetParameter(context, "POSTA_CORS_ORIGINS", options.CorsOrigins, "*"); + SetParameter(context, "POSTA_EMBEDDED_WORKER", options.EmbeddedWorker, "true"); + SetParameter(context, "POSTA_WORKER_CONCURRENCY", options.WorkerConcurrency, "10"); + SetParameter(context, "POSTA_WORKER_MAX_RETRIES", options.WorkerMaxRetries, "5"); + SetParameter(context, "POSTA_WEBHOOK_MAX_RETRIES", options.WebhookMaxRetries, "3"); + SetParameter(context, "POSTA_WEBHOOK_TIMEOUT_SECS", options.WebhookTimeoutSeconds, "10"); + SetParameter(context, "POSTA_WEBHOOK_PROXY_URL", options.WebhookProxyUrl); + SetParameter(context, "POSTA_GOOGLE_OAUTH_CLIENT_ID", options.GoogleOAuthClientId); SetParameter(context, "POSTA_GOOGLE_OAUTH_CLIENT_SECRET", options.GoogleOAuthClientSecret); - SetIfNotNull(context, "POSTA_OAUTH_CALLBACK_URL", options.OAuthCallbackUrl); - SetIfNotNull(context, "POSTA_BLOB_PROVIDER", options.BlobProvider); - SetIfNotNull(context, "POSTA_BLOB_S3_ENDPOINT", options.BlobS3Endpoint); - Set(context, "POSTA_BLOB_S3_REGION", options.BlobS3Region); - SetIfNotNull(context, "POSTA_BLOB_S3_BUCKET", options.BlobS3Bucket); + SetParameter(context, "POSTA_OAUTH_CALLBACK_URL", options.OAuthCallbackUrl); + SetParameter(context, "POSTA_BLOB_PROVIDER", options.BlobProvider); + SetParameter(context, "POSTA_BLOB_S3_ENDPOINT", options.BlobS3Endpoint); + SetParameter(context, "POSTA_BLOB_S3_REGION", options.BlobS3Region, "us-east-1"); + SetParameter(context, "POSTA_BLOB_S3_BUCKET", options.BlobS3Bucket); SetParameter(context, "POSTA_BLOB_S3_ACCESS_KEY", options.BlobS3AccessKey); SetParameter(context, "POSTA_BLOB_S3_SECRET_KEY", options.BlobS3SecretKey); - Set(context, "POSTA_BLOB_S3_USE_SSL", options.BlobS3UseSsl); - Set(context, "POSTA_BLOB_S3_PATH_STYLE", options.BlobS3PathStyle); - Set(context, "POSTA_BLOB_FS_PATH", options.BlobFileSystemPath); + SetParameter(context, "POSTA_BLOB_S3_USE_SSL", options.BlobS3UseSsl, "true"); + SetParameter(context, "POSTA_BLOB_S3_PATH_STYLE", options.BlobS3PathStyle, "false"); + SetParameter(context, "POSTA_BLOB_FS_PATH", options.BlobFileSystemPath, "/data/attachments"); SetParameter(context, "POSTA_ENCRYPTION_KEY", options.EncryptionKey); - SetIfNotNull(context, "POSTA_SYSTEM_SMTP_HOST", options.SystemSmtpHost); - Set(context, "POSTA_SYSTEM_SMTP_PORT", options.SystemSmtpPort); - SetIfNotNull(context, "POSTA_SYSTEM_SMTP_USERNAME", options.SystemSmtpUsername); + SetParameter(context, "POSTA_SYSTEM_SMTP_HOST", options.SystemSmtpHost); + SetParameter(context, "POSTA_SYSTEM_SMTP_PORT", options.SystemSmtpPort, "587"); + SetParameter(context, "POSTA_SYSTEM_SMTP_USERNAME", options.SystemSmtpUsername); SetParameter(context, "POSTA_SYSTEM_SMTP_PASSWORD", options.SystemSmtpPassword); - SetIfNotNull(context, "POSTA_SYSTEM_SMTP_FROM", options.SystemSmtpFrom); - Set(context, "POSTA_SYSTEM_SMTP_ENCRYPTION", options.SystemSmtpEncryption); - Set(context, "POSTA_INBOUND_ENABLED", options.InboundEnabled); - Set(context, "POSTA_INBOUND_SMTP_HOST", options.InboundSmtpHost); - Set(context, "POSTA_INBOUND_SMTP_PORT", options.InboundSmtpPort); - Set(context, "POSTA_INBOUND_HOSTNAME", options.InboundHostname); - Set(context, "POSTA_INBOUND_MAX_MESSAGE_SIZE", options.InboundMaxMessageSize); - Set(context, "POSTA_INBOUND_MAX_ATTACH_SIZE", options.InboundMaxAttachSize); + SetParameter(context, "POSTA_SYSTEM_SMTP_FROM", options.SystemSmtpFrom); + SetParameter(context, "POSTA_SYSTEM_SMTP_ENCRYPTION", options.SystemSmtpEncryption, "starttls"); + SetParameter(context, "POSTA_INBOUND_ENABLED", options.InboundEnabled, "false"); + SetParameter(context, "POSTA_INBOUND_SMTP_HOST", options.InboundSmtpHost, "0.0.0.0"); + SetParameter(context, "POSTA_INBOUND_SMTP_PORT", options.InboundSmtpPort, "2525"); + SetParameter(context, "POSTA_INBOUND_HOSTNAME", options.InboundHostname, "posta.local"); + SetParameter(context, "POSTA_INBOUND_MAX_MESSAGE_SIZE", options.InboundMaxMessageSize, "26214400"); + SetParameter(context, "POSTA_INBOUND_MAX_ATTACH_SIZE", options.InboundMaxAttachSize, "10485760"); SetParameter(context, "POSTA_INBOUND_WEBHOOK_SECRET", options.InboundWebhookSecret); - Set(context, "POSTA_INBOUND_TLS_MODE", options.InboundTlsMode); - SetIfNotNull(context, "POSTA_INBOUND_TLS_CERT_FILE", options.InboundTlsCertFile); - SetIfNotNull(context, "POSTA_INBOUND_TLS_KEY_FILE", options.InboundTlsKeyFile); - Set(context, "POSTA_INBOUND_SMTP_RATE_LIMIT", options.InboundSmtpRateLimit); - Set(context, "POSTA_INBOUND_SMTP_RATE_WINDOW", options.InboundSmtpRateWindow); - Set(context, "POSTA_EMAIL_VERIFICATION_REQUIRED", options.EmailVerificationRequired); - Set(context, "POSTA_AUTO_SUPPRESS_ON_REJECT", options.AutoSuppressOnReject); - Set(context, "POSTA_EMAIL_VERIFY_ENABLED", options.EmailVerifyEnabled); - Set(context, "POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS", options.EmailVerifyCacheTtlHours); - Set(context, "POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS", options.EmailVerifyMxCacheTtlHours); - Set(context, "POSTA_EMAIL_VERIFY_RATE_HOURLY", options.EmailVerifyRateHourly); - Set(context, "POSTA_ALLOW_DOWNGRADE", options.AllowDowngrade); - Set(context, "POSTA_PLAN_ENFORCEMENT", options.PlanEnforcement); + SetParameter(context, "POSTA_INBOUND_TLS_MODE", options.InboundTlsMode, "none"); + SetParameter(context, "POSTA_INBOUND_TLS_CERT_FILE", options.InboundTlsCertFile); + SetParameter(context, "POSTA_INBOUND_TLS_KEY_FILE", options.InboundTlsKeyFile); + SetParameter(context, "POSTA_INBOUND_SMTP_RATE_LIMIT", options.InboundSmtpRateLimit, "60"); + SetParameter(context, "POSTA_INBOUND_SMTP_RATE_WINDOW", options.InboundSmtpRateWindow, "60"); + SetParameter(context, "POSTA_EMAIL_VERIFICATION_REQUIRED", options.EmailVerificationRequired, "false"); + SetParameter(context, "POSTA_AUTO_SUPPRESS_ON_REJECT", options.AutoSuppressOnReject, "true"); + SetParameter(context, "POSTA_EMAIL_VERIFY_ENABLED", options.EmailVerifyEnabled, "true"); + SetParameter(context, "POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS", options.EmailVerifyCacheTtlHours, "168"); + SetParameter(context, "POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS", options.EmailVerifyMxCacheTtlHours, "24"); + SetParameter(context, "POSTA_EMAIL_VERIFY_RATE_HOURLY", options.EmailVerifyRateHourly, "1000"); + SetParameter(context, "POSTA_ALLOW_DOWNGRADE", options.AllowDowngrade, "false"); + SetParameter(context, "POSTA_PLAN_ENFORCEMENT", options.PlanEnforcement, "false"); } private static void Set(EnvironmentCallbackContext context, string name, string value) @@ -312,9 +311,14 @@ private static void Set(EnvironmentCallbackContext context, string name, Referen context.EnvironmentVariables[name] = value; } - private static void Set(EnvironmentCallbackContext context, string name, bool value) + private static void SetIfAbsent(EnvironmentCallbackContext context, string name, ParameterResource value) { - context.EnvironmentVariables[name] = value ? "true" : "false"; + context.EnvironmentVariables.TryAdd(name, value); + } + + private static void SetIfAbsent(EnvironmentCallbackContext context, string name, ReferenceExpression value) + { + context.EnvironmentVariables.TryAdd(name, value); } private static void Set(EnvironmentCallbackContext context, string name, int value) @@ -322,20 +326,27 @@ private static void Set(EnvironmentCallbackContext context, string name, int val context.EnvironmentVariables[name] = value.ToString(System.Globalization.CultureInfo.InvariantCulture); } - private static void SetIfNotNull(EnvironmentCallbackContext context, string name, string? value) + private static void SetParameter(EnvironmentCallbackContext context, string name, IResourceBuilder? parameter) { - if (!string.IsNullOrEmpty(value)) + if (parameter is not null) { - context.EnvironmentVariables[name] = value; + context.EnvironmentVariables[name] = parameter.Resource; } } - private static void SetParameter(EnvironmentCallbackContext context, string name, IResourceBuilder? parameter) + private static void SetParameter( + EnvironmentCallbackContext context, + string name, + IResourceBuilder? parameter, + string defaultValue) { if (parameter is not null) { context.EnvironmentVariables[name] = parameter.Resource; + return; } + + context.EnvironmentVariables[name] = defaultValue; } } From 74b634e7e48d17cad6214e15d4c19999ab98e188 Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 01:35:23 +0300 Subject: [PATCH 17/24] Refactor `PostaOptions` to replace primitive types with parameterized resource builders for improved flexibility and configurability. --- .../PostaOptions.cs | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs index f174491eb..f18cd443c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs @@ -3,99 +3,99 @@ namespace CommunityToolkit.Aspire.Hosting.Posta; /// -/// Configuration options for the Posta container. +/// Parameter-based configuration overrides for the Posta container. /// public sealed class PostaOptions { /// /// Gets or sets the Posta environment name. /// - public string Environment { get; set; } = "production"; + public IResourceBuilder? Environment { get; set; } /// - /// Gets or sets a value indicating whether Posta stores emails without sending them. + /// Gets or sets whether Posta stores emails without sending them. /// - public bool DevMode { get; set; } + public IResourceBuilder? DevMode { get; set; } /// - /// Gets or sets a value indicating whether auth rate limiting is enabled. + /// Gets or sets whether auth rate limiting is enabled. /// - public bool AuthRateLimitEnabled { get; set; } = true; + public IResourceBuilder? AuthRateLimitEnabled { get; set; } /// /// Gets or sets the maximum emails per hour per user. /// - public int RateLimitHourly { get; set; } = 100; + public IResourceBuilder? RateLimitHourly { get; set; } /// /// Gets or sets the maximum emails per day per user. /// - public int RateLimitDaily { get; set; } = 1000; + public IResourceBuilder? RateLimitDaily { get; set; } /// - /// Gets or sets a value indicating whether Swagger UI and ReDoc are enabled. + /// Gets or sets whether Swagger UI and ReDoc are enabled. /// - public bool OpenApiDocs { get; set; } = true; + public IResourceBuilder? OpenApiDocs { get; set; } /// - /// Gets or sets a value indicating whether Prometheus metrics are enabled. + /// Gets or sets whether Prometheus metrics are enabled. /// - public bool MetricsEnabled { get; set; } + public IResourceBuilder? MetricsEnabled { get; set; } /// /// Gets or sets the Vue dashboard build directory. /// - public string? WebDir { get; set; } + public IResourceBuilder? WebDir { get; set; } /// /// Gets or sets the public dashboard URL. /// - public string? WebUrl { get; set; } + public IResourceBuilder? WebUrl { get; set; } /// /// Gets or sets the public API base URL advertised in the OpenAPI servers list. /// - public string? ApiUrl { get; set; } + public IResourceBuilder? ApiUrl { get; set; } /// /// Gets or sets the comma-separated allowed CORS origins. /// - public string CorsOrigins { get; set; } = "*"; + public IResourceBuilder? CorsOrigins { get; set; } /// - /// Gets or sets a value indicating whether the worker runs in the server process. + /// Gets or sets whether the worker runs in the server process. /// - public bool EmbeddedWorker { get; set; } = true; + public IResourceBuilder? EmbeddedWorker { get; set; } /// /// Gets or sets the number of worker goroutines. /// - public int WorkerConcurrency { get; set; } = 10; + public IResourceBuilder? WorkerConcurrency { get; set; } /// /// Gets or sets the maximum email retry attempts. /// - public int WorkerMaxRetries { get; set; } = 5; + public IResourceBuilder? WorkerMaxRetries { get; set; } /// /// Gets or sets the maximum webhook delivery retry attempts. /// - public int WebhookMaxRetries { get; set; } = 3; + public IResourceBuilder? WebhookMaxRetries { get; set; } /// /// Gets or sets the webhook HTTP request timeout in seconds. /// - public int WebhookTimeoutSeconds { get; set; } = 10; + public IResourceBuilder? WebhookTimeoutSeconds { get; set; } /// /// Gets or sets the optional HTTP, HTTPS, or SOCKS5 proxy for webhook delivery. /// - public string? WebhookProxyUrl { get; set; } + public IResourceBuilder? WebhookProxyUrl { get; set; } /// /// Gets or sets the Google OAuth client ID for SSO login. /// - public string? GoogleOAuthClientId { get; set; } + public IResourceBuilder? GoogleOAuthClientId { get; set; } /// /// Gets or sets the Google OAuth client secret for SSO login. @@ -105,27 +105,27 @@ public sealed class PostaOptions /// /// Gets or sets the OAuth callback base URL. /// - public string? OAuthCallbackUrl { get; set; } + public IResourceBuilder? OAuthCallbackUrl { get; set; } /// /// Gets or sets the storage backend for attachments, either s3 or filesystem. /// - public string? BlobProvider { get; set; } + public IResourceBuilder? BlobProvider { get; set; } /// /// Gets or sets the S3-compatible endpoint. /// - public string? BlobS3Endpoint { get; set; } + public IResourceBuilder? BlobS3Endpoint { get; set; } /// /// Gets or sets the S3 region. /// - public string BlobS3Region { get; set; } = "us-east-1"; + public IResourceBuilder? BlobS3Region { get; set; } /// /// Gets or sets the S3 bucket name. /// - public string? BlobS3Bucket { get; set; } + public IResourceBuilder? BlobS3Bucket { get; set; } /// /// Gets or sets the S3 access key. @@ -138,19 +138,19 @@ public sealed class PostaOptions public IResourceBuilder? BlobS3SecretKey { get; set; } /// - /// Gets or sets a value indicating whether S3 storage uses TLS. + /// Gets or sets whether S3 storage uses TLS. /// - public bool BlobS3UseSsl { get; set; } = true; + public IResourceBuilder? BlobS3UseSsl { get; set; } /// - /// Gets or sets a value indicating whether S3 path-style addressing is used. + /// Gets or sets whether S3 path-style addressing is used. /// - public bool BlobS3PathStyle { get; set; } + public IResourceBuilder? BlobS3PathStyle { get; set; } /// /// Gets or sets the filesystem storage path. /// - public string BlobFileSystemPath { get; set; } = "/data/attachments"; + public IResourceBuilder? BlobFileSystemPath { get; set; } /// /// Gets or sets the AES-256-GCM encryption key for stored SMTP passwords. @@ -160,17 +160,17 @@ public sealed class PostaOptions /// /// Gets or sets the system SMTP server host used for platform notifications. /// - public string? SystemSmtpHost { get; set; } + public IResourceBuilder? SystemSmtpHost { get; set; } /// /// Gets or sets the system SMTP server port. /// - public int SystemSmtpPort { get; set; } = 587; + public IResourceBuilder? SystemSmtpPort { get; set; } /// /// Gets or sets the system SMTP username. /// - public string? SystemSmtpUsername { get; set; } + public IResourceBuilder? SystemSmtpUsername { get; set; } /// /// Gets or sets the system SMTP password. @@ -180,42 +180,42 @@ public sealed class PostaOptions /// /// Gets or sets the system SMTP from address. /// - public string? SystemSmtpFrom { get; set; } + public IResourceBuilder? SystemSmtpFrom { get; set; } /// /// Gets or sets the system SMTP encryption mode: none, ssl, or starttls. /// - public string SystemSmtpEncryption { get; set; } = "starttls"; + public IResourceBuilder? SystemSmtpEncryption { get; set; } /// - /// Gets or sets a value indicating whether inbound email processing is enabled. + /// Gets or sets whether inbound email processing is enabled. /// - public bool InboundEnabled { get; set; } + public IResourceBuilder? InboundEnabled { get; set; } /// /// Gets or sets the bind address for the built-in SMTP receiver. /// - public string InboundSmtpHost { get; set; } = "0.0.0.0"; + public IResourceBuilder? InboundSmtpHost { get; set; } /// /// Gets or sets the SMTP listener port. /// - public int InboundSmtpPort { get; set; } = 2525; + public IResourceBuilder? InboundSmtpPort { get; set; } /// /// Gets or sets the hostname announced in EHLO and used as TLS SNI. /// - public string InboundHostname { get; set; } = "posta.local"; + public IResourceBuilder? InboundHostname { get; set; } /// /// Gets or sets the maximum raw inbound message size in bytes. /// - public int InboundMaxMessageSize { get; set; } = 26214400; + public IResourceBuilder? InboundMaxMessageSize { get; set; } /// /// Gets or sets the maximum inbound attachment size in bytes. /// - public int InboundMaxAttachSize { get; set; } = 10485760; + public IResourceBuilder? InboundMaxAttachSize { get; set; } /// /// Gets or sets the shared secret for inbound webhooks. @@ -225,67 +225,67 @@ public sealed class PostaOptions /// /// Gets or sets the inbound SMTP TLS mode: none or starttls. /// - public string InboundTlsMode { get; set; } = "none"; + public IResourceBuilder? InboundTlsMode { get; set; } /// /// Gets or sets the PEM certificate path for inbound TLS. /// - public string? InboundTlsCertFile { get; set; } + public IResourceBuilder? InboundTlsCertFile { get; set; } /// /// Gets or sets the PEM key path for inbound TLS. /// - public string? InboundTlsKeyFile { get; set; } + public IResourceBuilder? InboundTlsKeyFile { get; set; } /// /// Gets or sets the per-IP SMTP session rate limit. /// - public int InboundSmtpRateLimit { get; set; } = 60; + public IResourceBuilder? InboundSmtpRateLimit { get; set; } /// /// Gets or sets the SMTP rate-limit window in seconds. /// - public int InboundSmtpRateWindow { get; set; } = 60; + public IResourceBuilder? InboundSmtpRateWindow { get; set; } /// - /// Gets or sets a value indicating whether users must verify their email address before sign-in. + /// Gets or sets whether users must verify their email address before sign-in. /// - public bool EmailVerificationRequired { get; set; } + public IResourceBuilder? EmailVerificationRequired { get; set; } /// - /// Gets or sets a value indicating whether permanently rejected recipients are added to the suppression list. + /// Gets or sets whether permanently rejected recipients are added to the suppression list. /// - public bool AutoSuppressOnReject { get; set; } = true; + public IResourceBuilder? AutoSuppressOnReject { get; set; } /// - /// Gets or sets a value indicating whether the email verification endpoint is enabled. + /// Gets or sets whether the email verification endpoint is enabled. /// - public bool EmailVerifyEnabled { get; set; } = true; + public IResourceBuilder? EmailVerifyEnabled { get; set; } /// /// Gets or sets how long address-level verification results are cached, in hours. /// - public int EmailVerifyCacheTtlHours { get; set; } = 168; + public IResourceBuilder? EmailVerifyCacheTtlHours { get; set; } /// /// Gets or sets how long domain MX lookups are cached, in hours. /// - public int EmailVerifyMxCacheTtlHours { get; set; } = 24; + public IResourceBuilder? EmailVerifyMxCacheTtlHours { get; set; } /// /// Gets or sets the per-user hourly cap on verification requests. /// - public int EmailVerifyRateHourly { get; set; } = 1000; + public IResourceBuilder? EmailVerifyRateHourly { get; set; } /// - /// Gets or sets a value indicating whether older binaries can boot against a newer database schema. + /// Gets or sets whether older binaries can boot against a newer database schema. /// - public bool AllowDowngrade { get; set; } + public IResourceBuilder? AllowDowngrade { get; set; } /// - /// Gets or sets a value indicating whether hosted plan limits and quotas are enforced. + /// Gets or sets whether hosted plan limits and quotas are enforced. /// - public bool PlanEnforcement { get; set; } + public IResourceBuilder? PlanEnforcement { get; set; } /// /// Gets or sets a PostgreSQL connection string parameter that overrides individual database settings. @@ -300,5 +300,5 @@ public sealed class PostaOptions /// /// Gets or sets an external Redis address in the form host:port. /// - public string? RedisAddress { get; set; } + public IResourceBuilder? RedisAddress { get; set; } } From b777259e46d7fe18ccb360e5cd02e26a8d12c36c Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 13:20:53 +0300 Subject: [PATCH 18/24] Add Posta configuration options and environment variable constants - Introduced `PostaEmailVerificationOptions`, `PostaGoogleOAuthOptions`, `PostaInboundSmtpOptions`, `PostaS3BlobStorageOptions`, and `PostaSystemSmtpOptions` for parameterized configuration. - Added `PostaEnvironmentVariables` to define constants for environment variable keys. - Created `launchSettings.json` example for local hosting configuration. --- .../Properties/launchSettings.json | 29 ++++++++ .../PostaEmailVerificationOptions.cs | 39 ++++++++++ .../PostaEnvironmentVariables.cs | 74 +++++++++++++++++++ .../PostaGoogleOAuthOptions.cs | 24 ++++++ .../PostaInboundSmtpOptions.cs | 69 +++++++++++++++++ .../PostaS3BlobStorageOptions.cs | 44 +++++++++++ .../PostaSystemSmtpOptions.cs | 39 ++++++++++ 7 files changed, 318 insertions(+) create mode 100644 examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Properties/launchSettings.json create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaEmailVerificationOptions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaEnvironmentVariables.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaGoogleOAuthOptions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaInboundSmtpOptions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaS3BlobStorageOptions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Posta/PostaSystemSmtpOptions.cs diff --git a/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Properties/launchSettings.json b/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..0a6a02a6a --- /dev/null +++ b/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:18131;http://localhost:12049", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:32487", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:12811" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15184", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19198", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:21263" + } + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaEmailVerificationOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaEmailVerificationOptions.cs new file mode 100644 index 000000000..41b7d0ebc --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaEmailVerificationOptions.cs @@ -0,0 +1,39 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Posta; + +/// +/// Parameter-based configuration options for Posta email verification. +/// +public sealed class PostaEmailVerificationOptions +{ + /// + /// Gets or sets whether users must verify their email address before sign-in. + /// + public IResourceBuilder? Required { get; set; } + + /// + /// Gets or sets whether permanently rejected recipients are added to the suppression list. + /// + public IResourceBuilder? AutoSuppressOnReject { get; set; } + + /// + /// Gets or sets whether the email verification endpoint is enabled. + /// + public IResourceBuilder? Enabled { get; set; } + + /// + /// Gets or sets how long address-level verification results are cached, in hours. + /// + public IResourceBuilder? CacheTtlHours { get; set; } + + /// + /// Gets or sets how long domain MX lookups are cached, in hours. + /// + public IResourceBuilder? MxCacheTtlHours { get; set; } + + /// + /// Gets or sets the per-user hourly cap on verification requests. + /// + public IResourceBuilder? RateHourly { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaEnvironmentVariables.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaEnvironmentVariables.cs new file mode 100644 index 000000000..24491fb0a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaEnvironmentVariables.cs @@ -0,0 +1,74 @@ +namespace CommunityToolkit.Aspire.Hosting.Posta; + +internal static class PostaEnvironmentVariables +{ + public const string Port = "POSTA_PORT"; + public const string DbUrl = "POSTA_DB_URL"; + public const string DbHost = "POSTA_DB_HOST"; + public const string DbPort = "POSTA_DB_PORT"; + public const string DbUser = "POSTA_DB_USER"; + public const string DbPassword = "POSTA_DB_PASSWORD"; + public const string DbName = "POSTA_DB_NAME"; + public const string DbSslMode = "POSTA_DB_SSL_MODE"; + public const string RedisAddress = "POSTA_REDIS_ADDR"; + public const string RedisPassword = "POSTA_REDIS_PASSWORD"; + public const string JwtSecret = "POSTA_JWT_SECRET"; + public const string AdminEmail = "POSTA_ADMIN_EMAIL"; + public const string AdminPassword = "POSTA_ADMIN_PASSWORD"; + public const string Environment = "POSTA_ENV"; + public const string DevMode = "POSTA_DEV_MODE"; + public const string AuthRateLimitEnabled = "POSTA_AUTH_RATE_LIMIT_ENABLED"; + public const string RateLimitHourly = "POSTA_RATE_LIMIT_HOURLY"; + public const string RateLimitDaily = "POSTA_RATE_LIMIT_DAILY"; + public const string OpenApiDocs = "POSTA_OPENAPI_DOCS"; + public const string MetricsEnabled = "POSTA_METRICS_ENABLED"; + public const string WebDir = "POSTA_WEB_DIR"; + public const string WebUrl = "POSTA_WEB_URL"; + public const string ApiUrl = "POSTA_API_URL"; + public const string CorsOrigins = "POSTA_CORS_ORIGINS"; + public const string EmbeddedWorker = "POSTA_EMBEDDED_WORKER"; + public const string WorkerConcurrency = "POSTA_WORKER_CONCURRENCY"; + public const string WorkerMaxRetries = "POSTA_WORKER_MAX_RETRIES"; + public const string WebhookMaxRetries = "POSTA_WEBHOOK_MAX_RETRIES"; + public const string WebhookTimeoutSeconds = "POSTA_WEBHOOK_TIMEOUT_SECS"; + public const string WebhookProxyUrl = "POSTA_WEBHOOK_PROXY_URL"; + public const string GoogleOAuthClientId = "POSTA_GOOGLE_OAUTH_CLIENT_ID"; + public const string GoogleOAuthClientSecret = "POSTA_GOOGLE_OAUTH_CLIENT_SECRET"; + public const string OAuthCallbackUrl = "POSTA_OAUTH_CALLBACK_URL"; + public const string BlobProvider = "POSTA_BLOB_PROVIDER"; + public const string BlobS3Endpoint = "POSTA_BLOB_S3_ENDPOINT"; + public const string BlobS3Region = "POSTA_BLOB_S3_REGION"; + public const string BlobS3Bucket = "POSTA_BLOB_S3_BUCKET"; + public const string BlobS3AccessKey = "POSTA_BLOB_S3_ACCESS_KEY"; + public const string BlobS3SecretKey = "POSTA_BLOB_S3_SECRET_KEY"; + public const string BlobS3UseSsl = "POSTA_BLOB_S3_USE_SSL"; + public const string BlobS3PathStyle = "POSTA_BLOB_S3_PATH_STYLE"; + public const string BlobFileSystemPath = "POSTA_BLOB_FS_PATH"; + public const string EncryptionKey = "POSTA_ENCRYPTION_KEY"; + public const string SystemSmtpHost = "POSTA_SYSTEM_SMTP_HOST"; + public const string SystemSmtpPort = "POSTA_SYSTEM_SMTP_PORT"; + public const string SystemSmtpUsername = "POSTA_SYSTEM_SMTP_USERNAME"; + public const string SystemSmtpPassword = "POSTA_SYSTEM_SMTP_PASSWORD"; + public const string SystemSmtpFrom = "POSTA_SYSTEM_SMTP_FROM"; + public const string SystemSmtpEncryption = "POSTA_SYSTEM_SMTP_ENCRYPTION"; + public const string InboundEnabled = "POSTA_INBOUND_ENABLED"; + public const string InboundSmtpHost = "POSTA_INBOUND_SMTP_HOST"; + public const string InboundSmtpPort = "POSTA_INBOUND_SMTP_PORT"; + public const string InboundHostname = "POSTA_INBOUND_HOSTNAME"; + public const string InboundMaxMessageSize = "POSTA_INBOUND_MAX_MESSAGE_SIZE"; + public const string InboundMaxAttachSize = "POSTA_INBOUND_MAX_ATTACH_SIZE"; + public const string InboundWebhookSecret = "POSTA_INBOUND_WEBHOOK_SECRET"; + public const string InboundTlsMode = "POSTA_INBOUND_TLS_MODE"; + public const string InboundTlsCertFile = "POSTA_INBOUND_TLS_CERT_FILE"; + public const string InboundTlsKeyFile = "POSTA_INBOUND_TLS_KEY_FILE"; + public const string InboundSmtpRateLimit = "POSTA_INBOUND_SMTP_RATE_LIMIT"; + public const string InboundSmtpRateWindow = "POSTA_INBOUND_SMTP_RATE_WINDOW"; + public const string EmailVerificationRequired = "POSTA_EMAIL_VERIFICATION_REQUIRED"; + public const string AutoSuppressOnReject = "POSTA_AUTO_SUPPRESS_ON_REJECT"; + public const string EmailVerifyEnabled = "POSTA_EMAIL_VERIFY_ENABLED"; + public const string EmailVerifyCacheTtlHours = "POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS"; + public const string EmailVerifyMxCacheTtlHours = "POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS"; + public const string EmailVerifyRateHourly = "POSTA_EMAIL_VERIFY_RATE_HOURLY"; + public const string AllowDowngrade = "POSTA_ALLOW_DOWNGRADE"; + public const string PlanEnforcement = "POSTA_PLAN_ENFORCEMENT"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaGoogleOAuthOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaGoogleOAuthOptions.cs new file mode 100644 index 000000000..f6067e293 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaGoogleOAuthOptions.cs @@ -0,0 +1,24 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Posta; + +/// +/// Parameter-based configuration options for Posta Google OAuth login. +/// +public sealed class PostaGoogleOAuthOptions +{ + /// + /// Gets or sets the Google OAuth client ID for SSO login. + /// + public IResourceBuilder? ClientId { get; set; } + + /// + /// Gets or sets the Google OAuth client secret for SSO login. + /// + public IResourceBuilder? ClientSecret { get; set; } + + /// + /// Gets or sets the OAuth callback base URL. + /// + public IResourceBuilder? CallbackUrl { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaInboundSmtpOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaInboundSmtpOptions.cs new file mode 100644 index 000000000..656238a44 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaInboundSmtpOptions.cs @@ -0,0 +1,69 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Posta; + +/// +/// Parameter-based configuration options for Posta inbound SMTP processing. +/// +public sealed class PostaInboundSmtpOptions +{ + /// + /// Gets or sets whether inbound email processing is enabled. + /// + public IResourceBuilder? Enabled { get; set; } + + /// + /// Gets or sets the bind address for the built-in SMTP receiver. + /// + public IResourceBuilder? Host { get; set; } + + /// + /// Gets or sets the SMTP listener port. + /// + public IResourceBuilder? Port { get; set; } + + /// + /// Gets or sets the hostname announced in EHLO and used as TLS SNI. + /// + public IResourceBuilder? Hostname { get; set; } + + /// + /// Gets or sets the maximum raw inbound message size in bytes. + /// + public IResourceBuilder? MaxMessageSize { get; set; } + + /// + /// Gets or sets the maximum inbound attachment size in bytes. + /// + public IResourceBuilder? MaxAttachmentSize { get; set; } + + /// + /// Gets or sets the shared secret for inbound webhooks. + /// + public IResourceBuilder? WebhookSecret { get; set; } + + /// + /// Gets or sets the inbound SMTP TLS mode: none or starttls. + /// + public IResourceBuilder? TlsMode { get; set; } + + /// + /// Gets or sets the PEM certificate path for inbound TLS. + /// + public IResourceBuilder? TlsCertFile { get; set; } + + /// + /// Gets or sets the PEM key path for inbound TLS. + /// + public IResourceBuilder? TlsKeyFile { get; set; } + + /// + /// Gets or sets the per-IP SMTP session rate limit. + /// + public IResourceBuilder? RateLimit { get; set; } + + /// + /// Gets or sets the SMTP rate-limit window in seconds. + /// + public IResourceBuilder? RateWindow { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaS3BlobStorageOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaS3BlobStorageOptions.cs new file mode 100644 index 000000000..ac80a0bfb --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaS3BlobStorageOptions.cs @@ -0,0 +1,44 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Posta; + +/// +/// Parameter-based configuration options for Posta S3-compatible attachment storage. +/// +public sealed class PostaS3BlobStorageOptions +{ + /// + /// Gets or sets the S3-compatible endpoint. + /// + public IResourceBuilder? Endpoint { get; set; } + + /// + /// Gets or sets the S3 region. + /// + public IResourceBuilder? Region { get; set; } + + /// + /// Gets or sets the S3 bucket name. + /// + public IResourceBuilder? Bucket { get; set; } + + /// + /// Gets or sets the S3 access key. + /// + public IResourceBuilder? AccessKey { get; set; } + + /// + /// Gets or sets the S3 secret key. + /// + public IResourceBuilder? SecretKey { get; set; } + + /// + /// Gets or sets whether S3 storage uses TLS. + /// + public IResourceBuilder? UseSsl { get; set; } + + /// + /// Gets or sets whether S3 path-style addressing is used. + /// + public IResourceBuilder? PathStyle { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaSystemSmtpOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaSystemSmtpOptions.cs new file mode 100644 index 000000000..c61f285f0 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaSystemSmtpOptions.cs @@ -0,0 +1,39 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Posta; + +/// +/// Parameter-based configuration options for Posta system SMTP notifications. +/// +public sealed class PostaSystemSmtpOptions +{ + /// + /// Gets or sets the system SMTP server host used for platform notifications. + /// + public IResourceBuilder? Host { get; set; } + + /// + /// Gets or sets the system SMTP server port. + /// + public IResourceBuilder? Port { get; set; } + + /// + /// Gets or sets the system SMTP username. + /// + public IResourceBuilder? Username { get; set; } + + /// + /// Gets or sets the system SMTP password. + /// + public IResourceBuilder? Password { get; set; } + + /// + /// Gets or sets the system SMTP from address. + /// + public IResourceBuilder? From { get; set; } + + /// + /// Gets or sets the system SMTP encryption mode: none, ssl, or starttls. + /// + public IResourceBuilder? Encryption { get; set; } +} From acdadd13e3227a4298eaac9cfcfa03146a604274 Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 13:21:04 +0300 Subject: [PATCH 19/24] Refactor `AddPosta` tests to improve parameterized resource configuration, enhance environment variable assertions, and streamline option setup. --- .../ContainerResourceCreationTests.cs | 338 ++++++++++++------ 1 file changed, 224 insertions(+), 114 deletions(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs index e8478951b..18973ae85 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs @@ -156,6 +156,167 @@ public async Task AddPostaConfiguresPostaEnvironmentVariables() var postgres = builder.AddPostgres("postgres"); var database = postgres.AddDatabase("posta-db", "posta"); var redis = builder.AddRedis("redis"); + var databaseUrl = builder.AddParameter("posta-db-url", "postgres://example"); + var redisPassword = builder.AddParameter("posta-redis-password", "redis-secret"); + var googleSecret = builder.AddParameter("posta-google-secret", "google-secret"); + var s3AccessKey = builder.AddParameter("posta-s3-access-key", "s3-access"); + var s3SecretKey = builder.AddParameter("posta-s3-secret-key", "s3-secret"); + var encryptionKey = builder.AddParameter("posta-encryption-key", "encryption-secret"); + var smtpPassword = builder.AddParameter("posta-smtp-password", "smtp-secret"); + var inboundWebhookSecret = builder.AddParameter("posta-inbound-secret", "inbound-secret"); + + var posta = builder.AddPosta("posta", database, redis, options => + { + options.Environment = "dev"; + options.DevMode = true; + options.AuthRateLimitEnabled = false; + options.RateLimitHourly = 500; + options.RateLimitDaily = 5000; + options.OpenApiDocs = false; + options.MetricsEnabled = true; + options.WebDir = "web/dist"; + options.WebUrl = "https://posta.example.com"; + options.ApiUrl = "https://api.posta.example.com"; + options.CorsOrigins = "https://app.example.com"; + options.EmbeddedWorker = false; + options.WorkerConcurrency = 20; + options.WorkerMaxRetries = 7; + options.WebhookMaxRetries = 4; + options.WebhookTimeoutSeconds = 30; + options.WebhookProxyUrl = "http://proxy.example.com"; + options.GoogleOAuthClientId = "google-client-id"; + options.GoogleOAuthClientSecret = googleSecret; + options.OAuthCallbackUrl = "https://posta.example.com/oauth/callback"; + options.BlobProvider = "s3"; + options.BlobS3Endpoint = "https://s3.example.com"; + options.BlobS3Region = "eu-central-1"; + options.BlobS3Bucket = "posta"; + options.BlobS3AccessKey = s3AccessKey; + options.BlobS3SecretKey = s3SecretKey; + options.BlobS3UseSsl = false; + options.BlobS3PathStyle = true; + options.BlobFileSystemPath = "/posta/attachments"; + options.EncryptionKey = encryptionKey; + options.SystemSmtpHost = "smtp.example.com"; + options.SystemSmtpPort = 2525; + options.SystemSmtpUsername = "notifications@example.com"; + options.SystemSmtpPassword = smtpPassword; + options.SystemSmtpFrom = "notifications@example.com"; + options.SystemSmtpEncryption = "ssl"; + options.InboundEnabled = true; + options.InboundSmtpHost = "127.0.0.1"; + options.InboundSmtpPort = 2526; + options.InboundHostname = "mx.example.com"; + options.InboundMaxMessageSize = 12345; + options.InboundMaxAttachSize = 6789; + options.InboundWebhookSecret = inboundWebhookSecret; + options.InboundTlsMode = "starttls"; + options.InboundTlsCertFile = "/certs/fullchain.pem"; + options.InboundTlsKeyFile = "/certs/privkey.pem"; + options.InboundSmtpRateLimit = 10; + options.InboundSmtpRateWindow = 20; + options.EmailVerificationRequired = true; + options.AutoSuppressOnReject = false; + options.EmailVerifyEnabled = false; + options.EmailVerifyCacheTtlHours = 12; + options.EmailVerifyMxCacheTtlHours = 6; + options.EmailVerifyRateHourly = 42; + options.AllowDowngrade = true; + options.PlanEnforcement = true; + options.DatabaseUrl = databaseUrl; + options.RedisPassword = redisPassword; + options.RedisAddress = "redis.example.com:6379"; + }) + .WithReference(redis, redisPassword); + + Assert.True(posta.Resource.TryGetAnnotationsOfType(out var annotations)); + + var context = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext( + new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run))); + + foreach (var annotation in annotations) + { + await annotation.Callback(context); + } + + var env = context.EnvironmentVariables; + + Assert.Equal("9000", env["POSTA_PORT"]); + Assert.Equal("dev", env["POSTA_ENV"]); + Assert.Equal("true", env["POSTA_DEV_MODE"]); + Assert.Equal("false", env["POSTA_AUTH_RATE_LIMIT_ENABLED"]); + Assert.Equal("500", env["POSTA_RATE_LIMIT_HOURLY"]); + Assert.Equal("5000", env["POSTA_RATE_LIMIT_DAILY"]); + Assert.Equal("false", env["POSTA_OPENAPI_DOCS"]); + Assert.Equal("true", env["POSTA_METRICS_ENABLED"]); + Assert.Equal("web/dist", env["POSTA_WEB_DIR"]); + Assert.Equal("https://posta.example.com", env["POSTA_WEB_URL"]); + Assert.Equal("https://api.posta.example.com", env["POSTA_API_URL"]); + Assert.Equal("https://app.example.com", env["POSTA_CORS_ORIGINS"]); + Assert.Equal("false", env["POSTA_EMBEDDED_WORKER"]); + Assert.Equal("20", env["POSTA_WORKER_CONCURRENCY"]); + Assert.Equal("7", env["POSTA_WORKER_MAX_RETRIES"]); + Assert.Equal("4", env["POSTA_WEBHOOK_MAX_RETRIES"]); + Assert.Equal("30", env["POSTA_WEBHOOK_TIMEOUT_SECS"]); + Assert.Equal("http://proxy.example.com", env["POSTA_WEBHOOK_PROXY_URL"]); + Assert.Equal("google-client-id", env["POSTA_GOOGLE_OAUTH_CLIENT_ID"]); + Assert.Same(googleSecret.Resource, env["POSTA_GOOGLE_OAUTH_CLIENT_SECRET"]); + Assert.Equal("https://posta.example.com/oauth/callback", env["POSTA_OAUTH_CALLBACK_URL"]); + Assert.Equal("s3", env["POSTA_BLOB_PROVIDER"]); + Assert.Equal("https://s3.example.com", env["POSTA_BLOB_S3_ENDPOINT"]); + Assert.Equal("eu-central-1", env["POSTA_BLOB_S3_REGION"]); + Assert.Equal("posta", env["POSTA_BLOB_S3_BUCKET"]); + Assert.Same(s3AccessKey.Resource, env["POSTA_BLOB_S3_ACCESS_KEY"]); + Assert.Same(s3SecretKey.Resource, env["POSTA_BLOB_S3_SECRET_KEY"]); + Assert.Equal("false", env["POSTA_BLOB_S3_USE_SSL"]); + Assert.Equal("true", env["POSTA_BLOB_S3_PATH_STYLE"]); + Assert.Equal("/posta/attachments", env["POSTA_BLOB_FS_PATH"]); + Assert.Same(encryptionKey.Resource, env["POSTA_ENCRYPTION_KEY"]); + Assert.Equal("smtp.example.com", env["POSTA_SYSTEM_SMTP_HOST"]); + Assert.Equal("2525", env["POSTA_SYSTEM_SMTP_PORT"]); + Assert.Equal("notifications@example.com", env["POSTA_SYSTEM_SMTP_USERNAME"]); + Assert.Same(smtpPassword.Resource, env["POSTA_SYSTEM_SMTP_PASSWORD"]); + Assert.Equal("notifications@example.com", env["POSTA_SYSTEM_SMTP_FROM"]); + Assert.Equal("ssl", env["POSTA_SYSTEM_SMTP_ENCRYPTION"]); + Assert.Equal("true", env["POSTA_INBOUND_ENABLED"]); + Assert.Equal("127.0.0.1", env["POSTA_INBOUND_SMTP_HOST"]); + Assert.Equal("2526", env["POSTA_INBOUND_SMTP_PORT"]); + Assert.Equal("mx.example.com", env["POSTA_INBOUND_HOSTNAME"]); + Assert.Equal("12345", env["POSTA_INBOUND_MAX_MESSAGE_SIZE"]); + Assert.Equal("6789", env["POSTA_INBOUND_MAX_ATTACH_SIZE"]); + Assert.Same(inboundWebhookSecret.Resource, env["POSTA_INBOUND_WEBHOOK_SECRET"]); + Assert.Equal("starttls", env["POSTA_INBOUND_TLS_MODE"]); + Assert.Equal("/certs/fullchain.pem", env["POSTA_INBOUND_TLS_CERT_FILE"]); + Assert.Equal("/certs/privkey.pem", env["POSTA_INBOUND_TLS_KEY_FILE"]); + Assert.Equal("10", env["POSTA_INBOUND_SMTP_RATE_LIMIT"]); + Assert.Equal("20", env["POSTA_INBOUND_SMTP_RATE_WINDOW"]); + Assert.Equal("true", env["POSTA_EMAIL_VERIFICATION_REQUIRED"]); + Assert.Equal("false", env["POSTA_AUTO_SUPPRESS_ON_REJECT"]); + Assert.Equal("false", env["POSTA_EMAIL_VERIFY_ENABLED"]); + Assert.Equal("12", env["POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS"]); + Assert.Equal("6", env["POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS"]); + Assert.Equal("42", env["POSTA_EMAIL_VERIFY_RATE_HOURLY"]); + Assert.Equal("true", env["POSTA_ALLOW_DOWNGRADE"]); + Assert.Equal("true", env["POSTA_PLAN_ENFORCEMENT"]); + Assert.Same(databaseUrl.Resource, env["POSTA_DB_URL"]); + + Assert.True(env.ContainsKey("POSTA_DB_HOST")); + Assert.True(env.ContainsKey("POSTA_DB_PORT")); + Assert.True(env.ContainsKey("POSTA_DB_USER")); + Assert.True(env.ContainsKey("POSTA_DB_PASSWORD")); + Assert.Equal("posta", env["POSTA_DB_NAME"]); + Assert.Equal("disable", env["POSTA_DB_SSL_MODE"]); + Assert.True(env.ContainsKey("POSTA_REDIS_ADDR")); + Assert.Equal("redis.example.com:6379", env["POSTA_REDIS_ADDR"]); + Assert.Same(redisPassword.Resource, env["POSTA_REDIS_PASSWORD"]); + } + + [Fact] + public async Task WithGroupedOptionsConfiguresParameterBasedEnvironmentVariables() + { + var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); var parameters = new Dictionary>(); IResourceBuilder Parameter(string name, string value) @@ -165,69 +326,56 @@ IResourceBuilder Parameter(string name, string value) return parameter; } - var posta = builder.AddPosta("posta", database, redis, options => - { - options.Environment = Parameter("environment", "dev"); - options.DevMode = Parameter("dev-mode", "true"); - options.AuthRateLimitEnabled = Parameter("auth-rate-limit-enabled", "false"); - options.RateLimitHourly = Parameter("rate-limit-hourly", "500"); - options.RateLimitDaily = Parameter("rate-limit-daily", "5000"); - options.OpenApiDocs = Parameter("openapi-docs", "false"); - options.MetricsEnabled = Parameter("metrics-enabled", "true"); - options.WebDir = Parameter("web-dir", "web/dist"); - options.WebUrl = Parameter("web-url", "https://posta.example.com"); - options.ApiUrl = Parameter("api-url", "https://api.posta.example.com"); - options.CorsOrigins = Parameter("cors-origins", "https://app.example.com"); - options.EmbeddedWorker = Parameter("embedded-worker", "false"); - options.WorkerConcurrency = Parameter("worker-concurrency", "20"); - options.WorkerMaxRetries = Parameter("worker-max-retries", "7"); - options.WebhookMaxRetries = Parameter("webhook-max-retries", "4"); - options.WebhookTimeoutSeconds = Parameter("webhook-timeout-seconds", "30"); - options.WebhookProxyUrl = Parameter("webhook-proxy-url", "http://proxy.example.com"); - options.GoogleOAuthClientId = Parameter("google-oauth-client-id", "google-client-id"); - options.GoogleOAuthClientSecret = Parameter("google-oauth-client-secret", "google-secret"); - options.OAuthCallbackUrl = Parameter("oauth-callback-url", "https://posta.example.com/oauth/callback"); - options.BlobProvider = Parameter("blob-provider", "s3"); - options.BlobS3Endpoint = Parameter("blob-s3-endpoint", "https://s3.example.com"); - options.BlobS3Region = Parameter("blob-s3-region", "eu-central-1"); - options.BlobS3Bucket = Parameter("blob-s3-bucket", "posta"); - options.BlobS3AccessKey = Parameter("blob-s3-access-key", "s3-access"); - options.BlobS3SecretKey = Parameter("blob-s3-secret-key", "s3-secret"); - options.BlobS3UseSsl = Parameter("blob-s3-use-ssl", "false"); - options.BlobS3PathStyle = Parameter("blob-s3-path-style", "true"); - options.BlobFileSystemPath = Parameter("blob-file-system-path", "/posta/attachments"); - options.EncryptionKey = Parameter("encryption-key", "encryption-secret"); - options.SystemSmtpHost = Parameter("system-smtp-host", "smtp.example.com"); - options.SystemSmtpPort = Parameter("system-smtp-port", "2525"); - options.SystemSmtpUsername = Parameter("system-smtp-username", "notifications@example.com"); - options.SystemSmtpPassword = Parameter("system-smtp-password", "smtp-secret"); - options.SystemSmtpFrom = Parameter("system-smtp-from", "notifications@example.com"); - options.SystemSmtpEncryption = Parameter("system-smtp-encryption", "ssl"); - options.InboundEnabled = Parameter("inbound-enabled", "true"); - options.InboundSmtpHost = Parameter("inbound-smtp-host", "127.0.0.1"); - options.InboundSmtpPort = Parameter("inbound-smtp-port", "2526"); - options.InboundHostname = Parameter("inbound-hostname", "mx.example.com"); - options.InboundMaxMessageSize = Parameter("inbound-max-message-size", "12345"); - options.InboundMaxAttachSize = Parameter("inbound-max-attach-size", "6789"); - options.InboundWebhookSecret = Parameter("inbound-webhook-secret", "inbound-secret"); - options.InboundTlsMode = Parameter("inbound-tls-mode", "starttls"); - options.InboundTlsCertFile = Parameter("inbound-tls-cert-file", "/certs/fullchain.pem"); - options.InboundTlsKeyFile = Parameter("inbound-tls-key-file", "/certs/privkey.pem"); - options.InboundSmtpRateLimit = Parameter("inbound-smtp-rate-limit", "10"); - options.InboundSmtpRateWindow = Parameter("inbound-smtp-rate-window", "20"); - options.EmailVerificationRequired = Parameter("email-verification-required", "true"); - options.AutoSuppressOnReject = Parameter("auto-suppress-on-reject", "false"); - options.EmailVerifyEnabled = Parameter("email-verify-enabled", "false"); - options.EmailVerifyCacheTtlHours = Parameter("email-verify-cache-ttl-hours", "12"); - options.EmailVerifyMxCacheTtlHours = Parameter("email-verify-mx-cache-ttl-hours", "6"); - options.EmailVerifyRateHourly = Parameter("email-verify-rate-hourly", "42"); - options.AllowDowngrade = Parameter("allow-downgrade", "true"); - options.PlanEnforcement = Parameter("plan-enforcement", "true"); - options.DatabaseUrl = Parameter("db-url", "postgres://example"); - options.RedisPassword = Parameter("redis-password", "redis-secret"); - options.RedisAddress = Parameter("redis-address", "redis.example.com:6379"); - }) - .WithReference(redis, parameters["redis-password"]); + var posta = builder.AddPosta("posta", database, redis) + .WithSystemSmtp(new PostaSystemSmtpOptions + { + Host = Parameter("system-smtp-host", "smtp.example.com"), + Port = Parameter("system-smtp-port", "2525"), + Username = Parameter("system-smtp-username", "notifications@example.com"), + Password = Parameter("system-smtp-password", "smtp-secret"), + From = Parameter("system-smtp-from", "notifications@example.com"), + Encryption = Parameter("system-smtp-encryption", "ssl") + }) + .WithInboundSmtp(new PostaInboundSmtpOptions + { + Enabled = Parameter("inbound-enabled", "true"), + Host = Parameter("inbound-smtp-host", "127.0.0.1"), + Port = Parameter("inbound-smtp-port", "2526"), + Hostname = Parameter("inbound-hostname", "mx.example.com"), + MaxMessageSize = Parameter("inbound-max-message-size", "12345"), + MaxAttachmentSize = Parameter("inbound-max-attachment-size", "6789"), + WebhookSecret = Parameter("inbound-webhook-secret", "inbound-secret"), + TlsMode = Parameter("inbound-tls-mode", "starttls"), + TlsCertFile = Parameter("inbound-tls-cert-file", "/certs/fullchain.pem"), + TlsKeyFile = Parameter("inbound-tls-key-file", "/certs/privkey.pem"), + RateLimit = Parameter("inbound-smtp-rate-limit", "10"), + RateWindow = Parameter("inbound-smtp-rate-window", "20") + }) + .WithS3BlobStorage(new PostaS3BlobStorageOptions + { + Endpoint = Parameter("blob-s3-endpoint", "https://s3.example.com"), + Region = Parameter("blob-s3-region", "eu-central-1"), + Bucket = Parameter("blob-s3-bucket", "posta"), + AccessKey = Parameter("blob-s3-access-key", "s3-access"), + SecretKey = Parameter("blob-s3-secret-key", "s3-secret"), + UseSsl = Parameter("blob-s3-use-ssl", "false"), + PathStyle = Parameter("blob-s3-path-style", "true") + }) + .WithGoogleOAuth(new PostaGoogleOAuthOptions + { + ClientId = Parameter("google-oauth-client-id", "google-client-id"), + ClientSecret = Parameter("google-oauth-client-secret", "google-secret"), + CallbackUrl = Parameter("oauth-callback-url", "https://posta.example.com/oauth/callback") + }) + .WithEmailVerification(new PostaEmailVerificationOptions + { + Required = Parameter("email-verification-required", "true"), + AutoSuppressOnReject = Parameter("auto-suppress-on-reject", "false"), + Enabled = Parameter("email-verify-enabled", "false"), + CacheTtlHours = Parameter("email-verify-cache-ttl-hours", "12"), + MxCacheTtlHours = Parameter("email-verify-mx-cache-ttl-hours", "6"), + RateHourly = Parameter("email-verify-rate-hourly", "42") + }); Assert.True(posta.Resource.TryGetAnnotationsOfType(out var annotations)); @@ -242,39 +390,9 @@ IResourceBuilder Parameter(string name, string value) var env = context.EnvironmentVariables; - Assert.Equal("9000", env["POSTA_PORT"]); + Assert.Equal("s3", env["POSTA_BLOB_PROVIDER"]); foreach (var (parameterName, environmentVariableName) in new (string ParameterName, string EnvironmentVariableName)[] { - ("environment", "POSTA_ENV"), - ("dev-mode", "POSTA_DEV_MODE"), - ("auth-rate-limit-enabled", "POSTA_AUTH_RATE_LIMIT_ENABLED"), - ("rate-limit-hourly", "POSTA_RATE_LIMIT_HOURLY"), - ("rate-limit-daily", "POSTA_RATE_LIMIT_DAILY"), - ("openapi-docs", "POSTA_OPENAPI_DOCS"), - ("metrics-enabled", "POSTA_METRICS_ENABLED"), - ("web-dir", "POSTA_WEB_DIR"), - ("web-url", "POSTA_WEB_URL"), - ("api-url", "POSTA_API_URL"), - ("cors-origins", "POSTA_CORS_ORIGINS"), - ("embedded-worker", "POSTA_EMBEDDED_WORKER"), - ("worker-concurrency", "POSTA_WORKER_CONCURRENCY"), - ("worker-max-retries", "POSTA_WORKER_MAX_RETRIES"), - ("webhook-max-retries", "POSTA_WEBHOOK_MAX_RETRIES"), - ("webhook-timeout-seconds", "POSTA_WEBHOOK_TIMEOUT_SECS"), - ("webhook-proxy-url", "POSTA_WEBHOOK_PROXY_URL"), - ("google-oauth-client-id", "POSTA_GOOGLE_OAUTH_CLIENT_ID"), - ("google-oauth-client-secret", "POSTA_GOOGLE_OAUTH_CLIENT_SECRET"), - ("oauth-callback-url", "POSTA_OAUTH_CALLBACK_URL"), - ("blob-provider", "POSTA_BLOB_PROVIDER"), - ("blob-s3-endpoint", "POSTA_BLOB_S3_ENDPOINT"), - ("blob-s3-region", "POSTA_BLOB_S3_REGION"), - ("blob-s3-bucket", "POSTA_BLOB_S3_BUCKET"), - ("blob-s3-access-key", "POSTA_BLOB_S3_ACCESS_KEY"), - ("blob-s3-secret-key", "POSTA_BLOB_S3_SECRET_KEY"), - ("blob-s3-use-ssl", "POSTA_BLOB_S3_USE_SSL"), - ("blob-s3-path-style", "POSTA_BLOB_S3_PATH_STYLE"), - ("blob-file-system-path", "POSTA_BLOB_FS_PATH"), - ("encryption-key", "POSTA_ENCRYPTION_KEY"), ("system-smtp-host", "POSTA_SYSTEM_SMTP_HOST"), ("system-smtp-port", "POSTA_SYSTEM_SMTP_PORT"), ("system-smtp-username", "POSTA_SYSTEM_SMTP_USERNAME"), @@ -286,38 +404,30 @@ IResourceBuilder Parameter(string name, string value) ("inbound-smtp-port", "POSTA_INBOUND_SMTP_PORT"), ("inbound-hostname", "POSTA_INBOUND_HOSTNAME"), ("inbound-max-message-size", "POSTA_INBOUND_MAX_MESSAGE_SIZE"), - ("inbound-max-attach-size", "POSTA_INBOUND_MAX_ATTACH_SIZE"), + ("inbound-max-attachment-size", "POSTA_INBOUND_MAX_ATTACH_SIZE"), ("inbound-webhook-secret", "POSTA_INBOUND_WEBHOOK_SECRET"), ("inbound-tls-mode", "POSTA_INBOUND_TLS_MODE"), ("inbound-tls-cert-file", "POSTA_INBOUND_TLS_CERT_FILE"), ("inbound-tls-key-file", "POSTA_INBOUND_TLS_KEY_FILE"), ("inbound-smtp-rate-limit", "POSTA_INBOUND_SMTP_RATE_LIMIT"), ("inbound-smtp-rate-window", "POSTA_INBOUND_SMTP_RATE_WINDOW"), + ("blob-s3-endpoint", "POSTA_BLOB_S3_ENDPOINT"), + ("blob-s3-region", "POSTA_BLOB_S3_REGION"), + ("blob-s3-bucket", "POSTA_BLOB_S3_BUCKET"), + ("blob-s3-access-key", "POSTA_BLOB_S3_ACCESS_KEY"), + ("blob-s3-secret-key", "POSTA_BLOB_S3_SECRET_KEY"), + ("blob-s3-use-ssl", "POSTA_BLOB_S3_USE_SSL"), + ("blob-s3-path-style", "POSTA_BLOB_S3_PATH_STYLE"), + ("google-oauth-client-id", "POSTA_GOOGLE_OAUTH_CLIENT_ID"), + ("google-oauth-client-secret", "POSTA_GOOGLE_OAUTH_CLIENT_SECRET"), + ("oauth-callback-url", "POSTA_OAUTH_CALLBACK_URL"), ("email-verification-required", "POSTA_EMAIL_VERIFICATION_REQUIRED"), ("auto-suppress-on-reject", "POSTA_AUTO_SUPPRESS_ON_REJECT"), ("email-verify-enabled", "POSTA_EMAIL_VERIFY_ENABLED"), ("email-verify-cache-ttl-hours", "POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS"), ("email-verify-mx-cache-ttl-hours", "POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS"), - ("email-verify-rate-hourly", "POSTA_EMAIL_VERIFY_RATE_HOURLY"), - ("allow-downgrade", "POSTA_ALLOW_DOWNGRADE"), - ("plan-enforcement", "POSTA_PLAN_ENFORCEMENT"), - ("db-url", "POSTA_DB_URL") + ("email-verify-rate-hourly", "POSTA_EMAIL_VERIFY_RATE_HOURLY") }) - { - AssertParameter(parameterName, environmentVariableName); - } - - Assert.True(env.ContainsKey("POSTA_DB_HOST")); - Assert.True(env.ContainsKey("POSTA_DB_PORT")); - Assert.True(env.ContainsKey("POSTA_DB_USER")); - Assert.True(env.ContainsKey("POSTA_DB_PASSWORD")); - Assert.Equal("posta", env["POSTA_DB_NAME"]); - Assert.Equal("disable", env["POSTA_DB_SSL_MODE"]); - Assert.True(env.ContainsKey("POSTA_REDIS_ADDR")); - AssertParameter("redis-address", "POSTA_REDIS_ADDR"); - AssertParameter("redis-password", "POSTA_REDIS_PASSWORD"); - - void AssertParameter(string parameterName, string environmentVariableName) { Assert.Same(parameters[parameterName].Resource, env[environmentVariableName]); } From 47e238e0f91d7cb542bb3af73b69fc65cafb4827 Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 13:21:12 +0300 Subject: [PATCH 20/24] Refactor `AddPosta` and README to enhance parameterized configuration, improve secret handling, and streamline examples. --- .../PostaHostingExtensions.cs | 4 +- .../README.md | 128 ++++++++++++++++-- 2 files changed, 116 insertions(+), 16 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index 896a73120..5ed6f80ec 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -48,13 +48,13 @@ public static IResourceBuilder AddPosta( /// The name of the Posta container resource. /// The PostgreSQL database resource used by Posta. /// The Redis resource used by Posta. - /// The parameter-based Posta environment configuration. + /// The Posta environment configuration. /// Optional parameter used as the Posta JWT signing secret. /// Optional parameter used as the initial Posta admin password. /// The initial Posta admin account email. /// Optional host port for the Posta HTTP API and dashboard. /// A reference to the for further resource configuration. - [AspireExportIgnore(Reason = "PostaOptions contains parameter builders and is not supported in polyglot app hosts.")] + [AspireExportIgnore(Reason = "PostaOptions contains parameter builders for secret values and is not supported in polyglot app hosts.")] public static IResourceBuilder AddPosta( this IDistributedApplicationBuilder builder, [ResourceName] string name, diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md index d442aa0d9..8cdea6732 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md @@ -10,25 +10,17 @@ Install the package in your AppHost project: aspire add CommunityToolkit.Aspire.Hosting.Posta ``` -## Usage example +## Basic usage ```csharp var builder = DistributedApplication.CreateBuilder(args); -var postgres = builder.AddPostgres("postgres"); +var postgresPassword = builder.AddParameter("postgres-password", secret: true); +var postgres = builder.AddPostgres("postgres", password: postgresPassword); var database = postgres.AddDatabase("posta-db", "posta"); var redis = builder.AddRedis("redis"); -var postaWebUrl = builder.AddParameter("posta-web-url"); -var postaCorsOrigins = builder.AddParameter("posta-cors-origins"); -var postaMetricsEnabled = builder.AddParameter("posta-metrics-enabled"); - -var posta = builder.AddPosta("posta", database, redis, options => - { - options.WebUrl = postaWebUrl; - options.CorsOrigins = postaCorsOrigins; - options.MetricsEnabled = postaMetricsEnabled; - }); +var posta = builder.AddPosta("posta", database, redis); builder.AddProject("api") .WithReference(posta) @@ -37,9 +29,117 @@ builder.AddProject("api") builder.Build().Run(); ``` -Posta stores data in PostgreSQL, uses Redis for queueing, and runs the embedded worker in the main container by default. `AddPosta` requires PostgreSQL database and Redis resources so the container starts with the dependencies it needs. Use `PostaOptions.DatabaseUrl`, `PostaOptions.RedisAddress`, and `PostaOptions.RedisPassword` only when you need to override the generated Posta environment values. +Posta stores data in PostgreSQL, uses Redis for queueing, and runs the embedded worker in the main container by default. `AddPosta` requires PostgreSQL database and Redis resources so the container starts with the dependencies it needs. + +## Configuration + +Use the `PostaOptions` callback when values are known in the AppHost and can be represented as typed literal values. Secret values inside `PostaOptions`, such as OAuth client secrets, S3 keys, SMTP passwords, encryption keys, inbound webhook secrets, `POSTA_DB_URL`, and Redis passwords, should still be passed as Aspire parameters. + +```csharp +var encryptionKey = builder.AddParameter("posta-encryption-key", secret: true); +var smtpPassword = builder.AddParameter("posta-smtp-password", secret: true); + +var posta = builder.AddPosta("posta", database, redis, options => +{ + options.Environment = "production"; + options.WebUrl = "https://mail.example.com"; + options.ApiUrl = "https://mail.example.com"; + options.CorsOrigins = "https://app.example.com"; + options.MetricsEnabled = true; + options.EncryptionKey = encryptionKey; + + options.SystemSmtpHost = "smtp.example.com"; + options.SystemSmtpPort = 587; + options.SystemSmtpPassword = smtpPassword; + options.SystemSmtpFrom = "notifications@example.com"; + options.SystemSmtpEncryption = "starttls"; +}); +``` + +For values coming from configuration, environment variables, user secrets, or publish-time parameters, use the grouped parameter-based methods. These methods accept options objects whose properties are `IResourceBuilder?`, so they work with `AddParameter` and `AddParameterFromConfiguration`. + +```csharp +var smtpHost = builder.AddParameterFromConfiguration("posta-smtp-host", "Posta:Smtp:Host"); +var smtpPort = builder.AddParameterFromConfiguration("posta-smtp-port", "Posta:Smtp:Port"); +var smtpUsername = builder.AddParameterFromConfiguration("posta-smtp-username", "Posta:Smtp:Username"); +var smtpPassword = builder.AddParameterFromConfiguration("posta-smtp-password", "Posta:Smtp:Password", secret: true); +var smtpFrom = builder.AddParameterFromConfiguration("posta-smtp-from", "Posta:Smtp:From"); +var smtpEncryption = builder.AddParameterFromConfiguration("posta-smtp-encryption", "Posta:Smtp:Encryption"); + +var googleClientId = builder.AddParameterFromConfiguration("posta-google-client-id", "Posta:GoogleOAuth:ClientId"); +var googleClientSecret = builder.AddParameterFromConfiguration("posta-google-client-secret", "Posta:GoogleOAuth:ClientSecret", secret: true); +var googleCallbackUrl = builder.AddParameterFromConfiguration("posta-google-callback-url", "Posta:GoogleOAuth:CallbackUrl"); + +var s3Endpoint = builder.AddParameterFromConfiguration("posta-s3-endpoint", "Posta:S3:Endpoint"); +var s3Region = builder.AddParameterFromConfiguration("posta-s3-region", "Posta:S3:Region"); +var s3Bucket = builder.AddParameterFromConfiguration("posta-s3-bucket", "Posta:S3:Bucket"); +var s3AccessKey = builder.AddParameterFromConfiguration("posta-s3-access-key", "Posta:S3:AccessKey", secret: true); +var s3SecretKey = builder.AddParameterFromConfiguration("posta-s3-secret-key", "Posta:S3:SecretKey", secret: true); + +var posta = builder.AddPosta("posta", database, redis) + .WithSystemSmtp(new PostaSystemSmtpOptions + { + Host = smtpHost, + Port = smtpPort, + Username = smtpUsername, + Password = smtpPassword, + From = smtpFrom, + Encryption = smtpEncryption + }) + .WithGoogleOAuth(new PostaGoogleOAuthOptions + { + ClientId = googleClientId, + ClientSecret = googleClientSecret, + CallbackUrl = googleCallbackUrl + }) + .WithS3BlobStorage(new PostaS3BlobStorageOptions + { + Endpoint = s3Endpoint, + Region = s3Region, + Bucket = s3Bucket, + AccessKey = s3AccessKey, + SecretKey = s3SecretKey + }); +``` + +You can also configure inbound SMTP and email verification through parameter-based options: + +```csharp +var inboundEnabled = builder.AddParameterFromConfiguration("posta-inbound-enabled", "Posta:Inbound:Enabled"); +var inboundPort = builder.AddParameterFromConfiguration("posta-inbound-port", "Posta:Inbound:Port"); +var inboundWebhookSecret = builder.AddParameterFromConfiguration("posta-inbound-webhook-secret", "Posta:Inbound:WebhookSecret", secret: true); + +var emailVerificationRequired = builder.AddParameterFromConfiguration("posta-email-verification-required", "Posta:EmailVerification:Required"); -Use the `PostaOptions` callback to configure Posta environment variables such as rate limits, web URLs, OAuth, blob storage, system SMTP, inbound email, email verification, and advanced switches. `PostaOptions` values are Aspire parameter builders so values can come from configuration, environment variables, user secrets, or publish-time parameters instead of being materialized in the AppHost model. Sensitive values such as SMTP passwords, S3 keys, OAuth client secrets, encryption keys, inbound webhook secrets, `POSTA_DB_URL`, and Redis password should be created as secret parameters. +builder.AddPosta("posta", database, redis) + .WithInboundSmtp(new PostaInboundSmtpOptions + { + Enabled = inboundEnabled, + Port = inboundPort, + WebhookSecret = inboundWebhookSecret + }) + .WithEmailVerification(new PostaEmailVerificationOptions + { + Required = emailVerificationRequired + }); +``` + +## API overview + +| Method | Purpose | +| --- | --- | +| `AddPosta(name, database, redis)` | Adds Posta and configures PostgreSQL and Redis references. | +| `AddPosta(name, database, redis, PostaOptions)` | Adds Posta with typed environment configuration. | +| `AddPosta(name, database, redis, Action)` | Adds Posta with callback-based typed environment configuration. | +| `WithReference(PostgresDatabaseResource)` | Configures the PostgreSQL database environment variables and waits for the database. | +| `WithReference(RedisResource, redisPassword)` | Configures Redis address/password environment variables and waits for Redis. | +| `WithSystemSmtp(PostaSystemSmtpOptions)` | Configures parameter-based system SMTP settings. | +| `WithInboundSmtp(PostaInboundSmtpOptions)` | Configures parameter-based inbound SMTP receiver settings. | +| `WithS3BlobStorage(PostaS3BlobStorageOptions)` | Configures parameter-based S3-compatible attachment storage and sets the blob provider to `s3`. | +| `WithGoogleOAuth(PostaGoogleOAuthOptions)` | Configures parameter-based Google OAuth login settings. | +| `WithEmailVerification(PostaEmailVerificationOptions)` | Configures parameter-based email verification settings. | + +Use `PostaOptions.DatabaseUrl`, `PostaOptions.RedisAddress`, and `PostaOptions.RedisPassword` only when you need to override the generated PostgreSQL or Redis environment values. ## Connection Properties From b6382a3d927bba8a57bc72643553211216d4ef9d Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 13:21:18 +0300 Subject: [PATCH 21/24] Refactor `PostaHostingExtensions` to use `PostaEnvironmentVariables` constants, improve environment variable handling, and modularize configuration setup. --- .../PostaHostingExtensions.cs | 245 ++++++++++++------ 1 file changed, 161 insertions(+), 84 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index 5ed6f80ec..da2499413 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -131,12 +131,12 @@ public static IResourceBuilder WithReference( { var postgres = database.Resource.Parent; - Set(context, "POSTA_DB_HOST", postgres.PrimaryEndpoint.Property(EndpointProperty.Host)); - Set(context, "POSTA_DB_PORT", postgres.PrimaryEndpoint.Property(EndpointProperty.Port)); - Set(context, "POSTA_DB_USER", postgres.UserNameReference); - Set(context, "POSTA_DB_PASSWORD", postgres.PasswordParameter); - Set(context, "POSTA_DB_NAME", database.Resource.DatabaseName); - Set(context, "POSTA_DB_SSL_MODE", "disable"); + Set(context, PostaEnvironmentVariables.DbHost, postgres.PrimaryEndpoint.Property(EndpointProperty.Host)); + Set(context, PostaEnvironmentVariables.DbPort, postgres.PrimaryEndpoint.Property(EndpointProperty.Port)); + Set(context, PostaEnvironmentVariables.DbUser, postgres.UserNameReference); + Set(context, PostaEnvironmentVariables.DbPassword, postgres.PasswordParameter); + Set(context, PostaEnvironmentVariables.DbName, database.Resource.DatabaseName); + Set(context, PostaEnvironmentVariables.DbSslMode, "disable"); }) .WaitFor(database); } @@ -163,14 +163,14 @@ public static IResourceBuilder WithReference( var redisResource = redis.Resource; var redisEndpoint = redisResource.GetEndpoint("secondary"); - SetIfAbsent(context, "POSTA_REDIS_ADDR", ReferenceExpression.Create($"{redisEndpoint.Property(EndpointProperty.Host)}:{redisEndpoint.Property(EndpointProperty.Port)}")); + SetIfAbsent(context, PostaEnvironmentVariables.RedisAddress, ReferenceExpression.Create($"{redisEndpoint.Property(EndpointProperty.Host)}:{redisEndpoint.Property(EndpointProperty.Port)}")); if (redisPassword is not null) { - Set(context, "POSTA_REDIS_PASSWORD", redisPassword.Resource); + Set(context, PostaEnvironmentVariables.RedisPassword, redisPassword.Resource); } else if (redisResource.PasswordParameter is not null) { - SetIfAbsent(context, "POSTA_REDIS_PASSWORD", redisResource.PasswordParameter); + SetIfAbsent(context, PostaEnvironmentVariables.RedisPassword, redisResource.PasswordParameter); } }) .WaitFor(redis); @@ -208,16 +208,16 @@ private static IResourceBuilder AddPostaCore( name: PostaResource.HttpEndpointName) .WithEnvironment(context => { - Set(context, "POSTA_PORT", PostaResource.HttpEndpointPort); - SetParameter(context, "POSTA_DB_URL", options.DatabaseUrl); - SetParameter(context, "POSTA_REDIS_ADDR", options.RedisAddress); + Set(context, PostaEnvironmentVariables.Port, PostaResource.HttpEndpointPort); + SetParameter(context, PostaEnvironmentVariables.DbUrl, options.DatabaseUrl); + SetIfNotNull(context, PostaEnvironmentVariables.RedisAddress, options.RedisAddress); if (options.RedisPassword is not null) { - Set(context, "POSTA_REDIS_PASSWORD", options.RedisPassword.Resource); + Set(context, PostaEnvironmentVariables.RedisPassword, options.RedisPassword.Resource); } - Set(context, "POSTA_JWT_SECRET", resource.JwtSecretParameter); - Set(context, "POSTA_ADMIN_EMAIL", adminEmail); - Set(context, "POSTA_ADMIN_PASSWORD", resource.AdminPasswordParameter); + Set(context, PostaEnvironmentVariables.JwtSecret, resource.JwtSecretParameter); + Set(context, PostaEnvironmentVariables.AdminEmail, adminEmail); + Set(context, PostaEnvironmentVariables.AdminPassword, resource.AdminPasswordParameter); ConfigurePostaEnvironment(context, options); }) .WithHttpHealthCheck( @@ -233,62 +233,142 @@ private static IResourceBuilder AddPostaCore( private static void ConfigurePostaEnvironment(EnvironmentCallbackContext context, PostaOptions options) { - SetParameter(context, "POSTA_ENV", options.Environment, "production"); - SetParameter(context, "POSTA_DEV_MODE", options.DevMode, "false"); - SetParameter(context, "POSTA_AUTH_RATE_LIMIT_ENABLED", options.AuthRateLimitEnabled, "true"); - SetParameter(context, "POSTA_RATE_LIMIT_HOURLY", options.RateLimitHourly, "100"); - SetParameter(context, "POSTA_RATE_LIMIT_DAILY", options.RateLimitDaily, "1000"); - SetParameter(context, "POSTA_OPENAPI_DOCS", options.OpenApiDocs, "true"); - SetParameter(context, "POSTA_METRICS_ENABLED", options.MetricsEnabled, "false"); - SetParameter(context, "POSTA_WEB_DIR", options.WebDir); - SetParameter(context, "POSTA_WEB_URL", options.WebUrl); - SetParameter(context, "POSTA_API_URL", options.ApiUrl); - SetParameter(context, "POSTA_CORS_ORIGINS", options.CorsOrigins, "*"); - SetParameter(context, "POSTA_EMBEDDED_WORKER", options.EmbeddedWorker, "true"); - SetParameter(context, "POSTA_WORKER_CONCURRENCY", options.WorkerConcurrency, "10"); - SetParameter(context, "POSTA_WORKER_MAX_RETRIES", options.WorkerMaxRetries, "5"); - SetParameter(context, "POSTA_WEBHOOK_MAX_RETRIES", options.WebhookMaxRetries, "3"); - SetParameter(context, "POSTA_WEBHOOK_TIMEOUT_SECS", options.WebhookTimeoutSeconds, "10"); - SetParameter(context, "POSTA_WEBHOOK_PROXY_URL", options.WebhookProxyUrl); - SetParameter(context, "POSTA_GOOGLE_OAUTH_CLIENT_ID", options.GoogleOAuthClientId); - SetParameter(context, "POSTA_GOOGLE_OAUTH_CLIENT_SECRET", options.GoogleOAuthClientSecret); - SetParameter(context, "POSTA_OAUTH_CALLBACK_URL", options.OAuthCallbackUrl); - SetParameter(context, "POSTA_BLOB_PROVIDER", options.BlobProvider); - SetParameter(context, "POSTA_BLOB_S3_ENDPOINT", options.BlobS3Endpoint); - SetParameter(context, "POSTA_BLOB_S3_REGION", options.BlobS3Region, "us-east-1"); - SetParameter(context, "POSTA_BLOB_S3_BUCKET", options.BlobS3Bucket); - SetParameter(context, "POSTA_BLOB_S3_ACCESS_KEY", options.BlobS3AccessKey); - SetParameter(context, "POSTA_BLOB_S3_SECRET_KEY", options.BlobS3SecretKey); - SetParameter(context, "POSTA_BLOB_S3_USE_SSL", options.BlobS3UseSsl, "true"); - SetParameter(context, "POSTA_BLOB_S3_PATH_STYLE", options.BlobS3PathStyle, "false"); - SetParameter(context, "POSTA_BLOB_FS_PATH", options.BlobFileSystemPath, "/data/attachments"); - SetParameter(context, "POSTA_ENCRYPTION_KEY", options.EncryptionKey); - SetParameter(context, "POSTA_SYSTEM_SMTP_HOST", options.SystemSmtpHost); - SetParameter(context, "POSTA_SYSTEM_SMTP_PORT", options.SystemSmtpPort, "587"); - SetParameter(context, "POSTA_SYSTEM_SMTP_USERNAME", options.SystemSmtpUsername); - SetParameter(context, "POSTA_SYSTEM_SMTP_PASSWORD", options.SystemSmtpPassword); - SetParameter(context, "POSTA_SYSTEM_SMTP_FROM", options.SystemSmtpFrom); - SetParameter(context, "POSTA_SYSTEM_SMTP_ENCRYPTION", options.SystemSmtpEncryption, "starttls"); - SetParameter(context, "POSTA_INBOUND_ENABLED", options.InboundEnabled, "false"); - SetParameter(context, "POSTA_INBOUND_SMTP_HOST", options.InboundSmtpHost, "0.0.0.0"); - SetParameter(context, "POSTA_INBOUND_SMTP_PORT", options.InboundSmtpPort, "2525"); - SetParameter(context, "POSTA_INBOUND_HOSTNAME", options.InboundHostname, "posta.local"); - SetParameter(context, "POSTA_INBOUND_MAX_MESSAGE_SIZE", options.InboundMaxMessageSize, "26214400"); - SetParameter(context, "POSTA_INBOUND_MAX_ATTACH_SIZE", options.InboundMaxAttachSize, "10485760"); - SetParameter(context, "POSTA_INBOUND_WEBHOOK_SECRET", options.InboundWebhookSecret); - SetParameter(context, "POSTA_INBOUND_TLS_MODE", options.InboundTlsMode, "none"); - SetParameter(context, "POSTA_INBOUND_TLS_CERT_FILE", options.InboundTlsCertFile); - SetParameter(context, "POSTA_INBOUND_TLS_KEY_FILE", options.InboundTlsKeyFile); - SetParameter(context, "POSTA_INBOUND_SMTP_RATE_LIMIT", options.InboundSmtpRateLimit, "60"); - SetParameter(context, "POSTA_INBOUND_SMTP_RATE_WINDOW", options.InboundSmtpRateWindow, "60"); - SetParameter(context, "POSTA_EMAIL_VERIFICATION_REQUIRED", options.EmailVerificationRequired, "false"); - SetParameter(context, "POSTA_AUTO_SUPPRESS_ON_REJECT", options.AutoSuppressOnReject, "true"); - SetParameter(context, "POSTA_EMAIL_VERIFY_ENABLED", options.EmailVerifyEnabled, "true"); - SetParameter(context, "POSTA_EMAIL_VERIFY_CACHE_TTL_HOURS", options.EmailVerifyCacheTtlHours, "168"); - SetParameter(context, "POSTA_EMAIL_VERIFY_MX_CACHE_TTL_HOURS", options.EmailVerifyMxCacheTtlHours, "24"); - SetParameter(context, "POSTA_EMAIL_VERIFY_RATE_HOURLY", options.EmailVerifyRateHourly, "1000"); - SetParameter(context, "POSTA_ALLOW_DOWNGRADE", options.AllowDowngrade, "false"); - SetParameter(context, "POSTA_PLAN_ENFORCEMENT", options.PlanEnforcement, "false"); + Set(context, PostaEnvironmentVariables.Environment, options.Environment); + Set(context, PostaEnvironmentVariables.DevMode, options.DevMode); + Set(context, PostaEnvironmentVariables.AuthRateLimitEnabled, options.AuthRateLimitEnabled); + Set(context, PostaEnvironmentVariables.RateLimitHourly, options.RateLimitHourly); + Set(context, PostaEnvironmentVariables.RateLimitDaily, options.RateLimitDaily); + Set(context, PostaEnvironmentVariables.OpenApiDocs, options.OpenApiDocs); + Set(context, PostaEnvironmentVariables.MetricsEnabled, options.MetricsEnabled); + SetIfNotNull(context, PostaEnvironmentVariables.WebDir, options.WebDir); + SetIfNotNull(context, PostaEnvironmentVariables.WebUrl, options.WebUrl); + SetIfNotNull(context, PostaEnvironmentVariables.ApiUrl, options.ApiUrl); + Set(context, PostaEnvironmentVariables.CorsOrigins, options.CorsOrigins); + Set(context, PostaEnvironmentVariables.EmbeddedWorker, options.EmbeddedWorker); + Set(context, PostaEnvironmentVariables.WorkerConcurrency, options.WorkerConcurrency); + Set(context, PostaEnvironmentVariables.WorkerMaxRetries, options.WorkerMaxRetries); + Set(context, PostaEnvironmentVariables.WebhookMaxRetries, options.WebhookMaxRetries); + Set(context, PostaEnvironmentVariables.WebhookTimeoutSeconds, options.WebhookTimeoutSeconds); + SetIfNotNull(context, PostaEnvironmentVariables.WebhookProxyUrl, options.WebhookProxyUrl); + ConfigureGoogleOAuth(context, options); + ConfigureBlobStorage(context, options); + SetParameter(context, PostaEnvironmentVariables.EncryptionKey, options.EncryptionKey); + ConfigureSystemSmtp(context, options); + ConfigureInboundSmtp(context, options); + ConfigureEmailVerification(context, options); + Set(context, PostaEnvironmentVariables.AllowDowngrade, options.AllowDowngrade); + Set(context, PostaEnvironmentVariables.PlanEnforcement, options.PlanEnforcement); + } + + private static void ConfigureGoogleOAuth(EnvironmentCallbackContext context, PostaOptions options) + { + SetIfNotNull(context, PostaEnvironmentVariables.GoogleOAuthClientId, options.GoogleOAuthClientId); + SetParameter(context, PostaEnvironmentVariables.GoogleOAuthClientSecret, options.GoogleOAuthClientSecret); + SetIfNotNull(context, PostaEnvironmentVariables.OAuthCallbackUrl, options.OAuthCallbackUrl); + } + + private static void ConfigureGoogleOAuth(EnvironmentCallbackContext context, PostaGoogleOAuthOptions options) + { + SetParameter(context, PostaEnvironmentVariables.GoogleOAuthClientId, options.ClientId); + SetParameter(context, PostaEnvironmentVariables.GoogleOAuthClientSecret, options.ClientSecret); + SetParameter(context, PostaEnvironmentVariables.OAuthCallbackUrl, options.CallbackUrl); + } + + private static void ConfigureBlobStorage(EnvironmentCallbackContext context, PostaOptions options) + { + SetIfNotNull(context, PostaEnvironmentVariables.BlobProvider, options.BlobProvider); + SetIfNotNull(context, PostaEnvironmentVariables.BlobS3Endpoint, options.BlobS3Endpoint); + Set(context, PostaEnvironmentVariables.BlobS3Region, options.BlobS3Region); + SetIfNotNull(context, PostaEnvironmentVariables.BlobS3Bucket, options.BlobS3Bucket); + SetParameter(context, PostaEnvironmentVariables.BlobS3AccessKey, options.BlobS3AccessKey); + SetParameter(context, PostaEnvironmentVariables.BlobS3SecretKey, options.BlobS3SecretKey); + Set(context, PostaEnvironmentVariables.BlobS3UseSsl, options.BlobS3UseSsl); + Set(context, PostaEnvironmentVariables.BlobS3PathStyle, options.BlobS3PathStyle); + Set(context, PostaEnvironmentVariables.BlobFileSystemPath, options.BlobFileSystemPath); + } + + private static void ConfigureS3BlobStorage(EnvironmentCallbackContext context, PostaS3BlobStorageOptions options) + { + Set(context, PostaEnvironmentVariables.BlobProvider, "s3"); + SetParameter(context, PostaEnvironmentVariables.BlobS3Endpoint, options.Endpoint); + SetParameter(context, PostaEnvironmentVariables.BlobS3Region, options.Region); + SetParameter(context, PostaEnvironmentVariables.BlobS3Bucket, options.Bucket); + SetParameter(context, PostaEnvironmentVariables.BlobS3AccessKey, options.AccessKey); + SetParameter(context, PostaEnvironmentVariables.BlobS3SecretKey, options.SecretKey); + SetParameter(context, PostaEnvironmentVariables.BlobS3UseSsl, options.UseSsl); + SetParameter(context, PostaEnvironmentVariables.BlobS3PathStyle, options.PathStyle); + } + + private static void ConfigureSystemSmtp(EnvironmentCallbackContext context, PostaOptions options) + { + SetIfNotNull(context, PostaEnvironmentVariables.SystemSmtpHost, options.SystemSmtpHost); + Set(context, PostaEnvironmentVariables.SystemSmtpPort, options.SystemSmtpPort); + SetIfNotNull(context, PostaEnvironmentVariables.SystemSmtpUsername, options.SystemSmtpUsername); + SetParameter(context, PostaEnvironmentVariables.SystemSmtpPassword, options.SystemSmtpPassword); + SetIfNotNull(context, PostaEnvironmentVariables.SystemSmtpFrom, options.SystemSmtpFrom); + Set(context, PostaEnvironmentVariables.SystemSmtpEncryption, options.SystemSmtpEncryption); + } + + private static void ConfigureSystemSmtp(EnvironmentCallbackContext context, PostaSystemSmtpOptions options) + { + SetParameter(context, PostaEnvironmentVariables.SystemSmtpHost, options.Host); + SetParameter(context, PostaEnvironmentVariables.SystemSmtpPort, options.Port); + SetParameter(context, PostaEnvironmentVariables.SystemSmtpUsername, options.Username); + SetParameter(context, PostaEnvironmentVariables.SystemSmtpPassword, options.Password); + SetParameter(context, PostaEnvironmentVariables.SystemSmtpFrom, options.From); + SetParameter(context, PostaEnvironmentVariables.SystemSmtpEncryption, options.Encryption); + } + + private static void ConfigureInboundSmtp(EnvironmentCallbackContext context, PostaOptions options) + { + Set(context, PostaEnvironmentVariables.InboundEnabled, options.InboundEnabled); + Set(context, PostaEnvironmentVariables.InboundSmtpHost, options.InboundSmtpHost); + Set(context, PostaEnvironmentVariables.InboundSmtpPort, options.InboundSmtpPort); + Set(context, PostaEnvironmentVariables.InboundHostname, options.InboundHostname); + Set(context, PostaEnvironmentVariables.InboundMaxMessageSize, options.InboundMaxMessageSize); + Set(context, PostaEnvironmentVariables.InboundMaxAttachSize, options.InboundMaxAttachSize); + SetParameter(context, PostaEnvironmentVariables.InboundWebhookSecret, options.InboundWebhookSecret); + Set(context, PostaEnvironmentVariables.InboundTlsMode, options.InboundTlsMode); + SetIfNotNull(context, PostaEnvironmentVariables.InboundTlsCertFile, options.InboundTlsCertFile); + SetIfNotNull(context, PostaEnvironmentVariables.InboundTlsKeyFile, options.InboundTlsKeyFile); + Set(context, PostaEnvironmentVariables.InboundSmtpRateLimit, options.InboundSmtpRateLimit); + Set(context, PostaEnvironmentVariables.InboundSmtpRateWindow, options.InboundSmtpRateWindow); + } + + private static void ConfigureInboundSmtp(EnvironmentCallbackContext context, PostaInboundSmtpOptions options) + { + SetParameter(context, PostaEnvironmentVariables.InboundEnabled, options.Enabled); + SetParameter(context, PostaEnvironmentVariables.InboundSmtpHost, options.Host); + SetParameter(context, PostaEnvironmentVariables.InboundSmtpPort, options.Port); + SetParameter(context, PostaEnvironmentVariables.InboundHostname, options.Hostname); + SetParameter(context, PostaEnvironmentVariables.InboundMaxMessageSize, options.MaxMessageSize); + SetParameter(context, PostaEnvironmentVariables.InboundMaxAttachSize, options.MaxAttachmentSize); + SetParameter(context, PostaEnvironmentVariables.InboundWebhookSecret, options.WebhookSecret); + SetParameter(context, PostaEnvironmentVariables.InboundTlsMode, options.TlsMode); + SetParameter(context, PostaEnvironmentVariables.InboundTlsCertFile, options.TlsCertFile); + SetParameter(context, PostaEnvironmentVariables.InboundTlsKeyFile, options.TlsKeyFile); + SetParameter(context, PostaEnvironmentVariables.InboundSmtpRateLimit, options.RateLimit); + SetParameter(context, PostaEnvironmentVariables.InboundSmtpRateWindow, options.RateWindow); + } + + private static void ConfigureEmailVerification(EnvironmentCallbackContext context, PostaOptions options) + { + Set(context, PostaEnvironmentVariables.EmailVerificationRequired, options.EmailVerificationRequired); + Set(context, PostaEnvironmentVariables.AutoSuppressOnReject, options.AutoSuppressOnReject); + Set(context, PostaEnvironmentVariables.EmailVerifyEnabled, options.EmailVerifyEnabled); + Set(context, PostaEnvironmentVariables.EmailVerifyCacheTtlHours, options.EmailVerifyCacheTtlHours); + Set(context, PostaEnvironmentVariables.EmailVerifyMxCacheTtlHours, options.EmailVerifyMxCacheTtlHours); + Set(context, PostaEnvironmentVariables.EmailVerifyRateHourly, options.EmailVerifyRateHourly); + } + + private static void ConfigureEmailVerification(EnvironmentCallbackContext context, PostaEmailVerificationOptions options) + { + SetParameter(context, PostaEnvironmentVariables.EmailVerificationRequired, options.Required); + SetParameter(context, PostaEnvironmentVariables.AutoSuppressOnReject, options.AutoSuppressOnReject); + SetParameter(context, PostaEnvironmentVariables.EmailVerifyEnabled, options.Enabled); + SetParameter(context, PostaEnvironmentVariables.EmailVerifyCacheTtlHours, options.CacheTtlHours); + SetParameter(context, PostaEnvironmentVariables.EmailVerifyMxCacheTtlHours, options.MxCacheTtlHours); + SetParameter(context, PostaEnvironmentVariables.EmailVerifyRateHourly, options.RateHourly); } private static void Set(EnvironmentCallbackContext context, string name, string value) @@ -321,32 +401,29 @@ private static void SetIfAbsent(EnvironmentCallbackContext context, string name, context.EnvironmentVariables.TryAdd(name, value); } + private static void Set(EnvironmentCallbackContext context, string name, bool value) + { + context.EnvironmentVariables[name] = value ? "true" : "false"; + } + private static void Set(EnvironmentCallbackContext context, string name, int value) { context.EnvironmentVariables[name] = value.ToString(System.Globalization.CultureInfo.InvariantCulture); } - private static void SetParameter(EnvironmentCallbackContext context, string name, IResourceBuilder? parameter) + private static void SetIfNotNull(EnvironmentCallbackContext context, string name, string? value) { - if (parameter is not null) + if (!string.IsNullOrEmpty(value)) { - context.EnvironmentVariables[name] = parameter.Resource; + context.EnvironmentVariables[name] = value; } } - private static void SetParameter( - EnvironmentCallbackContext context, - string name, - IResourceBuilder? parameter, - string defaultValue) + private static void SetParameter(EnvironmentCallbackContext context, string name, IResourceBuilder? parameter) { if (parameter is not null) { context.EnvironmentVariables[name] = parameter.Resource; - return; } - - context.EnvironmentVariables[name] = defaultValue; } } - From bd9cdafea49d57e4a5ac5fe195d2c68396618d52 Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 13:21:25 +0300 Subject: [PATCH 22/24] Refactor `PostaOptions` to replace `IResourceBuilder` with primitive types, add defaults, and improve XML summaries for configuration properties. --- .../PostaOptions.cs | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs index f18cd443c..f174491eb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs @@ -3,99 +3,99 @@ namespace CommunityToolkit.Aspire.Hosting.Posta; /// -/// Parameter-based configuration overrides for the Posta container. +/// Configuration options for the Posta container. /// public sealed class PostaOptions { /// /// Gets or sets the Posta environment name. /// - public IResourceBuilder? Environment { get; set; } + public string Environment { get; set; } = "production"; /// - /// Gets or sets whether Posta stores emails without sending them. + /// Gets or sets a value indicating whether Posta stores emails without sending them. /// - public IResourceBuilder? DevMode { get; set; } + public bool DevMode { get; set; } /// - /// Gets or sets whether auth rate limiting is enabled. + /// Gets or sets a value indicating whether auth rate limiting is enabled. /// - public IResourceBuilder? AuthRateLimitEnabled { get; set; } + public bool AuthRateLimitEnabled { get; set; } = true; /// /// Gets or sets the maximum emails per hour per user. /// - public IResourceBuilder? RateLimitHourly { get; set; } + public int RateLimitHourly { get; set; } = 100; /// /// Gets or sets the maximum emails per day per user. /// - public IResourceBuilder? RateLimitDaily { get; set; } + public int RateLimitDaily { get; set; } = 1000; /// - /// Gets or sets whether Swagger UI and ReDoc are enabled. + /// Gets or sets a value indicating whether Swagger UI and ReDoc are enabled. /// - public IResourceBuilder? OpenApiDocs { get; set; } + public bool OpenApiDocs { get; set; } = true; /// - /// Gets or sets whether Prometheus metrics are enabled. + /// Gets or sets a value indicating whether Prometheus metrics are enabled. /// - public IResourceBuilder? MetricsEnabled { get; set; } + public bool MetricsEnabled { get; set; } /// /// Gets or sets the Vue dashboard build directory. /// - public IResourceBuilder? WebDir { get; set; } + public string? WebDir { get; set; } /// /// Gets or sets the public dashboard URL. /// - public IResourceBuilder? WebUrl { get; set; } + public string? WebUrl { get; set; } /// /// Gets or sets the public API base URL advertised in the OpenAPI servers list. /// - public IResourceBuilder? ApiUrl { get; set; } + public string? ApiUrl { get; set; } /// /// Gets or sets the comma-separated allowed CORS origins. /// - public IResourceBuilder? CorsOrigins { get; set; } + public string CorsOrigins { get; set; } = "*"; /// - /// Gets or sets whether the worker runs in the server process. + /// Gets or sets a value indicating whether the worker runs in the server process. /// - public IResourceBuilder? EmbeddedWorker { get; set; } + public bool EmbeddedWorker { get; set; } = true; /// /// Gets or sets the number of worker goroutines. /// - public IResourceBuilder? WorkerConcurrency { get; set; } + public int WorkerConcurrency { get; set; } = 10; /// /// Gets or sets the maximum email retry attempts. /// - public IResourceBuilder? WorkerMaxRetries { get; set; } + public int WorkerMaxRetries { get; set; } = 5; /// /// Gets or sets the maximum webhook delivery retry attempts. /// - public IResourceBuilder? WebhookMaxRetries { get; set; } + public int WebhookMaxRetries { get; set; } = 3; /// /// Gets or sets the webhook HTTP request timeout in seconds. /// - public IResourceBuilder? WebhookTimeoutSeconds { get; set; } + public int WebhookTimeoutSeconds { get; set; } = 10; /// /// Gets or sets the optional HTTP, HTTPS, or SOCKS5 proxy for webhook delivery. /// - public IResourceBuilder? WebhookProxyUrl { get; set; } + public string? WebhookProxyUrl { get; set; } /// /// Gets or sets the Google OAuth client ID for SSO login. /// - public IResourceBuilder? GoogleOAuthClientId { get; set; } + public string? GoogleOAuthClientId { get; set; } /// /// Gets or sets the Google OAuth client secret for SSO login. @@ -105,27 +105,27 @@ public sealed class PostaOptions /// /// Gets or sets the OAuth callback base URL. /// - public IResourceBuilder? OAuthCallbackUrl { get; set; } + public string? OAuthCallbackUrl { get; set; } /// /// Gets or sets the storage backend for attachments, either s3 or filesystem. /// - public IResourceBuilder? BlobProvider { get; set; } + public string? BlobProvider { get; set; } /// /// Gets or sets the S3-compatible endpoint. /// - public IResourceBuilder? BlobS3Endpoint { get; set; } + public string? BlobS3Endpoint { get; set; } /// /// Gets or sets the S3 region. /// - public IResourceBuilder? BlobS3Region { get; set; } + public string BlobS3Region { get; set; } = "us-east-1"; /// /// Gets or sets the S3 bucket name. /// - public IResourceBuilder? BlobS3Bucket { get; set; } + public string? BlobS3Bucket { get; set; } /// /// Gets or sets the S3 access key. @@ -138,19 +138,19 @@ public sealed class PostaOptions public IResourceBuilder? BlobS3SecretKey { get; set; } /// - /// Gets or sets whether S3 storage uses TLS. + /// Gets or sets a value indicating whether S3 storage uses TLS. /// - public IResourceBuilder? BlobS3UseSsl { get; set; } + public bool BlobS3UseSsl { get; set; } = true; /// - /// Gets or sets whether S3 path-style addressing is used. + /// Gets or sets a value indicating whether S3 path-style addressing is used. /// - public IResourceBuilder? BlobS3PathStyle { get; set; } + public bool BlobS3PathStyle { get; set; } /// /// Gets or sets the filesystem storage path. /// - public IResourceBuilder? BlobFileSystemPath { get; set; } + public string BlobFileSystemPath { get; set; } = "/data/attachments"; /// /// Gets or sets the AES-256-GCM encryption key for stored SMTP passwords. @@ -160,17 +160,17 @@ public sealed class PostaOptions /// /// Gets or sets the system SMTP server host used for platform notifications. /// - public IResourceBuilder? SystemSmtpHost { get; set; } + public string? SystemSmtpHost { get; set; } /// /// Gets or sets the system SMTP server port. /// - public IResourceBuilder? SystemSmtpPort { get; set; } + public int SystemSmtpPort { get; set; } = 587; /// /// Gets or sets the system SMTP username. /// - public IResourceBuilder? SystemSmtpUsername { get; set; } + public string? SystemSmtpUsername { get; set; } /// /// Gets or sets the system SMTP password. @@ -180,42 +180,42 @@ public sealed class PostaOptions /// /// Gets or sets the system SMTP from address. /// - public IResourceBuilder? SystemSmtpFrom { get; set; } + public string? SystemSmtpFrom { get; set; } /// /// Gets or sets the system SMTP encryption mode: none, ssl, or starttls. /// - public IResourceBuilder? SystemSmtpEncryption { get; set; } + public string SystemSmtpEncryption { get; set; } = "starttls"; /// - /// Gets or sets whether inbound email processing is enabled. + /// Gets or sets a value indicating whether inbound email processing is enabled. /// - public IResourceBuilder? InboundEnabled { get; set; } + public bool InboundEnabled { get; set; } /// /// Gets or sets the bind address for the built-in SMTP receiver. /// - public IResourceBuilder? InboundSmtpHost { get; set; } + public string InboundSmtpHost { get; set; } = "0.0.0.0"; /// /// Gets or sets the SMTP listener port. /// - public IResourceBuilder? InboundSmtpPort { get; set; } + public int InboundSmtpPort { get; set; } = 2525; /// /// Gets or sets the hostname announced in EHLO and used as TLS SNI. /// - public IResourceBuilder? InboundHostname { get; set; } + public string InboundHostname { get; set; } = "posta.local"; /// /// Gets or sets the maximum raw inbound message size in bytes. /// - public IResourceBuilder? InboundMaxMessageSize { get; set; } + public int InboundMaxMessageSize { get; set; } = 26214400; /// /// Gets or sets the maximum inbound attachment size in bytes. /// - public IResourceBuilder? InboundMaxAttachSize { get; set; } + public int InboundMaxAttachSize { get; set; } = 10485760; /// /// Gets or sets the shared secret for inbound webhooks. @@ -225,67 +225,67 @@ public sealed class PostaOptions /// /// Gets or sets the inbound SMTP TLS mode: none or starttls. /// - public IResourceBuilder? InboundTlsMode { get; set; } + public string InboundTlsMode { get; set; } = "none"; /// /// Gets or sets the PEM certificate path for inbound TLS. /// - public IResourceBuilder? InboundTlsCertFile { get; set; } + public string? InboundTlsCertFile { get; set; } /// /// Gets or sets the PEM key path for inbound TLS. /// - public IResourceBuilder? InboundTlsKeyFile { get; set; } + public string? InboundTlsKeyFile { get; set; } /// /// Gets or sets the per-IP SMTP session rate limit. /// - public IResourceBuilder? InboundSmtpRateLimit { get; set; } + public int InboundSmtpRateLimit { get; set; } = 60; /// /// Gets or sets the SMTP rate-limit window in seconds. /// - public IResourceBuilder? InboundSmtpRateWindow { get; set; } + public int InboundSmtpRateWindow { get; set; } = 60; /// - /// Gets or sets whether users must verify their email address before sign-in. + /// Gets or sets a value indicating whether users must verify their email address before sign-in. /// - public IResourceBuilder? EmailVerificationRequired { get; set; } + public bool EmailVerificationRequired { get; set; } /// - /// Gets or sets whether permanently rejected recipients are added to the suppression list. + /// Gets or sets a value indicating whether permanently rejected recipients are added to the suppression list. /// - public IResourceBuilder? AutoSuppressOnReject { get; set; } + public bool AutoSuppressOnReject { get; set; } = true; /// - /// Gets or sets whether the email verification endpoint is enabled. + /// Gets or sets a value indicating whether the email verification endpoint is enabled. /// - public IResourceBuilder? EmailVerifyEnabled { get; set; } + public bool EmailVerifyEnabled { get; set; } = true; /// /// Gets or sets how long address-level verification results are cached, in hours. /// - public IResourceBuilder? EmailVerifyCacheTtlHours { get; set; } + public int EmailVerifyCacheTtlHours { get; set; } = 168; /// /// Gets or sets how long domain MX lookups are cached, in hours. /// - public IResourceBuilder? EmailVerifyMxCacheTtlHours { get; set; } + public int EmailVerifyMxCacheTtlHours { get; set; } = 24; /// /// Gets or sets the per-user hourly cap on verification requests. /// - public IResourceBuilder? EmailVerifyRateHourly { get; set; } + public int EmailVerifyRateHourly { get; set; } = 1000; /// - /// Gets or sets whether older binaries can boot against a newer database schema. + /// Gets or sets a value indicating whether older binaries can boot against a newer database schema. /// - public IResourceBuilder? AllowDowngrade { get; set; } + public bool AllowDowngrade { get; set; } /// - /// Gets or sets whether hosted plan limits and quotas are enforced. + /// Gets or sets a value indicating whether hosted plan limits and quotas are enforced. /// - public IResourceBuilder? PlanEnforcement { get; set; } + public bool PlanEnforcement { get; set; } /// /// Gets or sets a PostgreSQL connection string parameter that overrides individual database settings. @@ -300,5 +300,5 @@ public sealed class PostaOptions /// /// Gets or sets an external Redis address in the form host:port. /// - public IResourceBuilder? RedisAddress { get; set; } + public string? RedisAddress { get; set; } } From d069e6ebb8567d64289b1b3704d9cd02c7c9ebed Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 13:21:31 +0300 Subject: [PATCH 23/24] Add extension methods to configure Posta resources for SMTP, OAuth, S3 storage, and email verification --- .../PostaHostingExtensions.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index da2499413..b1d6978d0 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -176,6 +176,91 @@ public static IResourceBuilder WithReference( .WaitFor(redis); } + /// + /// Configures the system SMTP server used by Posta for platform notifications. + /// + /// The Posta resource builder. + /// The parameter-based system SMTP configuration. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "PostaSystemSmtpOptions contains parameter builders and is intended for C# AppHosts.")] + public static IResourceBuilder WithSystemSmtp( + this IResourceBuilder builder, + PostaSystemSmtpOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + return builder.WithEnvironment(context => ConfigureSystemSmtp(context, options)); + } + + /// + /// Configures the built-in inbound SMTP receiver used by Posta. + /// + /// The Posta resource builder. + /// The parameter-based inbound SMTP configuration. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "PostaInboundSmtpOptions contains parameter builders and is intended for C# AppHosts.")] + public static IResourceBuilder WithInboundSmtp( + this IResourceBuilder builder, + PostaInboundSmtpOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + return builder.WithEnvironment(context => ConfigureInboundSmtp(context, options)); + } + + /// + /// Configures S3-compatible attachment storage for Posta. + /// + /// The Posta resource builder. + /// The parameter-based S3 blob storage configuration. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "PostaS3BlobStorageOptions contains parameter builders and is intended for C# AppHosts.")] + public static IResourceBuilder WithS3BlobStorage( + this IResourceBuilder builder, + PostaS3BlobStorageOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + return builder.WithEnvironment(context => ConfigureS3BlobStorage(context, options)); + } + + /// + /// Configures Google OAuth login for Posta. + /// + /// The Posta resource builder. + /// The parameter-based Google OAuth configuration. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "PostaGoogleOAuthOptions contains parameter builders and is intended for C# AppHosts.")] + public static IResourceBuilder WithGoogleOAuth( + this IResourceBuilder builder, + PostaGoogleOAuthOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + return builder.WithEnvironment(context => ConfigureGoogleOAuth(context, options)); + } + + /// + /// Configures email verification behavior for Posta. + /// + /// The Posta resource builder. + /// The parameter-based email verification configuration. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "PostaEmailVerificationOptions contains parameter builders and is intended for C# AppHosts.")] + public static IResourceBuilder WithEmailVerification( + this IResourceBuilder builder, + PostaEmailVerificationOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + return builder.WithEnvironment(context => ConfigureEmailVerification(context, options)); + } + private static IResourceBuilder AddPostaCore( IDistributedApplicationBuilder builder, string name, From 64b542b3ebed78663bedcadb76023d5e8f30d2d2 Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 9 Jul 2026 13:28:28 +0300 Subject: [PATCH 24/24] Add action-based overloads to configure Posta resources for SMTP, OAuth, S3 storage, and email verification --- .../PostaHostingExtensions.cs | 100 ++++++++++++++++++ .../README.md | 51 +++++---- .../ContainerResourceCreationTests.cs | 53 ++++++++++ 3 files changed, 181 insertions(+), 23 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs index b1d6978d0..10ddc7fff 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -193,6 +193,26 @@ public static IResourceBuilder WithSystemSmtp( return builder.WithEnvironment(context => ConfigureSystemSmtp(context, options)); } + /// + /// Configures the system SMTP server used by Posta for platform notifications. + /// + /// The Posta resource builder. + /// A delegate that configures the parameter-based system SMTP options. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the options object overload instead.")] + public static IResourceBuilder WithSystemSmtp( + this IResourceBuilder builder, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = new PostaSystemSmtpOptions(); + configureOptions.Invoke(options); + + return builder.WithSystemSmtp(options); + } + /// /// Configures the built-in inbound SMTP receiver used by Posta. /// @@ -210,6 +230,26 @@ public static IResourceBuilder WithInboundSmtp( return builder.WithEnvironment(context => ConfigureInboundSmtp(context, options)); } + /// + /// Configures the built-in inbound SMTP receiver used by Posta. + /// + /// The Posta resource builder. + /// A delegate that configures the parameter-based inbound SMTP options. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the options object overload instead.")] + public static IResourceBuilder WithInboundSmtp( + this IResourceBuilder builder, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = new PostaInboundSmtpOptions(); + configureOptions.Invoke(options); + + return builder.WithInboundSmtp(options); + } + /// /// Configures S3-compatible attachment storage for Posta. /// @@ -227,6 +267,26 @@ public static IResourceBuilder WithS3BlobStorage( return builder.WithEnvironment(context => ConfigureS3BlobStorage(context, options)); } + /// + /// Configures S3-compatible attachment storage for Posta. + /// + /// The Posta resource builder. + /// A delegate that configures the parameter-based S3 blob storage options. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the options object overload instead.")] + public static IResourceBuilder WithS3BlobStorage( + this IResourceBuilder builder, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = new PostaS3BlobStorageOptions(); + configureOptions.Invoke(options); + + return builder.WithS3BlobStorage(options); + } + /// /// Configures Google OAuth login for Posta. /// @@ -244,6 +304,26 @@ public static IResourceBuilder WithGoogleOAuth( return builder.WithEnvironment(context => ConfigureGoogleOAuth(context, options)); } + /// + /// Configures Google OAuth login for Posta. + /// + /// The Posta resource builder. + /// A delegate that configures the parameter-based Google OAuth options. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the options object overload instead.")] + public static IResourceBuilder WithGoogleOAuth( + this IResourceBuilder builder, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = new PostaGoogleOAuthOptions(); + configureOptions.Invoke(options); + + return builder.WithGoogleOAuth(options); + } + /// /// Configures email verification behavior for Posta. /// @@ -261,6 +341,26 @@ public static IResourceBuilder WithEmailVerification( return builder.WithEnvironment(context => ConfigureEmailVerification(context, options)); } + /// + /// Configures email verification behavior for Posta. + /// + /// The Posta resource builder. + /// A delegate that configures the parameter-based email verification options. + /// A reference to the for further resource configuration. + [AspireExportIgnore(Reason = "Action is not supported in polyglot app hosts. Use the options object overload instead.")] + public static IResourceBuilder WithEmailVerification( + this IResourceBuilder builder, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = new PostaEmailVerificationOptions(); + configureOptions.Invoke(options); + + return builder.WithEmailVerification(options); + } + private static IResourceBuilder AddPostaCore( IDistributedApplicationBuilder builder, string name, diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md index 8cdea6732..89a6b3f66 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md @@ -77,28 +77,28 @@ var s3AccessKey = builder.AddParameterFromConfiguration("posta-s3-access-key", " var s3SecretKey = builder.AddParameterFromConfiguration("posta-s3-secret-key", "Posta:S3:SecretKey", secret: true); var posta = builder.AddPosta("posta", database, redis) - .WithSystemSmtp(new PostaSystemSmtpOptions + .WithSystemSmtp(options => { - Host = smtpHost, - Port = smtpPort, - Username = smtpUsername, - Password = smtpPassword, - From = smtpFrom, - Encryption = smtpEncryption + options.Host = smtpHost; + options.Port = smtpPort; + options.Username = smtpUsername; + options.Password = smtpPassword; + options.From = smtpFrom; + options.Encryption = smtpEncryption; }) - .WithGoogleOAuth(new PostaGoogleOAuthOptions + .WithGoogleOAuth(options => { - ClientId = googleClientId, - ClientSecret = googleClientSecret, - CallbackUrl = googleCallbackUrl + options.ClientId = googleClientId; + options.ClientSecret = googleClientSecret; + options.CallbackUrl = googleCallbackUrl; }) - .WithS3BlobStorage(new PostaS3BlobStorageOptions + .WithS3BlobStorage(options => { - Endpoint = s3Endpoint, - Region = s3Region, - Bucket = s3Bucket, - AccessKey = s3AccessKey, - SecretKey = s3SecretKey + options.Endpoint = s3Endpoint; + options.Region = s3Region; + options.Bucket = s3Bucket; + options.AccessKey = s3AccessKey; + options.SecretKey = s3SecretKey; }); ``` @@ -112,15 +112,15 @@ var inboundWebhookSecret = builder.AddParameterFromConfiguration("posta-inbound- var emailVerificationRequired = builder.AddParameterFromConfiguration("posta-email-verification-required", "Posta:EmailVerification:Required"); builder.AddPosta("posta", database, redis) - .WithInboundSmtp(new PostaInboundSmtpOptions + .WithInboundSmtp(options => { - Enabled = inboundEnabled, - Port = inboundPort, - WebhookSecret = inboundWebhookSecret + options.Enabled = inboundEnabled; + options.Port = inboundPort; + options.WebhookSecret = inboundWebhookSecret; }) - .WithEmailVerification(new PostaEmailVerificationOptions + .WithEmailVerification(options => { - Required = emailVerificationRequired + options.Required = emailVerificationRequired; }); ``` @@ -134,10 +134,15 @@ builder.AddPosta("posta", database, redis) | `WithReference(PostgresDatabaseResource)` | Configures the PostgreSQL database environment variables and waits for the database. | | `WithReference(RedisResource, redisPassword)` | Configures Redis address/password environment variables and waits for Redis. | | `WithSystemSmtp(PostaSystemSmtpOptions)` | Configures parameter-based system SMTP settings. | +| `WithSystemSmtp(Action)` | Configures parameter-based system SMTP settings with a callback. | | `WithInboundSmtp(PostaInboundSmtpOptions)` | Configures parameter-based inbound SMTP receiver settings. | +| `WithInboundSmtp(Action)` | Configures parameter-based inbound SMTP receiver settings with a callback. | | `WithS3BlobStorage(PostaS3BlobStorageOptions)` | Configures parameter-based S3-compatible attachment storage and sets the blob provider to `s3`. | +| `WithS3BlobStorage(Action)` | Configures parameter-based S3-compatible attachment storage with a callback and sets the blob provider to `s3`. | | `WithGoogleOAuth(PostaGoogleOAuthOptions)` | Configures parameter-based Google OAuth login settings. | +| `WithGoogleOAuth(Action)` | Configures parameter-based Google OAuth login settings with a callback. | | `WithEmailVerification(PostaEmailVerificationOptions)` | Configures parameter-based email verification settings. | +| `WithEmailVerification(Action)` | Configures parameter-based email verification settings with a callback. | Use `PostaOptions.DatabaseUrl`, `PostaOptions.RedisAddress`, and `PostaOptions.RedisPassword` only when you need to override the generated PostgreSQL or Redis environment values. diff --git a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs index 18973ae85..f75ff0c83 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs @@ -43,6 +43,20 @@ public void WithReferenceThrowsWhenRedisIsNull() Assert.Throws(() => posta.WithReference((IResourceBuilder)null!)); } + [Fact] + public void WithGroupedOptionCallbacksThrowWhenConfigureOptionsIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); + var posta = builder.AddPosta("posta", database, redis); + + Assert.Throws(() => posta.WithSystemSmtp((Action)null!)); + Assert.Throws(() => posta.WithInboundSmtp((Action)null!)); + Assert.Throws(() => posta.WithS3BlobStorage((Action)null!)); + Assert.Throws(() => posta.WithGoogleOAuth((Action)null!)); + Assert.Throws(() => posta.WithEmailVerification((Action)null!)); + } + [Fact] public void AddPostaWithReferencesThrowsWhenDatabaseIsNull() { @@ -433,6 +447,45 @@ IResourceBuilder Parameter(string name, string value) } } + [Fact] + public async Task WithGroupedOptionCallbacksConfigureParameterBasedEnvironmentVariables() + { + var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); + var smtpHost = builder.AddParameter("posta-smtp-host", "smtp.example.com"); + var inboundEnabled = builder.AddParameter("posta-inbound-enabled", "true"); + var s3Bucket = builder.AddParameter("posta-s3-bucket", "posta"); + var googleClientId = builder.AddParameter("posta-google-client-id", "google-client-id"); + var emailVerificationRequired = builder.AddParameter("posta-email-verification-required", "true"); + + var posta = builder.AddPosta("posta", database, redis) + .WithSystemSmtp(options => options.Host = smtpHost) + .WithInboundSmtp(options => options.Enabled = inboundEnabled) + .WithS3BlobStorage(options => options.Bucket = s3Bucket) + .WithGoogleOAuth(options => options.ClientId = googleClientId) + .WithEmailVerification(options => options.Required = emailVerificationRequired); + + Assert.True(posta.Resource.TryGetAnnotationsOfType(out var annotations)); + + var context = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext( + new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run))); + + foreach (var annotation in annotations) + { + await annotation.Callback(context); + } + + var env = context.EnvironmentVariables; + + Assert.Same(smtpHost.Resource, env["POSTA_SYSTEM_SMTP_HOST"]); + Assert.Same(inboundEnabled.Resource, env["POSTA_INBOUND_ENABLED"]); + Assert.Equal("s3", env["POSTA_BLOB_PROVIDER"]); + Assert.Same(s3Bucket.Resource, env["POSTA_BLOB_S3_BUCKET"]); + Assert.Same(googleClientId.Resource, env["POSTA_GOOGLE_OAUTH_CLIENT_ID"]); + Assert.Same(emailVerificationRequired.Resource, env["POSTA_EMAIL_VERIFICATION_REQUIRED"]); + } + private static (IResourceBuilder Database, IResourceBuilder Redis) AddPostaDependencies(IDistributedApplicationBuilder builder) { var postgres = builder.AddPostgres("postgres");