Skip to content

Commit e68ec7f

Browse files
authored
Make IEmailSender more customizable (#50301)
* Make IEmailSender more customizable * Remove unnecessary metadata * Add TUser parameter * React to API review feedback * Fix IdentitySample.DefaultUI
1 parent 263ee71 commit e68ec7f

26 files changed

+165
-103
lines changed

src/Identity/Extensions.Core/src/IEmailSender.cs renamed to src/Identity/Core/src/IEmailSender.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Threading.Tasks;
5-
64
namespace Microsoft.AspNetCore.Identity.UI.Services;
75

86
/// <summary>
97
/// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose
10-
/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation emails.
8+
/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation and password reset emails.
119
/// </summary>
1210
public interface IEmailSender
1311
{
1412
/// <summary>
1513
/// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose
16-
/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation emails.
14+
/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation and apassword reset emails.
1715
/// </summary>
1816
/// <param name="email">The recipient's email address.</param>
1917
/// <param name="subject">The subject of the email.</param>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Identity;
5+
6+
/// <summary>
7+
/// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose
8+
/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation and password reset emails.
9+
/// </summary>
10+
public interface IEmailSender<TUser> where TUser : class
11+
{
12+
/// <summary>
13+
/// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose
14+
/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation emails.
15+
/// </summary>
16+
/// <param name="user">The user that is attempting to confirm their email.</param>
17+
/// <param name="email">The recipient's email address.</param>
18+
/// <param name="confirmationLink">The link to follow to confirm a user's email. Do not double encode this.</param>
19+
/// <returns></returns>
20+
Task SendConfirmationLinkAsync(TUser user, string email, string confirmationLink);
21+
22+
/// <summary>
23+
/// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose
24+
/// email abstraction. It should be implemented by the application so the Identity infrastructure can send password reset emails.
25+
/// </summary>
26+
/// <param name="user">The user that is attempting to reset their password.</param>
27+
/// <param name="email">The recipient's email address.</param>
28+
/// <param name="resetLink">The link to follow to reset the user password. Do not double encode this.</param>
29+
/// <returns></returns>
30+
Task SendPasswordResetLinkAsync(TUser user, string email, string resetLink);
31+
32+
/// <summary>
33+
/// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose
34+
/// email abstraction. It should be implemented by the application so the Identity infrastructure can send password reset emails.
35+
/// </summary>
36+
/// <param name="user">The user that is attempting to reset their password.</param>
37+
/// <param name="email">The recipient's email address.</param>
38+
/// <param name="resetCode">The code to use to reset the user password. Do not double encode this.</param>
39+
/// <returns></returns>
40+
Task SendPasswordResetCodeAsync(TUser user, string email, string resetCode);
41+
}

src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
using Microsoft.AspNetCore.Http.Metadata;
1515
using Microsoft.AspNetCore.Identity;
1616
using Microsoft.AspNetCore.Identity.Data;
17-
using Microsoft.AspNetCore.Identity.UI.Services;
1817
using Microsoft.AspNetCore.WebUtilities;
1918
using Microsoft.Extensions.DependencyInjection;
2019
using Microsoft.Extensions.Options;
@@ -45,7 +44,7 @@ public static IEndpointConventionBuilder MapIdentityApi<TUser>(this IEndpointRou
4544

4645
var timeProvider = endpoints.ServiceProvider.GetRequiredService<TimeProvider>();
4746
var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService<IOptionsMonitor<BearerTokenOptions>>();
48-
var emailSender = endpoints.ServiceProvider.GetRequiredService<IEmailSender>();
47+
var emailSender = endpoints.ServiceProvider.GetRequiredService<IEmailSender<TUser>>();
4948
var linkGenerator = endpoints.ServiceProvider.GetRequiredService<LinkGenerator>();
5049

5150
// We'll figure out a unique endpoint name based on the final route pattern during endpoint generation.
@@ -189,7 +188,6 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T
189188
var finalPattern = ((RouteEndpointBuilder)endpointBuilder).RoutePattern.RawText;
190189
confirmEmailEndpointName = $"{nameof(MapIdentityApi)}-{finalPattern}";
191190
endpointBuilder.Metadata.Add(new EndpointNameMetadata(confirmEmailEndpointName));
192-
endpointBuilder.Metadata.Add(new RouteNameMetadata(confirmEmailEndpointName));
193191
});
194192

195193
routeGroup.MapPost("/resendConfirmationEmail", async Task<Ok>
@@ -216,8 +214,7 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T
216214
var code = await userManager.GeneratePasswordResetTokenAsync(user);
217215
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
218216

219-
await emailSender.SendEmailAsync(resetRequest.Email, "Reset your password",
220-
$"Reset your password using the following code: {HtmlEncoder.Default.Encode(code)}");
217+
await emailSender.SendPasswordResetCodeAsync(user, resetRequest.Email, HtmlEncoder.Default.Encode(code));
221218
}
222219

223220
// Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have
@@ -416,8 +413,7 @@ async Task SendConfirmationEmailAsync(TUser user, UserManager<TUser> userManager
416413
var confirmEmailUrl = linkGenerator.GetUriByName(context, confirmEmailEndpointName, routeValues)
417414
?? throw new NotSupportedException($"Could not find endpoint named '{confirmEmailEndpointName}'.");
418415

419-
await emailSender.SendEmailAsync(email, "Confirm your email",
420-
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(confirmEmailUrl)}'>clicking here</a>.");
416+
await emailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(confirmEmailUrl));
421417
}
422418

