diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index ed3837435..b0156934f 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -155,6 +155,9 @@ + + + @@ -269,6 +272,7 @@ + @@ -344,6 +348,7 @@ + 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..50c3cce45 --- /dev/null +++ b/examples/posta/CommunityToolkit.Aspire.Hosting.Posta.AppHost/Program.cs @@ -0,0 +1,10 @@ +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", database, redis); + +builder.Build().Run(); 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/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/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/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/PostaHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs new file mode 100644 index 000000000..10ddc7fff --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaHostingExtensions.cs @@ -0,0 +1,614 @@ +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 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(builder); + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(redis); + + 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 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 for secret values 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); + } + + /// + /// 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 options object 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(builder); + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(redis); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = new PostaOptions(); + configureOptions.Invoke(options); + + return builder.AddPosta(name, database, redis, options, jwtSecret, adminPassword, adminEmail, port); + } + + /// + /// 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; + + 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); + } + + /// + /// 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"); + + SetIfAbsent(context, PostaEnvironmentVariables.RedisAddress, ReferenceExpression.Create($"{redisEndpoint.Property(EndpointProperty.Host)}:{redisEndpoint.Property(EndpointProperty.Port)}")); + if (redisPassword is not null) + { + Set(context, PostaEnvironmentVariables.RedisPassword, redisPassword.Resource); + } + else if (redisResource.PasswordParameter is not null) + { + SetIfAbsent(context, PostaEnvironmentVariables.RedisPassword, redisResource.PasswordParameter); + } + }) + .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 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. + /// + /// 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 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. + /// + /// 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 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. + /// + /// 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 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. + /// + /// 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)); + } + + /// + /// 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, + IResourceBuilder? jwtSecret, + IResourceBuilder? adminPassword, + string adminEmail, + int? port, + PostaOptions? options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(adminEmail); + + options ??= new PostaOptions(); + + 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 => + { + 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, PostaEnvironmentVariables.RedisPassword, options.RedisPassword.Resource); + } + Set(context, PostaEnvironmentVariables.JwtSecret, resource.JwtSecretParameter); + Set(context, PostaEnvironmentVariables.AdminEmail, adminEmail); + Set(context, PostaEnvironmentVariables.AdminPassword, 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, 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) + { + 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 SetIfAbsent(EnvironmentCallbackContext context, string name, ParameterResource value) + { + 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, 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/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/PostaOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Posta/PostaOptions.cs new file mode 100644 index 000000000..f174491eb --- /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; } +} 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/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; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Posta/README.md b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md new file mode 100644 index 000000000..89a6b3f66 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Posta/README.md @@ -0,0 +1,169 @@ +# 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 +``` + +## Basic usage + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +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 posta = builder.AddPosta("posta", database, 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. `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(options => + { + options.Host = smtpHost; + options.Port = smtpPort; + options.Username = smtpUsername; + options.Password = smtpPassword; + options.From = smtpFrom; + options.Encryption = smtpEncryption; + }) + .WithGoogleOAuth(options => + { + options.ClientId = googleClientId; + options.ClientSecret = googleClientSecret; + options.CallbackUrl = googleCallbackUrl; + }) + .WithS3BlobStorage(options => + { + options.Endpoint = s3Endpoint; + options.Region = s3Region; + options.Bucket = s3Bucket; + options.AccessKey = s3AccessKey; + options.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"); + +builder.AddPosta("posta", database, redis) + .WithInboundSmtp(options => + { + options.Enabled = inboundEnabled; + options.Port = inboundPort; + options.WebhookSecret = inboundWebhookSecret; + }) + .WithEmailVerification(options => + { + options.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. | +| `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. + +## 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..f75ff0c83 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Posta.Tests/ContainerResourceCreationTests.cs @@ -0,0 +1,497 @@ +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", null!, null!)); + Assert.Equal("builder", exception.ParamName); + } + + [Fact] + public void AddPostaThrowsWhenNameIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); + + Assert.Throws(() => builder.AddPosta(null!, database, redis)); + } + + [Fact] + public void WithReferenceThrowsWhenDatabaseIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); + var posta = builder.AddPosta("posta", database, redis); + + Assert.Throws(() => posta.WithReference((IResourceBuilder)null!)); + } + + [Fact] + public void WithReferenceThrowsWhenRedisIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); + var posta = builder.AddPosta("posta", database, redis); + + 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() + { + 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() + { + var builder = DistributedApplication.CreateBuilder(); + var (database, redis) = AddPostaDependencies(builder); + + builder.AddPosta("posta", database, redis); + + 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(); + var (database, redis) = AddPostaDependencies(builder); + + builder.AddPosta("posta", database, redis, 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(); + var (database, redis) = AddPostaDependencies(builder); + + builder.AddPosta("posta", database, redis); + + 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 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() + { + 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", 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) + { + var parameter = builder.AddParameter($"posta-{name}", value); + parameters[name] = parameter; + return parameter; + } + + 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)); + + 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("s3", env["POSTA_BLOB_PROVIDER"]); + foreach (var (parameterName, environmentVariableName) in new (string ParameterName, string EnvironmentVariableName)[] + { + ("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-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") + }) + { + Assert.Same(parameters[parameterName].Resource, env[environmentVariableName]); + } + } + + [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"); + var database = postgres.AddDatabase("posta-db", "posta"); + var redis = builder.AddRedis("redis"); + + return (database, redis); + } +}