From 9238036042006c3bc92045f785d53c71a8b8421d Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 7 Mar 2026 23:03:31 -0500 Subject: [PATCH] Add resend confirmation email feature (#166) --- .../Features/ResendConfirmation.feature | 36 +++ .../API/API.Specs/Mocks/MockEmailService.cs | 27 +++ src/Core/API/API.Specs/Steps/AuthSteps.cs | 87 +++++++ .../Mail/ResendConfirmation.razor | 117 ++++++++++ .../Rendering/EmailTemplateProvider.cs | 17 ++ .../Rendering/IEmailTemplateProvider.cs | 11 + .../Auth/AuthRepository.cs | 2 +- .../Auth/IAuthRepository.cs | 7 + .../ConfirmationService.test.cs | 218 +++++++++--------- .../Service.Auth/ConfirmationService.cs | 59 +++-- .../Service.Auth/IConfirmationService.cs | 2 + .../Service/Service.Emails/EmailService.cs | 27 +++ 12 files changed, 482 insertions(+), 128 deletions(-) create mode 100644 src/Core/API/API.Specs/Features/ResendConfirmation.feature create mode 100644 src/Core/Infrastructure/Infrastructure.Email.Templates/Mail/ResendConfirmation.razor diff --git a/src/Core/API/API.Specs/Features/ResendConfirmation.feature b/src/Core/API/API.Specs/Features/ResendConfirmation.feature new file mode 100644 index 0000000..9cf414c --- /dev/null +++ b/src/Core/API/API.Specs/Features/ResendConfirmation.feature @@ -0,0 +1,36 @@ +Feature: Resend Confirmation Email + As a user who did not receive the confirmation email + I want to request a resend of the confirmation email + So that I can obtain a working confirmation link while preventing abuse + + Scenario: Legitimate resend for an unconfirmed user + Given the API is running + And I have registered a new account + And I have a valid access token for my account + When I submit a resend confirmation request for my account + Then the response has HTTP status 200 + And the response JSON should have "message" containing "confirmation email has been resent" + + Scenario: Resend is a no-op for an already confirmed user + Given the API is running + And I have registered a new account + And I have a valid confirmation token for my account + And I have a valid access token for my account + And I have confirmed my account + When I submit a resend confirmation request for my account + Then the response has HTTP status 200 + And the response JSON should have "message" containing "confirmation email has been resent" + + Scenario: Resend is a no-op for a non-existent user + Given the API is running + And I have registered a new account + And I have a valid access token for my account + When I submit a resend confirmation request for a non-existent user + Then the response has HTTP status 200 + And the response JSON should have "message" containing "confirmation email has been resent" + + Scenario: Resend requires authentication + Given the API is running + And I have registered a new account + When I submit a resend confirmation request without an access token + Then the response has HTTP status 401 diff --git a/src/Core/API/API.Specs/Mocks/MockEmailService.cs b/src/Core/API/API.Specs/Mocks/MockEmailService.cs index 7a36528..0e9f0b2 100644 --- a/src/Core/API/API.Specs/Mocks/MockEmailService.cs +++ b/src/Core/API/API.Specs/Mocks/MockEmailService.cs @@ -7,6 +7,8 @@ public class MockEmailService : IEmailService { public List SentRegistrationEmails { get; } = new(); + public List SentResendConfirmationEmails { get; } = new(); + public Task SendRegistrationEmailAsync( UserAccount createdUser, string confirmationToken @@ -24,9 +26,27 @@ public class MockEmailService : IEmailService return Task.CompletedTask; } + public Task SendResendConfirmationEmailAsync( + UserAccount user, + string confirmationToken + ) + { + SentResendConfirmationEmails.Add( + new ResendConfirmationEmail + { + UserAccount = user, + ConfirmationToken = confirmationToken, + SentAt = DateTime.UtcNow, + } + ); + + return Task.CompletedTask; + } + public void Clear() { SentRegistrationEmails.Clear(); + SentResendConfirmationEmails.Clear(); } public class RegistrationEmail @@ -35,4 +55,11 @@ public class MockEmailService : IEmailService public string ConfirmationToken { get; init; } = string.Empty; public DateTime SentAt { get; init; } } + + public class ResendConfirmationEmail + { + public UserAccount UserAccount { get; init; } = null!; + public string ConfirmationToken { get; init; } = string.Empty; + public DateTime SentAt { get; init; } + } } diff --git a/src/Core/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs index bb99c39..e80dcb1 100644 --- a/src/Core/API/API.Specs/Steps/AuthSteps.cs +++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs @@ -1124,4 +1124,91 @@ public class AuthSteps(ScenarioContext scenario) refreshToken.Should().NotBe(previousRefreshToken); } } + + [Given("I have confirmed my account")] + public async Task GivenIHaveConfirmedMyAccount() + { + var client = GetClient(); + var token = scenario.TryGetValue("confirmationToken", out var t) + ? t + : throw new InvalidOperationException("confirmation token not found"); + var accessToken = scenario.TryGetValue("accessToken", out var at) + ? at + : string.Empty; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" + ); + if (!string.IsNullOrEmpty(accessToken)) + requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); + + var response = await client.SendAsync(requestMessage); + response.EnsureSuccessStatusCode(); + } + + [When("I submit a resend confirmation request for my account")] + public async Task WhenISubmitAResendConfirmationRequestForMyAccount() + { + var client = GetClient(); + var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + ? id + : throw new InvalidOperationException("registered user ID not found"); + var accessToken = scenario.TryGetValue("accessToken", out var at) + ? at + : string.Empty; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm/resend?userId={userId}" + ); + if (!string.IsNullOrEmpty(accessToken)) + requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a resend confirmation request for a non-existent user")] + public async Task WhenISubmitAResendConfirmationRequestForANonExistentUser() + { + var client = GetClient(); + var fakeUserId = Guid.NewGuid(); + var accessToken = scenario.TryGetValue("accessToken", out var at) + ? at + : string.Empty; + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm/resend?userId={fakeUserId}" + ); + if (!string.IsNullOrEmpty(accessToken)) + requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } + + [When("I submit a resend confirmation request without an access token")] + public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken() + { + var client = GetClient(); + var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + ? id + : Guid.NewGuid(); + + var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + $"/api/auth/confirm/resend?userId={userId}" + ); + + var response = await client.SendAsync(requestMessage); + var responseBody = await response.Content.ReadAsStringAsync(); + scenario[ResponseKey] = response; + scenario[ResponseBodyKey] = responseBody; + } } diff --git a/src/Core/Infrastructure/Infrastructure.Email.Templates/Mail/ResendConfirmation.razor b/src/Core/Infrastructure/Infrastructure.Email.Templates/Mail/ResendConfirmation.razor new file mode 100644 index 0000000..42db0ae --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Email.Templates/Mail/ResendConfirmation.razor @@ -0,0 +1,117 @@ +@using Infrastructure.Email.Templates.Components + + + + + + + + + + Resend Confirmation - The Biergarten App + + + + + + + + + + + +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+

+ New Confirmation Link +

+
+

+ Hi @Username, you requested another email confirmation + link. + Use the button below to verify your account. +

+
+ + + + +
+ + + + Confirm Email Again + + +
+
+

+ This replacement link expires in 24 hours. +

+
+

+ If you did not request this, you can safely ignore this email. +

+
+ +
+ + + +@code { + [Parameter] + public string Username { get; set; } = string.Empty; + + [Parameter] + public string ConfirmationLink { get; set; } = string.Empty; +} diff --git a/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs b/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs index a7e1743..e8203e4 100644 --- a/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs +++ b/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs @@ -30,6 +30,23 @@ public class EmailTemplateProvider( return await RenderComponentAsync(parameters); } + /// + /// Renders the ResendConfirmation template with the specified parameters. + /// + public async Task RenderResendConfirmationEmailAsync( + string username, + string confirmationLink + ) + { + var parameters = new Dictionary + { + { nameof(ResendConfirmation.Username), username }, + { nameof(ResendConfirmation.ConfirmationLink), confirmationLink }, + }; + + return await RenderComponentAsync(parameters); + } + /// /// Generic method to render any Razor component to HTML. /// diff --git a/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs b/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs index bc67c28..2125025 100644 --- a/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs +++ b/src/Core/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs @@ -15,4 +15,15 @@ public interface IEmailTemplateProvider string username, string confirmationLink ); + + /// + /// Renders the ResendConfirmation template with the specified parameters. + /// + /// The username to include in the email + /// The new confirmation link + /// The rendered HTML string + Task RenderResendConfirmationEmailAsync( + string username, + string confirmationLink + ); } diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs index 132499d..fbff2b3 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs @@ -159,7 +159,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) return await GetUserByIdAsync(userAccountId); } - private async Task IsUserVerifiedAsync(Guid userAccountId) + public async Task IsUserVerifiedAsync(Guid userAccountId) { await using var connection = await CreateConnection(); await using var command = connection.CreateCommand(); diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs index 108f5c7..4648c4b 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs @@ -75,4 +75,11 @@ public interface IAuthRepository /// ID of the user account /// UserAccount if found, null otherwise Task GetUserByIdAsync(Guid userAccountId); + + /// + /// Checks whether a user account has been verified. + /// + /// ID of the user account + /// True if the user has a verification record, false otherwise + Task IsUserVerifiedAsync(Guid userAccountId); } diff --git a/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs b/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs index 0caf31f..e04c293 100644 --- a/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs +++ b/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs @@ -5,151 +5,155 @@ using Domain.Exceptions; using FluentAssertions; using Infrastructure.Repository.Auth; using Moq; +using Service.Emails; namespace Service.Auth.Tests; public class ConfirmationServiceTest { - private readonly Mock _authRepositoryMock; - private readonly Mock _tokenServiceMock; - private readonly ConfirmationService _confirmationService; + private readonly Mock _authRepositoryMock; + private readonly Mock _tokenServiceMock; + private readonly Mock _emailServiceMock; + private readonly ConfirmationService _confirmationService; - public ConfirmationServiceTest() - { - _authRepositoryMock = new Mock(); - _tokenServiceMock = new Mock(); + public ConfirmationServiceTest() + { + _authRepositoryMock = new Mock(); + _tokenServiceMock = new Mock(); + _emailServiceMock = new Mock(); - _confirmationService = new ConfirmationService( - _authRepositoryMock.Object, - _tokenServiceMock.Object - ); - } + _confirmationService = new ConfirmationService( + _authRepositoryMock.Object, + _tokenServiceMock.Object, + _emailServiceMock.Object + ); + } - [Fact] - public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser() - { - // Arrange - var userId = Guid.NewGuid(); - const string username = "testuser"; - const string confirmationToken = "valid-confirmation-token"; + [Fact] + public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser() + { + // Arrange + var userId = Guid.NewGuid(); + const string username = "testuser"; + const string confirmationToken = "valid-confirmation-token"; - var claims = new List + var claims = new List { new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.UniqueName, username), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; - var claimsIdentity = new ClaimsIdentity(claims); - var principal = new ClaimsPrincipal(claimsIdentity); + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); - var validatedToken = new ValidatedToken(userId, username, principal); - var userAccount = new UserAccount - { - UserAccountId = userId, - Username = username, - FirstName = "Test", - LastName = "User", - Email = "test@example.com", - DateOfBirth = new DateTime(1990, 1, 1), - }; + var validatedToken = new ValidatedToken(userId, username, principal); + var userAccount = new UserAccount + { + UserAccountId = userId, + Username = username, + FirstName = "Test", + LastName = "User", + Email = "test@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + }; - _tokenServiceMock - .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) - .ReturnsAsync(validatedToken); + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) + .ReturnsAsync(validatedToken); - _authRepositoryMock - .Setup(x => x.ConfirmUserAccountAsync(userId)) - .ReturnsAsync(userAccount); + _authRepositoryMock + .Setup(x => x.ConfirmUserAccountAsync(userId)) + .ReturnsAsync(userAccount); - // Act - var result = - await _confirmationService.ConfirmUserAsync(confirmationToken); + // Act + var result = + await _confirmationService.ConfirmUserAsync(confirmationToken); - // Assert - result.Should().NotBeNull(); - result.UserId.Should().Be(userId); - result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + // Assert + result.Should().NotBeNull(); + result.UserId.Should().Be(userId); + result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); - _tokenServiceMock.Verify( - x => x.ValidateConfirmationTokenAsync(confirmationToken), - Times.Once - ); + _tokenServiceMock.Verify( + x => x.ValidateConfirmationTokenAsync(confirmationToken), + Times.Once + ); - _authRepositoryMock.Verify( - x => x.ConfirmUserAccountAsync(userId), - Times.Once - ); - } + _authRepositoryMock.Verify( + x => x.ConfirmUserAccountAsync(userId), + Times.Once + ); + } - [Fact] - public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException() - { - // Arrange - const string invalidToken = "invalid-confirmation-token"; + [Fact] + public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException() + { + // Arrange + const string invalidToken = "invalid-confirmation-token"; - _tokenServiceMock - .Setup(x => x.ValidateConfirmationTokenAsync(invalidToken)) - .ThrowsAsync(new UnauthorizedException( - "Invalid confirmation token" - )); + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(invalidToken)) + .ThrowsAsync(new UnauthorizedException( + "Invalid confirmation token" + )); - // Act & Assert - await FluentActions.Invoking(async () => - await _confirmationService.ConfirmUserAsync(invalidToken) - ).Should().ThrowAsync(); - } + // Act & Assert + await FluentActions.Invoking(async () => + await _confirmationService.ConfirmUserAsync(invalidToken) + ).Should().ThrowAsync(); + } - [Fact] - public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException() - { - // Arrange - const string expiredToken = "expired-confirmation-token"; + [Fact] + public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException() + { + // Arrange + const string expiredToken = "expired-confirmation-token"; - _tokenServiceMock - .Setup(x => x.ValidateConfirmationTokenAsync(expiredToken)) - .ThrowsAsync(new UnauthorizedException( - "Confirmation token has expired" - )); + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(expiredToken)) + .ThrowsAsync(new UnauthorizedException( + "Confirmation token has expired" + )); - // Act & Assert - await FluentActions.Invoking(async () => - await _confirmationService.ConfirmUserAsync(expiredToken) - ).Should().ThrowAsync(); - } + // Act & Assert + await FluentActions.Invoking(async () => + await _confirmationService.ConfirmUserAsync(expiredToken) + ).Should().ThrowAsync(); + } - [Fact] - public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException() - { - // Arrange - var userId = Guid.NewGuid(); - const string username = "nonexistent"; - const string confirmationToken = "valid-token-for-nonexistent-user"; + [Fact] + public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException() + { + // Arrange + var userId = Guid.NewGuid(); + const string username = "nonexistent"; + const string confirmationToken = "valid-token-for-nonexistent-user"; - var claims = new List + var claims = new List { new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.UniqueName, username), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; - var claimsIdentity = new ClaimsIdentity(claims); - var principal = new ClaimsPrincipal(claimsIdentity); + var claimsIdentity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(claimsIdentity); - var validatedToken = new ValidatedToken(userId, username, principal); + var validatedToken = new ValidatedToken(userId, username, principal); - _tokenServiceMock - .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) - .ReturnsAsync(validatedToken); + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) + .ReturnsAsync(validatedToken); - _authRepositoryMock - .Setup(x => x.ConfirmUserAccountAsync(userId)) - .ReturnsAsync((UserAccount?)null); + _authRepositoryMock + .Setup(x => x.ConfirmUserAccountAsync(userId)) + .ReturnsAsync((UserAccount?)null); - // Act & Assert - await FluentActions.Invoking(async () => - await _confirmationService.ConfirmUserAsync(confirmationToken) - ).Should().ThrowAsync() - .WithMessage("*User account not found*"); - } + // Act & Assert + await FluentActions.Invoking(async () => + await _confirmationService.ConfirmUserAsync(confirmationToken) + ).Should().ThrowAsync() + .WithMessage("*User account not found*"); + } } diff --git a/src/Core/Service/Service.Auth/ConfirmationService.cs b/src/Core/Service/Service.Auth/ConfirmationService.cs index ff4abf0..78015eb 100644 --- a/src/Core/Service/Service.Auth/ConfirmationService.cs +++ b/src/Core/Service/Service.Auth/ConfirmationService.cs @@ -1,34 +1,53 @@ using Domain.Exceptions; using Infrastructure.Repository.Auth; +using Service.Emails; namespace Service.Auth; public class ConfirmationService( IAuthRepository authRepository, - ITokenService tokenService + ITokenService tokenService, + IEmailService emailService ) : IConfirmationService { - public async Task ConfirmUserAsync( - string confirmationToken - ) - { - var validatedToken = await tokenService.ValidateConfirmationTokenAsync( - confirmationToken - ); + public async Task ConfirmUserAsync( + string confirmationToken + ) + { + var validatedToken = await tokenService.ValidateConfirmationTokenAsync( + confirmationToken + ); - var user = await authRepository.ConfirmUserAccountAsync( - validatedToken.UserId - ); + var user = await authRepository.ConfirmUserAccountAsync( + validatedToken.UserId + ); - if (user == null) - { - throw new UnauthorizedException("User account not found"); - } + if (user == null) + { + throw new UnauthorizedException("User account not found"); + } - return new ConfirmationServiceReturn( - DateTime.UtcNow, - user.UserAccountId - ); - } + return new ConfirmationServiceReturn( + DateTime.UtcNow, + user.UserAccountId + ); + } + + public async Task ResendConfirmationEmailAsync(Guid userId) + { + var user = await authRepository.GetUserByIdAsync(userId); + if (user == null) + { + return; // Silent return to prevent user enumeration + } + + if (await authRepository.IsUserVerifiedAsync(userId)) + { + return; // Already confirmed, no-op + } + + var confirmationToken = tokenService.GenerateConfirmationToken(user); + await emailService.SendResendConfirmationEmailAsync(user, confirmationToken); + } } diff --git a/src/Core/Service/Service.Auth/IConfirmationService.cs b/src/Core/Service/Service.Auth/IConfirmationService.cs index 8abb9be..c245906 100644 --- a/src/Core/Service/Service.Auth/IConfirmationService.cs +++ b/src/Core/Service/Service.Auth/IConfirmationService.cs @@ -8,4 +8,6 @@ public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId); public interface IConfirmationService { Task ConfirmUserAsync(string confirmationToken); + Task ResendConfirmationEmailAsync(Guid userId); + } diff --git a/src/Core/Service/Service.Emails/EmailService.cs b/src/Core/Service/Service.Emails/EmailService.cs index 18ce350..a65a9c9 100644 --- a/src/Core/Service/Service.Emails/EmailService.cs +++ b/src/Core/Service/Service.Emails/EmailService.cs @@ -10,6 +10,11 @@ public interface IEmailService UserAccount createdUser, string confirmationToken ); + + public Task SendResendConfirmationEmailAsync( + UserAccount user, + string confirmationToken + ); } public class EmailService( @@ -42,4 +47,26 @@ public class EmailService( isHtml: true ); } + + public async Task SendResendConfirmationEmailAsync( + UserAccount user, + string confirmationToken + ) + { + var confirmationLink = + $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}"; + + var emailHtml = + await emailTemplateProvider.RenderResendConfirmationEmailAsync( + user.FirstName, + confirmationLink + ); + + await emailProvider.SendAsync( + user.Email, + "Confirm Your Email - The Biergarten App", + emailHtml, + isHtml: true + ); + } }