Add resend confirmation email feature (#166)

This commit is contained in:
Aaron Po
2026-03-07 23:03:31 -05:00
committed by GitHub
parent 431e11e052
commit 9238036042
12 changed files with 482 additions and 128 deletions

View File

@@ -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

View File

@@ -7,6 +7,8 @@ public class MockEmailService : IEmailService
{ {
public List<RegistrationEmail> SentRegistrationEmails { get; } = new(); public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
public Task SendRegistrationEmailAsync( public Task SendRegistrationEmailAsync(
UserAccount createdUser, UserAccount createdUser,
string confirmationToken string confirmationToken
@@ -24,9 +26,27 @@ public class MockEmailService : IEmailService
return Task.CompletedTask; 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() public void Clear()
{ {
SentRegistrationEmails.Clear(); SentRegistrationEmails.Clear();
SentResendConfirmationEmails.Clear();
} }
public class RegistrationEmail public class RegistrationEmail
@@ -35,4 +55,11 @@ public class MockEmailService : IEmailService
public string ConfirmationToken { get; init; } = string.Empty; public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; } 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; }
}
} }

View File

@@ -1124,4 +1124,91 @@ public class AuthSteps(ScenarioContext scenario)
refreshToken.Should().NotBe(previousRefreshToken); refreshToken.Should().NotBe(previousRefreshToken);
} }
} }
[Given("I have confirmed my account")]
public async Task GivenIHaveConfirmedMyAccount()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: throw new InvalidOperationException("confirmation token not found");
var accessToken = scenario.TryGetValue<string>("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<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException("registered user ID not found");
var accessToken = scenario.TryGetValue<string>("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<string>("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<Guid>(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;
}
} }

View File

@@ -0,0 +1,117 @@
@using Infrastructure.Email.Templates.Components
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<title>Resend Confirmation - The Biergarten App</title>
<!--[if mso]>
<style>
* { font-family: Arial, sans-serif !important; }
table { border-collapse: collapse; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<style>
* {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
</style>
<!--<![endif]-->
</head>
<body style="margin:0; padding:0; background-color:#f4f4f4; width:100%;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f4f4f4;">
<tr>
<td align="center" style="padding:40px 10px;">
<!--[if mso]>
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width:600px;">
<tr><td>
<![endif]-->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width:600px; background:#ffffff; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08);">
<Header />
<tr>
<td style="padding:40px 40px 16px 40px; text-align:center;">
<h1 style="margin:0; color:#333333; font-size:26px; font-weight:700;">
New Confirmation Link
</h1>
</td>
</tr>
<tr>
<td style="padding:0 40px 20px 40px; text-align:center;">
<p style="margin:0; color:#666666; font-size:16px; line-height:24px;">
Hi <strong style="color:#333333;">@Username</strong>, you requested another email confirmation
link.
Use the button below to verify your account.
</p>
</td>
</tr>
<tr>
<td style="padding:8px 40px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:260px;"
arcsize="10%" stroke="f" fillcolor="#f59e0b">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
Confirm Email Again
</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
style="display:inline-block; padding:16px 40px; background:#d97706; color:#ffffff; text-decoration:none; border-radius:6px; font-size:16px; font-weight:700;">
Confirm Email Again
</a>
<!--<![endif]-->
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 40px 8px 40px; text-align:center;">
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
This replacement link expires in 24 hours.
</p>
</td>
</tr>
<tr>
<td style="padding:0 40px 28px 40px; text-align:center;">
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
If you did not request this, you can safely ignore this email.
</p>
</td>
</tr>
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</table>
</body>
</html>
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
[Parameter]
public string ConfirmationLink { get; set; } = string.Empty;
}

View File

@@ -30,6 +30,23 @@ public class EmailTemplateProvider(
return await RenderComponentAsync<UserRegistration>(parameters); return await RenderComponentAsync<UserRegistration>(parameters);
} }
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// </summary>
public async Task<string> RenderResendConfirmationEmailAsync(
string username,
string confirmationLink
)
{
var parameters = new Dictionary<string, object?>
{
{ nameof(ResendConfirmation.Username), username },
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
};
return await RenderComponentAsync<ResendConfirmation>(parameters);
}
/// <summary> /// <summary>
/// Generic method to render any Razor component to HTML. /// Generic method to render any Razor component to HTML.
/// </summary> /// </summary>

View File

@@ -15,4 +15,15 @@ public interface IEmailTemplateProvider
string username, string username,
string confirmationLink string confirmationLink
); );
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// </summary>
/// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The new confirmation link</param>
/// <returns>The rendered HTML string</returns>
Task<string> RenderResendConfirmationEmailAsync(
string username,
string confirmationLink
);
} }

View File

@@ -159,7 +159,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await GetUserByIdAsync(userAccountId); return await GetUserByIdAsync(userAccountId);
} }
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId) public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();

View File

@@ -75,4 +75,11 @@ public interface IAuthRepository
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId); Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary>
/// Checks whether a user account has been verified.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
} }

View File