423419
return new IdentityEndpointsConventionBuilder(routeGroup);

src/Identity/Core/src/IdentityBuilderExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public static IdentityBuilder AddApiEndpoints(this IdentityBuilder builder)
9797

9898
builder.AddSignInManager();
9999
builder.AddDefaultTokenProviders();
100+
builder.Services.TryAddTransient(typeof(IEmailSender<>), typeof(DefaultMessageEmailSender<>));
100101
builder.Services.TryAddTransient<IEmailSender, NoOpEmailSender>();
101102
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<JsonOptions>, IdentityEndpointsJsonOptionsSetup>());
102103
return builder;

src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
</PropertyGroup>
1414

1515
<ItemGroup>
16-
<Compile Include="$(SharedSourceRoot)BearerToken\DTO\*.cs" LinkBase="DTO" />
16+
<Compile Include="$(SharedSourceRoot)DefaultMessageEmailSender.cs" />
1717
</ItemGroup>
1818

1919
<ItemGroup>

src/Identity/Extensions.Core/src/NoOpEmailSender.cs renamed to src/Identity/Core/src/NoOpEmailSender.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Threading.Tasks;
5-
64
namespace Microsoft.AspNetCore.Identity.UI.Services;
75

86
/// <summary>

src/Identity/Core/src/PublicAPI.Unshipped.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,22 @@ Microsoft.AspNetCore.Identity.Data.TwoFactorResponse.RecoveryCodesLeft.init -> v
7575
Microsoft.AspNetCore.Identity.Data.TwoFactorResponse.SharedKey.get -> string!
7676
Microsoft.AspNetCore.Identity.Data.TwoFactorResponse.SharedKey.init -> void
7777
Microsoft.AspNetCore.Identity.Data.TwoFactorResponse.TwoFactorResponse() -> void
78+
Microsoft.AspNetCore.Identity.IEmailSender<TUser>
79+
Microsoft.AspNetCore.Identity.IEmailSender<TUser>.SendConfirmationLinkAsync(TUser! user, string! email, string! confirmationLink) -> System.Threading.Tasks.Task!
80+
Microsoft.AspNetCore.Identity.IEmailSender<TUser>.SendPasswordResetCodeAsync(TUser! user, string! email, string! resetCode) -> System.Threading.Tasks.Task!
81+
Microsoft.AspNetCore.Identity.IEmailSender<TUser>.SendPasswordResetLinkAsync(TUser! user, string! email, string! resetLink) -> System.Threading.Tasks.Task!
7882
Microsoft.AspNetCore.Identity.SecurityStampValidator<TUser>.SecurityStampValidator(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Identity.SecurityStampValidatorOptions!>! options, Microsoft.AspNetCore.Identity.SignInManager<TUser!>! signInManager, Microsoft.Extensions.Logging.ILoggerFactory! logger) -> void
7983
Microsoft.AspNetCore.Identity.SecurityStampValidator<TUser>.TimeProvider.get -> System.TimeProvider!
8084
Microsoft.AspNetCore.Identity.SecurityStampValidatorOptions.TimeProvider.get -> System.TimeProvider?
8185
Microsoft.AspNetCore.Identity.SecurityStampValidatorOptions.TimeProvider.set -> void
8286
Microsoft.AspNetCore.Identity.SignInManager<TUser>.AuthenticationScheme.get -> string!
8387
Microsoft.AspNetCore.Identity.SignInManager<TUser>.AuthenticationScheme.set -> void
8488
Microsoft.AspNetCore.Identity.TwoFactorSecurityStampValidator<TUser>.TwoFactorSecurityStampValidator(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Identity.SecurityStampValidatorOptions!>! options, Microsoft.AspNetCore.Identity.SignInManager<TUser!>! signInManager, Microsoft.Extensions.Logging.ILoggerFactory! logger) -> void
89+
Microsoft.AspNetCore.Identity.UI.Services.IEmailSender
90+
Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task!
91+
Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender
92+
Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.NoOpEmailSender() -> void
93+
Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task!
8594
Microsoft.AspNetCore.Routing.IdentityApiEndpointRouteBuilderExtensions
8695
static Microsoft.AspNetCore.Identity.IdentityBuilderExtensions.AddApiEndpoints(this Microsoft.AspNetCore.Identity.IdentityBuilder! builder) -> Microsoft.AspNetCore.Identity.IdentityBuilder!
8796
static Microsoft.AspNetCore.Routing.IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi<TUser>(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!

src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@
22
Microsoft.AspNetCore.Identity.IdentitySchemaVersions
33
Microsoft.AspNetCore.Identity.StoreOptions.SchemaVersion.get -> System.Version!
44
Microsoft.AspNetCore.Identity.StoreOptions.SchemaVersion.set -> void
5-
Microsoft.AspNetCore.Identity.UI.Services.IEmailSender
6-
Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task!
7-
Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender
8-
Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.NoOpEmailSender() -> void
9-
Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task!
105
static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Default -> System.Version!
116
static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version1 -> System.Version!
127
static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version2 -> System.Version!

src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ExternalLogin.cshtml.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using System.Text;
88
using System.Text.Encodings.Web;
99
using Microsoft.AspNetCore.Authorization;
10-
using Microsoft.AspNetCore.Identity.UI.Services;
1110
using Microsoft.AspNetCore.Mvc;
1211
using Microsoft.AspNetCore.Mvc.RazorPages;
1312
using Microsoft.AspNetCore.WebUtilities;
@@ -95,15 +94,15 @@ internal sealed class ExternalLoginModel<TUser> : ExternalLoginModel where TUser
9594
private readonly UserManager<TUser> _userManager;
9695
private readonly IUserStore<TUser> _userStore;
9796
private readonly IUserEmailStore<TUser> _emailStore;
98-
private readonly IEmailSender _emailSender;
97+
private readonly IEmailSender<TUser> _emailSender;
9998
private readonly ILogger<ExternalLoginModel> _logger;
10099

101100
public ExternalLoginModel(
102101
SignInManager<TUser> signInManager,
103102
UserManager<TUser> userManager,
104103
IUserStore<TUser> userStore,
105104
ILogger<ExternalLoginModel> logger,
106-
IEmailSender emailSender)
105+
IEmailSender<TUser> emailSender)
107106
{
108107
_signInManager = signInManager;
109108
_userManager = userManager;
@@ -206,8 +205,7 @@ public override async Task<IActionResult> OnPostConfirmationAsync(string? return
206205
values: new { area = "Identity", userId = userId, code = code },
207206
protocol: Request.Scheme)!;
208207

209-
await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
210-
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
208+
await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
211209

212210
// If account confirmation is required, we need to show the link if we don't have a real email sender
213211
if (_userManager.Options.SignIn.RequireConfirmedAccount)

src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ForgotPassword.cshtml.cs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using System.Text;
66
using System.Text.Encodings.Web;
77
using Microsoft.AspNetCore.Authorization;
8-
using Microsoft.AspNetCore.Identity.UI.Services;
98
using Microsoft.AspNetCore.Mvc;
109
using Microsoft.AspNetCore.Mvc.RazorPages;
1110
using Microsoft.AspNetCore.WebUtilities;
@@ -52,9 +51,9 @@ public class InputModel
5251
internal sealed class ForgotPasswordModel<TUser> : ForgotPasswordModel where TUser : class
5352
{
5453
private readonly UserManager<TUser> _userManager;
55-
private readonly IEmailSender _emailSender;
54+
private readonly IEmailSender<TUser> _emailSender;
5655

57-
public ForgotPasswordModel(UserManager<TUser> userManager, IEmailSender emailSender)
56+
public ForgotPasswordModel(UserManager<TUser> userManager, IEmailSender<TUser> emailSender)
5857
{
5958
_userManager = userManager;
6059
_emailSender = emailSender;
@@ -81,10 +80,7 @@ public override async Task<IActionResult> OnPostAsync()
8180
values: new { area = "Identity", code },
8281
protocol: Request.Scheme)!;
8382

84-
await _emailSender.SendEmailAsync(
85-
Input.Email,
86-
"Reset Password",
87-
$"Please reset your password by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
83+
await _emailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
8884

8985
return RedirectToPage("./ForgotPasswordConfirmation");
9086
}

0 commit comments

Comments
 (0)