diff --git a/docs/extensibility/custom-component.md b/docs/extensibility/custom-component.md new file mode 100644 index 0000000000..98c563239c --- /dev/null +++ b/docs/extensibility/custom-component.md @@ -0,0 +1,265 @@ +--- +title: Create custom .NET Aspire component +description: Learn how to create a custom .NET Aspire component for an existing containerized application. +ms.date: 07/16/2024 +ms.topic: how-to +--- + +# Create custom .NET Aspire component + +This article is a continuation of the [Create custom resource types for .NET Aspire](custom-resources.md) article. It guides you through creating a .NET Aspire component that uses [MailKit](https://github.com/jstedfast/MailKit) to send emails. This component is then integrated into the Newsletter app you previously built. The previous example, omitted the creation of a component and instead relied on the existing .NET `SmtpClient`. It's best to use MailKit's `SmtpClient` over the official .NET `SmtpClient` for sending emails, as it's more modern and supports more features/protocols. For more information, see [.NET SmtpClient: Remarks](/dotnet/api/system.net.mail.smtpclient#remarks). + +## Prerequisites + +If you're following along, you should have a Newsletter app from the steps in the [Create custom resource types for .NET Aspire](custom-resources.md) article. + +> [!TIP] +> This article is inspired by existing .NET Aspire components, and based on the teams official guidance. There are places where said guidance varies, and it's important to understand the reasoning behind the differences. For more information, see [.NET Aspire component requirements](https://github.com/dotnet/aspire/blob/f38b6cba86942ad1c45fc04fe7170f0fd4ba7c0b/src/Components/Aspire_Components_Progress.md#net-aspire-component-requirements). + +## Create library for component + +[.NET Aspire components](../fundamentals/components-overview.md) are delivered as NuGet packages, but in this example, it's beyond the scope of this article to publish a NuGet package. Instead, you create a class library project that contains the component and reference it as a project. .NET Aspire component packages are intended to wrap a client library, such as MailKit, and provide production-ready telemetry, health checks, configurability, and testability. Let's start by creating a new class library project. + +1. Create a new class library project named `MailKit.Client` in the same directory as the _MailDevResource.sln_ from the previous article. + + ```dotnetcli + dotnet new classlib -o MailKit.Client + ``` + +1. Add the project to the solution. + + ```dotnetcli + dotnet sln /MailDevResource.sln add MailKit.Client/MailKit.Client.csproj + ``` + +The next step is to add all the NuGet packages that the component relies on. Rather than having you add each package one-by-one from the .NET CLI, it's likely easier to copy and paste the following XML into the _MailKit.Client.csproj_ file. + +```xml + + + + + + + + +``` + +## Define component settings + +Whenever you're creating a .NET Aspire component, it's best to understand the client library that you're mapping to. With MailKit, you need to understand the configuration settings that are required to connect to a Simple Mail Transfer Protocol (SMTP) server. But it's also important to understand if the library has support for _health checks_, _tracing_ and _metrics_. MailKit supports _tracing_ and _metrics_, through its [`Telemetry.SmtpClient` class](https://github.com/jstedfast/MailKit/blob/master/MailKit/Telemetry.cs#L112-L189). When adding _health checks_, you should use any established or existing health checks where possible. Otherwise, you might consider implementing your own in the component. Add the following code to the `MailKit.Client` project in a file named _MailKitClientSettings.cs_: + +:::code source="snippets/MailDevResource/MailKit.Client/MailKitClientSettings.cs"::: + +The preceding code defines the `MailKitClientSettings` class with: + +- `Endpoint` property that represents the connection string to the SMTP server. +- `Credentials` property that represents the credentials to authenticate with the SMTP server. +- `DisableHealthChecks` property that determines whether health checks are enabled. +- `DisableTracing` property that determines whether tracing is enabled. +- `DisableMetrics` property that determines whether metrics are enabled. + +### Parse connection string logic + +The settings class also contains a `ParseConnectionString` method that parses the connection string into a valid `Uri`. The configuration is expected to be provided in the following format: + +- `ConnectionStrings:`: The connection string to the SMTP server. +- `MailKit:Client:Endpoint`: The connection string to the SMTP server. + +If neither of these values are provided, an exception is thrown. Likewise, if there's a value but it's not a valid URI, an exception is thrown. + +### Parse credentials logic + +The settings class also contains a `ParseCredentials` method that parses the credentials into a valid `NetworkCredential`. The configuration is expected to be provided in the following format: + +- `MailKit:Client:Credentials:UserName`: The username to authenticate with the SMTP server. +- `MailKit:Client:Credentials:Password`: The password to authenticate with the SMTP server. + +When credentials are configured, the `ParseCredentials` method attempts to parse the username and password from the configuration. If either the username or password is missing, an exception is thrown. + +## Expose component wrapper functionality + +The goal of .NET Aspire components is to expose the underlying client library to consumers through dependency injection. With MailKit and for this example, the `SmtpClient` class is what you want to expose. You're not wrapping any functionality, but rather mapping configuration settings to an `SmtpClient` class. It's common to expose both standard and keyed-service registrations for components. Standard registrations are used when there's only one instance of a service, and keyed-service registrations are used when there are multiple instances of a service. Sometimes, to achieve multiple registrations of the same type you use a factory pattern. Add the following code to the `MailKit.Client` project in a file named _MailKitClientFactory.cs_: + +:::code source="snippets/MailDevResource/MailKit.Client/MailKitClientFactory.cs"::: + +The `MailKitClientFactory` class is a factory that creates an `ISmtpClient` instance based on the configuration settings. It's responsible for returning an `ISmtpClient` implementation that has an active connection to a configured SMTP server and optionally authenticated. Next, you need to expose the functionality for the consumers to register this factory with the dependency injection container. Add the following code to the `MailKit.Client` project in a file named _MailKitExtensions.cs_: + +:::code source="snippets/MailDevResource/MailKit.Client/MailKitExtensions.cs"::: + +The preceding code adds two extension methods on the `IHostApplicationBuilder` type, one for the standard registration of MailKit and another for keyed-registration of MailKit. + +> [!TIP] +> Extension methods for .NET Aspire components should extend the `IHostApplicationBuilder` type and follow the `Add` naming convention where the `` is the type or functionality you're adding. For this article, the `AddMailKitClient` extension method is used to add the MailKit client. It's likely more in-line with the official guidance to use `AddMailKitSmtpClient` instead of `AddMailKitClient`, since this only registers the `SmtpClient` and not the entire MailKit library. + +Both extensions ultimately rely on the private `AddMailKitClient` method to register the `MailKitClientFactory` with the dependency injection container as a [scoped service](/dotnet/core/extensions/dependency-injection#scoped). The reason for registering the `MailKitClientFactory` as a scoped service is because the connection (and authentication) operations are considered expensive and should be reused within the same scope where possible. In other words, for a single request, the same `ISmtpClient` instance should be used. The factory holds on to the instance of the `SmtpClient` that it creates and disposes of it. + +### Configuration binding + +One of the first things that the private implementation of the `AddMailKitClient` methods does, is to bind the configuration settings to the `MailKitClientSettings` class. The settings class is instantiated and then `Bind` is called with the specific section of configuration. Then the optional `configureSettings` delegate is invoked with the current settings. This allows the consumer to further configure the settings, ensuring that manual code settings are honored over configuration settings. After that, depending on whether the `serviceKey` value was provided, the `MailKitClientFactory` should be registered with the dependency injection container as either a standard or keyed service. + +> [!IMPORTANT] +> It's intentional that the `implementationFactory` overload is called when registering services. The `CreateMailKitClientFactory` method throws when the configuration is invalid. This ensures that creation of the `MailKitClientFactory` is deferred until it's needed and it prevents the app from erroring out before logging is available. + +The registration of health checks, and telemetry are described in a bit more detail in the following sections. + +### Add health checks + +[Health checks](../fundamentals/health-checks.md) are a way to monitor the health of a component. With MailKit, you can check if the connection to the SMTP server is healthy. Add the following code to the `MailKit.Client` project in a file named _MailKitHealthCheck.cs_: + +:::code source="snippets/MailDevResource/MailKit.Client/MailKitHealthCheck.cs"::: + +The preceding health check implementation: + +- Implements the `IHealthCheck` interface. +- Accepts the `MailKitClientFactory` as a primary constructor parameter. +- Satisfies the `CheckHealthAsync` method by: + - Attempting to get an `ISmtpClient` instance from the `factory`. If successful, it returns `HealthCheckResult.Healthy`. + - If an exception is thrown, it returns `HealthCheckResult.Unhealthy`. + +As previously shared in the registration of the `MailKitClientFactory`, the `MailKitHealthCheck` is conditionally registered with the `IHeathChecksBuilder`: + +```csharp +if (settings.DisableHealthChecks is false) +{ + builder.Services.AddHealthChecks() + .AddCheck( + name: serviceKey is null ? "MailKit" : $"MailKit_{connectionName}", + failureStatus: default, + tags: []); +} +``` + +The consumer could choose to omit health checks by setting the `DisableHealthChecks` property to `true` in the configuration. A common pattern for components is to have optional features and .NET Aspire components strongly encourages these types of configurations. For more information on health checks and a working sample that includes a user interface, see [.NET Aspire ASP.NET Core HealthChecksUI sample](/samples/dotnet/aspire-samples/aspire-health-checks-ui/). + +### Wire up telemetry + +As a best practice, the [MailKit client library exposes telemetry](https://github.com/jstedfast/MailKit/blob/master/Telemetry.md). .NET Aspire can take advantage of this telemetry and display it in the [.NET Aspire dashboard](../fundamentals/dashboard/overview.md). Depending on whether or not tracing and metrics are enabled, telemetry is wired up as shown in the following code snippet: + +```csharp +if (settings.DisableTracing is false) +{ + builder.Services.AddOpenTelemetry() + .WithTracing( + traceBuilder => traceBuilder.AddSource( + Telemetry.SmtpClient.ActivitySourceName)); +} + +if (settings.DisableMetrics is false) +{ + // Required by MailKit to enable metrics + Telemetry.SmtpClient.Configure(); + + builder.Services.AddOpenTelemetry() + .WithMetrics( + metricsBuilder => metricsBuilder.AddMeter( + Telemetry.SmtpClient.MeterName)); +} +``` + +## Update the Newsletter service + +With the component library created, you can now update the Newsletter service to use the MailKit client. The first step is to add a reference to the `MailKit.Client` project. Add the _MailKit.Client.csproj_ project reference to the `MailDevResource.NewsletterService` project: + +```dotnetcli +dotnet add ./MailDevResource.NewsletterService/MailDevResource.NewsletterService.csproj reference MailKit.Client/MailKit.Client.csproj +``` + +The final step is to replace the existing _Program.cs_ file in the `MailDevResource.NewsletterService` project with the following C# code: + +:::code source="snippets/MailDevResource/MailDevResource.NewsletterService/Program.cs"::: + +The most notable changes in the preceding code are: + +- The updated `using` statements that include the `MailKit.Client`, `MailKit.Net.Smtp`, and `MimeKit` namespaces. +- The replacement of the registration for the official .NET `SmtpClient` with the call to the `AddMailKitClient` extension method. +- The replacement of both `/subscribe` and `/unsubscribe` map post calls to instead inject the `MailKitClientFactory` and use the `ISmtpClient` instance to send the email. + +## Run the sample + +Now that you've created the MailKit client component and updated the Newsletter service to use it, you can run the sample. From your IDE, select F5 or run `dotnet run` from the root directory of the solution to start the application—you should see the [.NET Aspire dashboard](../fundamentals/dashboard/overview.md): + +:::image type="content" source="./media/maildev-with-newsletterservice-dashboard.png" lightbox="./media/maildev-with-newsletterservice-dashboard.png" alt-text=".NET Aspire dashboard: MailDev and Newsletter resources running."::: + +Once the application is running, navigate to the Swagger UI at [https://localhost:7251/swagger](https://localhost:7251/swagger) and test the `/subscribe` and `/unsubscribe` endpoints. Select the down arrow to expand the endpoint: + +:::image type="content" source="./media/swagger-ui.png" lightbox="./media/swagger-ui.png" alt-text="Swagger UI: Subscribe endpoint."::: + +Then select the `Try it out` button. Enter an email address, and then select the `Execute` button. + +:::image type="content" source="./media/swagger-ui-try.png" lightbox="./media/swagger-ui-try.png" alt-text="Swagger UI: Subscribe endpoint with email address."::: + +Repeat this several times, to add multiple email addresses. You should see the email sent to the MailDev inbox: + +:::image type="content" source="./media/maildev-inbox.png" alt-text="MailDev inbox with multiple emails."::: + +Stop the application by selecting Ctrl+C in the terminal window where the application is running, or by selecting the stop button in your IDE. + +### Configure MailDev credentials + +The MailDev container supports basic authentication for both incoming and outgoing SMTP. To configure the credentials for incoming, you need to set the `MAILDEV_INCOMING_USER` and `MAILDEV_INCOMING_PASS` environment variables. For more information, see [MailDev: Usage](https://maildev.github.io/maildev/#usage). + +To configure these credentials, update the _Program.cs_ file in the `MailDevResource.AppHost` project with the following code: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var mailDevUsername = builder.AddParameter("maildev-username"); +var mailDevPassword = builder.AddParameter("maildev-password"); + +var maildev = builder.AddMailDev("maildev") + .WithEnvironment("MAILDEV_INCOMING_USER", mailDevUsername) + .WithEnvironment("MAILDEV_INCOMING_PASS", mailDevPassword); + +builder.AddProject("newsletterservice") + .WithReference(maildev); + +builder.Build().Run(); +``` + +The preceding code adds two parameters for the MailDev username and password. It assigns these parameters to the `MAILDEV_INCOMING_USER` and `MAILDEV_INCOMING_PASS` environment variables. The `AddMailDev` method has two chained calls to `WithEnvironment` which includes these environment variables. For more information on parameters, see [External parameters](../fundamentals/external-parameters.md). + +Next, configure the secrets for these paremeters. Right-click on the `MailDevResource.AppHost` project and select `Manage User Secrets`. Add the following JSON to the `secrets.json` file: + +```json +{ + "Parameters:maildev-username": "@admin", + "Parameters:maildev-password": "t3st1ng" +} +``` + +> [!WARNING] +> These credentials are for demonstration purposes only and MailDev is intended for local development. These crednetials are fictitious and shouldn't be used in a production environment. + +If you're to run the sample now, the client wouldn't be able to connect to the MailDev container. This is because the MailDev container is configured to require authentication for incoming SMTP connections. The MailKit client configuration also needs to be updated to include the credentials. + +To configure the credentials in the client, right-click on the `MailDevResource.NewsletterService` project and select `Manage User Secrets`. Add the following JSON to the `secrets.json` file: + +```json +{ + "MailKit:Client": { + "Credentials": { + "UserName": "@admin", + "Password": "t3st1ng" + } + } +} +``` + +Run the app again, and everything works as it did before, but now with authentication enabled. + +### View MailKit telemetry + +The MailKit client library exposes telemetry that can be viewed in the .NET Aspire dashboard. To view the telemetry, navigate to the .NET Aspire dashboard at [https://localhost:7251](https://localhost:7251). Select the `newsletter` resource to view the telemetry on the **Metrics** page: + +:::image type="content" source="./media/mailkit-metrics-dashboard.png" lightbox="./media/mailkit-metrics-dashboard.png" alt-text=".NET Aspire dashboard: MailKit telemetry."::: + +Open up the Swagger UI again, and make some requests to the `/subscribe` and `/unsubscribe` endpoints. Then, navigate back to the .NET Aspire dashboard and select the `newsletter` resource. Select a metric under the **mailkit.net.smtp** node, such as `mailkit.net.smtp.client.operation.count`. You should see the telemetry for the MailKit client: + +:::image type="content" source="./media/mailkit-metrics-graph-dashboard.png" lightbox="./media/mailkit-metrics-graph-dashboard.png" alt-text=".NET Aspire dashboard: MailKit telemetry for operation count."::: + +## Summary + +In this article, you learned how to create a .NET Aspire component that uses MailKit to send emails. You also learned how to integrate this component into the Newsletter app you previously built. You learned about the core principles of .NET Aspire components, such as exposing the underlying client library to consumers through dependency injection, and how to add health checks and telemetry to the component. You also learned how to update the Newsletter service to use the MailKit client. + +Go forth and build your own .NET Aspire components. If you believe that there's enough community value in the component you're building, consider publishing it as a [NuGet package](/dotnet/standard/library-guidance/nuget) for others to use. Furthermore, consider submitting a pull request to the [.NET Aspire GitHub repository](https://github.com/dotnet/aspire) for consideration to be included in the official .NET Aspire components. diff --git a/docs/extensibility/custom-resources.md b/docs/extensibility/custom-resources.md index 60d67e694e..8c4ac58bc0 100644 --- a/docs/extensibility/custom-resources.md +++ b/docs/extensibility/custom-resources.md @@ -1,9 +1,8 @@ --- title: Create custom resource types for .NET Aspire description: Learn how to create a custom resource for an existing containerized application. -ms.date: 05/29/2024 +ms.date: 07/15/2024 ms.topic: how-to -ms.custom: devx-track-extended-azdevcli --- # Create custom resource types for .NET Aspire @@ -230,7 +229,7 @@ In order to test the end-to-end scenario, you need a .NET project which we can i 1. Create a new .NET project named _:::no-loc text="MailDevResource.NewsletterService":::_. ```dotnetcli - dotnet new webapi --use-minimal-apis --no-openapi -o MailDevResource.NewsletterService + dotnet new webapi --use-minimal-apis -o MailDevResource.NewsletterService ``` 1. Add a reference to the _:::no-loc text="MailDev.Hosting":::_ project. @@ -265,11 +264,45 @@ The preceding screenshot shows the environment variables for the `newsletterserv To use the SMTP connection details that were injected into the newsletter service project, you inject an instance of into the dependency injection container as a singleton. Add the following code to the _:::no-loc text="Program.cs":::_ file in the _:::no-loc text="MailDevResource.NewsletterService":::_ project to setup the singleton service. In the `Program` class, immediately following the `// Add services to the container` comment, add the following code: -:::code source="snippets/MailDevResource/MailDevResource.NewsletterService/Program.cs" id="smtp"::: +```csharp +builder.Services.AddSingleton(sp => +{ + var smtpUri = new Uri(builder.Configuration.GetConnectionString("maildev")!); -To test the client, add two simple `subscribe` and `unsubscribe` GET methods to the newsletter service. Add the following code after the `MapGet` call in the _:::no-loc text="Program.cs":::_ file of the _MailDevResource.NewsletterService_ project to setup the ASP.NET Core routes: + var smtpClient = new SmtpClient(smtpUri.Host, smtpUri.Port); -:::code source="snippets/MailDevResource/MailDevResource.NewsletterService/Program.cs" id="subs"::: + return smtpClient; +}); +``` + +> [!TIP] +> This code snippet relies on the official `SmtpClient`, however; this type is obsolete on some platforms and not recommended on others. This is used here to demonstrate a non-componentized approach to using the MailDev resource. For a more modern approach using [MailKit](https://github.com/jstedfast/MailKit), see [Create custom .NET Aspire component](custom-component.md). + +To test the client, add two simple `subscribe` and `unsubscribe` POST methods to the newsletter service. Add the following code replacing the "weatherforecast" `MapGet` call in the _:::no-loc text="Program.cs":::_ file of the _MailDevResource.NewsletterService_ project to setup the ASP.NET Core routes: + +```csharp +app.MapPost("/subscribe", async (SmtpClient smtpClient, string email) => +{ + using var message = new MailMessage("newsletter@yourcompany.com", email) + { + Subject = "Welcome to our newsletter!", + Body = "Thank you for subscribing to our newsletter!" + }; + + await smtpClient.SendMailAsync(message); +}); + +app.MapPost("/unsubscribe", async (SmtpClient smtpClient, string email) => +{ + using var message = new MailMessage("newsletter@yourcompany.com", email) + { + Subject = "You are unsubscribed from our newsletter!", + Body = "Sorry to see you go. We hope you will come back soon!" + }; + + await smtpClient.SendMailAsync(message); +}); +``` > [!TIP] > Remember to reference the `System.Net.Mail` and `Microsoft.AspNetCore.Mvc` namespaces in _:::no-loc text="Program.cs":::_ if your code editor doesn't automatically add them. @@ -293,7 +326,7 @@ curl -H "Content-Type: application/json" --request POST https://localhost:7251/s ## [Windows](#tab/windows) ```powershell -curl -H "Content-Type: application/json" --request POST https://localhost:7251/subscribe?email=test@test.com +curl -H @{ ContentType = "application/json" } -Method POST https://localhost:7251/subscribe?email=test@test.com ``` --- @@ -317,7 +350,7 @@ curl -H "Content-Type: application/json" --request POST https://localhost:7251/u ## [Windows](#tab/windows) ```powershell -curl -H "Content-Type: application/json" --request POST https://localhost:7251/unsubscribe?email=test@test.com +curl -H @{ ContentType = "application/json" } -Method POST https://localhost:7251/unsubscribe?email=test@test.com ``` --- @@ -482,3 +515,8 @@ Careful consideration should be given as to whether the resource should be prese ## Summary In the custom resource tutorial, you learned how to create a custom .NET Aspire resource which uses an existing containerized application (MailDev). You then used that to improve the local development experience by making it easy to test e-mail capabilities that might be used within an app. These learnings can be applied to building out other custom resources that can be used in .NET Aspire-based applications. This specific example didn't include any custom components, but it's possible to build out custom components to make it easier for developers to use the resource. In this scenario you were able to rely on the existing `SmtpClient` class in the .NET platform to send e-mails. + +## Next steps + +> [!div class="nextstepaction"] +> [Create custom .NET Aspire component](custom-component.md) diff --git a/docs/extensibility/media/maildev-inbox.png b/docs/extensibility/media/maildev-inbox.png new file mode 100644 index 0000000000..b4f6d177fb Binary files /dev/null and b/docs/extensibility/media/maildev-inbox.png differ diff --git a/docs/extensibility/media/maildev-with-newsletterservice-dashboard.png b/docs/extensibility/media/maildev-with-newsletterservice-dashboard.png new file mode 100644 index 0000000000..75bcc71aef Binary files /dev/null and b/docs/extensibility/media/maildev-with-newsletterservice-dashboard.png differ diff --git a/docs/extensibility/media/mailkit-metrics-dashboard.png b/docs/extensibility/media/mailkit-metrics-dashboard.png new file mode 100644 index 0000000000..504cb745eb Binary files /dev/null and b/docs/extensibility/media/mailkit-metrics-dashboard.png differ diff --git a/docs/extensibility/media/mailkit-metrics-graph-dashboard.png b/docs/extensibility/media/mailkit-metrics-graph-dashboard.png new file mode 100644 index 0000000000..f47b91fa97 Binary files /dev/null and b/docs/extensibility/media/mailkit-metrics-graph-dashboard.png differ diff --git a/docs/extensibility/media/swagger-ui-try.png b/docs/extensibility/media/swagger-ui-try.png new file mode 100644 index 0000000000..1e06009894 Binary files /dev/null and b/docs/extensibility/media/swagger-ui-try.png differ diff --git a/docs/extensibility/media/swagger-ui.png b/docs/extensibility/media/swagger-ui.png new file mode 100644 index 0000000000..e0177c8e5b Binary files /dev/null and b/docs/extensibility/media/swagger-ui.png differ diff --git a/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/MailDevResource.NewsletterService.csproj b/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/MailDevResource.NewsletterService.csproj index fe1a129336..68f91ed347 100644 --- a/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/MailDevResource.NewsletterService.csproj +++ b/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/MailDevResource.NewsletterService.csproj @@ -1,6 +1,12 @@ + + + + + + @@ -9,6 +15,7 @@ net8.0 enable enable + f2608916-3fcc-4bd8-9697-ce27b4156fc0 diff --git a/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/Program.cs b/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/Program.cs index c10e16c5c0..45b2365ccd 100644 --- a/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/Program.cs +++ b/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/Program.cs @@ -1,21 +1,17 @@ using System.Net.Mail; -using Microsoft.AspNetCore.Mvc; +using MailKit.Client; +using MailKit.Net.Smtp; +using MimeKit; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); -// Add services to the container. -// -builder.Services.AddSingleton(sp => -{ - var smtpUri = new Uri(builder.Configuration.GetConnectionString("maildev")!); - - var smtpClient = new SmtpClient(smtpUri.Host, smtpUri.Port); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); - return smtpClient; -}); -// +// Add services to the container. +builder.AddMailKitClient("maildev"); var app = builder.Build(); @@ -23,53 +19,36 @@ // Configure the HTTP request pipeline. +app.UseSwagger(); +app.UseSwaggerUI(); app.UseHttpsRedirection(); -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => +app.MapPost("/subscribe", + async (MailKitClientFactory factory, string email) => { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}); + ISmtpClient client = await factory.GetSmtpClientAsync(); -// -app.MapPost("/subscribe", async ([FromServices] SmtpClient smtpClient, string email) => -{ using var message = new MailMessage("newsletter@yourcompany.com", email) { Subject = "Welcome to our newsletter!", Body = "Thank you for subscribing to our newsletter!" }; - await smtpClient.SendMailAsync(message); + await client.SendAsync(MimeMessage.CreateFromMailMessage(message)); }); -app.MapPost("/unsubscribe", async ([FromServices] SmtpClient smtpClient, string email) => +app.MapPost("/unsubscribe", + async (MailKitClientFactory factory, string email) => { + ISmtpClient client = await factory.GetSmtpClientAsync(); + using var message = new MailMessage("newsletter@yourcompany.com", email) { Subject = "You are unsubscribed from our newsletter!", Body = "Sorry to see you go. We hope you will come back soon!" }; - await smtpClient.SendMailAsync(message); + await client.SendAsync(MimeMessage.CreateFromMailMessage(message)); }); -// app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/Properties/launchSettings.json b/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/Properties/launchSettings.json index c7eff401b7..aed106ab5c 100644 --- a/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/Properties/launchSettings.json +++ b/docs/extensibility/snippets/MailDevResource/MailDevResource.NewsletterService/Properties/launchSettings.json @@ -1,41 +1,25 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:21371", - "sslPort": 44378 - } - }, "profiles": { "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "swagger", "applicationUrl": "http://localhost:5021", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "weatherforecast", - "applicationUrl": "https://localhost:7251;http://localhost:5021", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "weatherforecast", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7251;http://localhost:5021", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } } } } diff --git a/docs/extensibility/snippets/MailDevResource/MailDevResource.sln b/docs/extensibility/snippets/MailDevResource/MailDevResource.sln index 9583a750e5..88c6bd3078 100644 --- a/docs/extensibility/snippets/MailDevResource/MailDevResource.sln +++ b/docs/extensibility/snippets/MailDevResource/MailDevResource.sln @@ -1,4 +1,5 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 + +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.8.0.0 MinimumVisualStudioVersion = 17.8.0.0 @@ -6,9 +7,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MailDevResource.AppHost", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MailDevResource.ServiceDefaults", "MailDevResource.ServiceDefaults\MailDevResource.ServiceDefaults.csproj", "{9AE7DA9D-B8AD-4BA6-A358-6F352C2D7255}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailDev.Hosting", "MailDev.Hosting\MailDev.Hosting.csproj", "{896B2FA6-E580-4AFC-ACC5-383D052F9EEB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MailDev.Hosting", "MailDev.Hosting\MailDev.Hosting.csproj", "{896B2FA6-E580-4AFC-ACC5-383D052F9EEB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailDevResource.NewsletterService", "MailDevResource.NewsletterService\MailDevResource.NewsletterService.csproj", "{3C023F9E-2B5D-4C48-818F-A640EDAE9E4C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MailDevResource.NewsletterService", "MailDevResource.NewsletterService\MailDevResource.NewsletterService.csproj", "{3C023F9E-2B5D-4C48-818F-A640EDAE9E4C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MailKit.Client", "MailKit.Client\MailKit.Client.csproj", "{C9A58484-13CC-4391-9D93-88FDFA7B0DDF}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -32,6 +35,10 @@ Global {3C023F9E-2B5D-4C48-818F-A640EDAE9E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU {3C023F9E-2B5D-4C48-818F-A640EDAE9E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU {3C023F9E-2B5D-4C48-818F-A640EDAE9E4C}.Release|Any CPU.Build.0 = Release|Any CPU + {C9A58484-13CC-4391-9D93-88FDFA7B0DDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9A58484-13CC-4391-9D93-88FDFA7B0DDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9A58484-13CC-4391-9D93-88FDFA7B0DDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9A58484-13CC-4391-9D93-88FDFA7B0DDF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKit.Client.csproj b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKit.Client.csproj new file mode 100644 index 0000000000..c543c8dbe9 --- /dev/null +++ b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKit.Client.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitClientFactory.cs b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitClientFactory.cs new file mode 100644 index 0000000000..623d3e3e72 --- /dev/null +++ b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitClientFactory.cs @@ -0,0 +1,67 @@ +using System.Net; +using MailKit.Net.Smtp; + +namespace MailKit.Client; + +/// +/// A factory for creating instances +/// given a (and optional ). +/// +/// +/// The settings for the SMTP server +/// +/// +/// The optional used to authenticate to the SMTP server +/// +public sealed class MailKitClientFactory(MailKitClientSettings settings) : IDisposable +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + + private SmtpClient? _client; + + /// + /// Gets an instance in the connected state + /// (and that's been authenticated if configured). + /// + /// Used to abort client creation and connection. + /// A connected (and authenticated) instance. + /// + /// Since both the connection and authentication are considered expensive operations, + /// the returned is intended to be used for the duration of a request + /// (registered as 'Scoped') and is automatically disposed of. + /// + public async Task GetSmtpClientAsync( + CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + + try + { + if (_client is null) + { + _client = new SmtpClient(); + + await _client.ConnectAsync(settings.Endpoint, cancellationToken) + .ConfigureAwait(false); + + if (settings.Credentials is not null) + { + await _client.AuthenticateAsync(settings.Credentials, cancellationToken) + .ConfigureAwait(false); + } + } + } + finally + { + _semaphore.Release(); + } + + return _client; + } + + public void Dispose() + { + _client?.Dispose(); + _semaphore.Dispose(); + } +} diff --git a/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitClientSettings.cs b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitClientSettings.cs new file mode 100644 index 0000000000..c75435f179 --- /dev/null +++ b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitClientSettings.cs @@ -0,0 +1,97 @@ +using System.Net; +using Microsoft.Extensions.Configuration; + +namespace MailKit.Client; + +/// +/// Provides the client configuration settings for connecting MailKit to an SMTP server. +/// +public sealed class MailKitClientSettings +{ + internal const string DefaultConfigSectionName = "MailKit:Client"; + + /// + /// Gets or sets the SMTP server . + /// + /// + /// The default value is . + /// + public Uri? Endpoint { get; set; } + + /// + /// Gets or sets the network credentials that are optionally configurable for SMTP + /// server's that require authentication. + /// + /// + /// The default value is . + /// + public NetworkCredential? Credentials { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the database health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableTracing { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableMetrics { get; set; } + + internal void ParseConnectionString(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException($""" + ConnectionString is missing. + It should be provided in 'ConnectionStrings:' + or '{DefaultConfigSectionName}:Endpoint' key.' + configuration section. + """); + } + + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri) is false) + { + throw new InvalidOperationException($""" + The 'ConnectionStrings:' (or 'Endpoint' key in + '{DefaultConfigSectionName}') isn't a valid URI format. + """); + } + + Endpoint = uri; + } + + internal void ParseCredentials(IConfigurationSection credentialsSection) + { + if (credentialsSection is null or { Value: null }) + { + return; + } + + var username = credentialsSection["UserName"]; + var password = credentialsSection["Password"]; + + if (username is null || password is null) + { + throw new InvalidOperationException($""" + The '{DefaultConfigSectionName}:Credentials' section cannot be empty. + Either remove Credentials altogether, or provide them. + """); + } + + Credentials = new(username, password); + } +} diff --git a/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitExtensions.cs b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitExtensions.cs new file mode 100644 index 0000000000..eb9639b64d --- /dev/null +++ b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitExtensions.cs @@ -0,0 +1,143 @@ +using MailKit; +using MailKit.Client; +using MailKit.Net.Smtp; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for registering a as a +/// scoped-lifetime service in the services provided by the . +/// +public static class MailKitExtensions +{ + /// + /// Registers 'Scoped' for creating + /// connected instance for sending emails. + /// + /// + /// The to read config from and add services to. + /// + /// + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// + /// + /// An optional delegate that can be used for customizing options. + /// It's invoked after the settings are read from the configuration. + /// + public static void AddMailKitClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null) => + AddMailKitClient( + builder, + MailKitClientSettings.DefaultConfigSectionName, + configureSettings, + connectionName, + serviceKey: null); + + /// + /// Registers 'Scoped' for creating + /// connected instance for sending emails. + /// + /// + /// The to read config from and add services to. + /// + /// + /// The name of the component, which is used as the of the + /// service and also to retrieve the connection string from the ConnectionStrings configuration section. + /// + /// + /// An optional method that can be used for customizing options. It's invoked after the settings are + /// read from the configuration. + /// + public static void AddKeyedMailKitClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(name); + + AddMailKitClient( + builder, + $"{MailKitClientSettings.DefaultConfigSectionName}:{name}", + configureSettings, + connectionName: name, + serviceKey: name); + } + + private static void AddMailKitClient( + this IHostApplicationBuilder builder, + string configurationSectionName, + Action? configureSettings, + string connectionName, + object? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + + var settings = new MailKitClientSettings(); + + builder.Configuration + .GetSection(configurationSectionName) + .Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ParseConnectionString(connectionString); + } + + if (builder.Configuration.GetSection( + $"{configurationSectionName}:Credentials") is { } section) + { + settings.ParseCredentials(section); + } + + configureSettings?.Invoke(settings); + + if (serviceKey is null) + { + builder.Services.AddScoped(CreateMailKitClientFactory); + } + else + { + builder.Services.AddKeyedScoped(serviceKey, (sp, key) => CreateMailKitClientFactory(sp)); + } + + MailKitClientFactory CreateMailKitClientFactory(IServiceProvider _) + { + return new MailKitClientFactory(settings); + } + + if (settings.DisableHealthChecks is false) + { + builder.Services.AddHealthChecks() + .AddCheck( + name: serviceKey is null ? "MailKit" : $"MailKit_{connectionName}", + failureStatus: default, + tags: []); + } + + if (settings.DisableTracing is false) + { + // Required by MailKit to enable tracing + Telemetry.SmtpClient.Configure(); + + builder.Services.AddOpenTelemetry() + .WithTracing( + traceBuilder => traceBuilder.AddSource( + Telemetry.SmtpClient.ActivitySourceName)); + } + + if (settings.DisableMetrics is false) + { + // Required by MailKit to enable metrics + Telemetry.SmtpClient.Configure(); + + builder.Services.AddOpenTelemetry() + .WithMetrics( + metricsBuilder => metricsBuilder.AddMeter( + Telemetry.SmtpClient.MeterName)); + } + } +} diff --git a/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitHealthCheck.cs b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitHealthCheck.cs new file mode 100644 index 0000000000..01924a130f --- /dev/null +++ b/docs/extensibility/snippets/MailDevResource/MailKit.Client/MailKitHealthCheck.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace MailKit.Client; + +internal sealed class MailKitHealthCheck(MailKitClientFactory factory) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + // The factory connects (and authenticates). + _ = await factory.GetSmtpClientAsync(cancellationToken); + + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy(exception: ex); + } + } +} diff --git a/docs/extensibility/snippets/MailDevResource/global.json b/docs/extensibility/snippets/MailDevResource/global.json new file mode 100644 index 0000000000..a1aaefd486 --- /dev/null +++ b/docs/extensibility/snippets/MailDevResource/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.303", + "rollForward": "latestPatch" + } +} diff --git a/docs/toc.yml b/docs/toc.yml index 3edde5f837..5b81de742e 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -69,6 +69,9 @@ items: - name: Overview displayName: aspire components,components,nuget packages,packages href: fundamentals/components-overview.md + - name: Create custom components + displayName: custom components,extensibility + href: extensibility/custom-component.md - name: Tutorials items: - name: Caching using Redis components