@@ -5,151 +5,155 @@ using Domain.Exceptions;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Moq; using Moq;
using Service.Emails;
namespace Service.Auth.Tests; namespace Service.Auth.Tests;
public class ConfirmationServiceTest public class ConfirmationServiceTest
{ {
private readonly Mock<IAuthRepository> _authRepositoryMock; private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly ConfirmationService _confirmationService; private readonly Mock<IEmailService> _emailServiceMock;
private readonly ConfirmationService _confirmationService;
public ConfirmationServiceTest() public ConfirmationServiceTest()
{ {
_authRepositoryMock = new Mock<IAuthRepository>(); _authRepositoryMock = new Mock<IAuthRepository>();
_tokenServiceMock = new Mock<ITokenService>(); _tokenServiceMock = new Mock<ITokenService>();
_emailServiceMock = new Mock<IEmailService>();
_confirmationService = new ConfirmationService( _confirmationService = new ConfirmationService(
_authRepositoryMock.Object, _authRepositoryMock.Object,
_tokenServiceMock.Object _tokenServiceMock.Object,
); _emailServiceMock.Object
} );
}
[Fact] [Fact]
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser() public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string confirmationToken = "valid-confirmation-token"; const string confirmationToken = "valid-confirmation-token";
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity); var principal = new ClaimsPrincipal(claimsIdentity);
var validatedToken = new ValidatedToken(userId, username, principal); var validatedToken = new ValidatedToken(userId, username, principal);
var userAccount = new UserAccount var userAccount = new UserAccount
{ {
UserAccountId = userId, UserAccountId = userId,
Username = username, Username = username,
FirstName = "Test", FirstName = "Test",
LastName = "User", LastName = "User",
Email = "test@example.com", Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1), DateOfBirth = new DateTime(1990, 1, 1),
}; };
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken); .ReturnsAsync(validatedToken);
_authRepositoryMock _authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId)) .Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync(userAccount); .ReturnsAsync(userAccount);
// Act // Act
var result = var result =
await _confirmationService.ConfirmUserAsync(confirmationToken); await _confirmationService.ConfirmUserAsync(confirmationToken);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserId.Should().Be(userId); result.UserId.Should().Be(userId);
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
_tokenServiceMock.Verify( _tokenServiceMock.Verify(
x => x.ValidateConfirmationTokenAsync(confirmationToken), x => x.ValidateConfirmationTokenAsync(confirmationToken),
Times.Once Times.Once
); );
_authRepositoryMock.Verify( _authRepositoryMock.Verify(
x => x.ConfirmUserAccountAsync(userId), x => x.ConfirmUserAccountAsync(userId),
Times.Once Times.Once
); );
} }
[Fact] [Fact]
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException() public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string invalidToken = "invalid-confirmation-token"; const string invalidToken = "invalid-confirmation-token";
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken)) .Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
.ThrowsAsync(new UnauthorizedException( .ThrowsAsync(new UnauthorizedException(
"Invalid confirmation token" "Invalid confirmation token"
)); ));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(invalidToken) await _confirmationService.ConfirmUserAsync(invalidToken)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException() public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string expiredToken = "expired-confirmation-token"; const string expiredToken = "expired-confirmation-token";
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken)) .Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
.ThrowsAsync(new UnauthorizedException( .ThrowsAsync(new UnauthorizedException(
"Confirmation token has expired" "Confirmation token has expired"
)); ));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(expiredToken) await _confirmationService.ConfirmUserAsync(expiredToken)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException() public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
const string username = "nonexistent"; const string username = "nonexistent";
const string confirmationToken = "valid-token-for-nonexistent-user"; const string confirmationToken = "valid-token-for-nonexistent-user";
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity); var principal = new ClaimsPrincipal(claimsIdentity);
var validatedToken = new ValidatedToken(userId, username, principal); var validatedToken = new ValidatedToken(userId, username, principal);
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken); .ReturnsAsync(validatedToken);
_authRepositoryMock _authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId)) .Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((UserAccount?)null);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(confirmationToken) await _confirmationService.ConfirmUserAsync(confirmationToken)
).Should().ThrowAsync<UnauthorizedException>() ).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*"); .WithMessage("*User account not found*");
} }
} }

View File

@@ -1,34 +1,53 @@
using Domain.Exceptions; using Domain.Exceptions;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Service.Emails;
namespace Service.Auth; namespace Service.Auth;
public class ConfirmationService( public class ConfirmationService(
IAuthRepository authRepository, IAuthRepository authRepository,
ITokenService tokenService ITokenService tokenService,
IEmailService emailService
) : IConfirmationService ) : IConfirmationService
{ {
public async Task<ConfirmationServiceReturn> ConfirmUserAsync( public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
string confirmationToken string confirmationToken
) )
{ {
var validatedToken = await tokenService.ValidateConfirmationTokenAsync( var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
confirmationToken confirmationToken
); );
var user = await authRepository.ConfirmUserAccountAsync( var user = await authRepository.ConfirmUserAccountAsync(
validatedToken.UserId validatedToken.UserId
); );
if (user == null) if (user == null)
{ {
throw new UnauthorizedException("User account not found"); throw new UnauthorizedException("User account not found");
} }
return new ConfirmationServiceReturn( return new ConfirmationServiceReturn(
DateTime.UtcNow, DateTime.UtcNow,
user.UserAccountId 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);
}
} }

View File

@@ -8,4 +8,6 @@ public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
public interface IConfirmationService public interface IConfirmationService
{ {
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken); Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
Task ResendConfirmationEmailAsync(Guid userId);
} }

View File

@@ -10,6 +10,11 @@ public interface IEmailService
UserAccount createdUser, UserAccount createdUser,
string confirmationToken string confirmationToken
); );
public Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
);
} }
public class EmailService( public class EmailService(
@@ -42,4 +47,26 @@ public class EmailService(
isHtml: true 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
);
}
} }