diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.cs new file mode 100644 index 000000000000..71043d4a3773 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.cs @@ -0,0 +1,13 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; + +public class MemberEmailChangedNotificationView : BaseMailView +{ + public required string NewEmail { get; set; } +} + +public class MemberEmailChangedNotificationMail : BaseMail +{ + public override string Subject { get; set; } = "Your Bitwarden account email was updated"; +} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs new file mode 100644 index 000000000000..494efb374d1f --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs @@ -0,0 +1,51 @@ +

Your Bitwarden account email was updated by an admin

Use the new email to log in to your account.
Your new email
{{NewEmail}}
If this change doesn't seem right, contact your admin.

Learn more about Bitwarden

Find user guides, product documentation, and videos on the Bitwarden Help Center.

© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
bitwarden.com | Learn why we include this

\ No newline at end of file diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.text.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.text.hbs new file mode 100644 index 000000000000..acf2c2c15f9e --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.text.hbs @@ -0,0 +1,7 @@ +Your Bitwarden account email was updated by an admin. + +Use the new email to log in to your account. + +Your new email: {{NewEmail}} + +If this change doesn't seem right, contact your admin. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs index cc43ad3518fc..a14c6614c7c1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; +using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -9,10 +10,12 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Microsoft.Extensions.Logging; using OneOf.Types; using CommandError = Bit.Core.AdminConsole.Utilities.v2.Error; @@ -31,7 +34,9 @@ public class UpdateOrganizationUserCommand( IUserRepository userRepository, IChangeEmailCommand changeEmailCommand, IPushNotificationService pushNotificationService, - TimeProvider timeProvider) + IMailer mailer, + TimeProvider timeProvider, + ILogger logger) : IUpdateOrganizationUserCommand { public async Task UpdateUserAsync(UpdateOrganizationUserRequest request) @@ -107,8 +112,12 @@ await collectionRepository.CreateDefaultCollectionsAsync( if (request.IsEmailChanged()) { + var previousEmail = request.UserToUpdate.Email; + // ChangeEmailAsync persists the account (including any name change above) and syncs Stripe. await changeEmailCommand.ChangeEmailAsync(request.UserToUpdate, request.NewEmail!); + + await TrySendEmailChangedNotificationAsync(previousEmail, request); } else { @@ -135,6 +144,22 @@ await collectionRepository.CreateDefaultCollectionsAsync( _ => new EmailChangeFailedError(ex.Message) }; + private async Task TrySendEmailChangedNotificationAsync(string previousEmail, UpdateOrganizationUserRequest request) + { + try + { + await mailer.SendEmail(new MemberEmailChangedNotificationMail + { + ToEmails = [previousEmail], + View = new MemberEmailChangedNotificationView { NewEmail = request.NewEmail! } + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send member email-change notification for organization user {OrganizationUserId}.", request.OrganizationUserToUpdate.Id); + } + } + private async Task TryEnablingSecretsManagerAsync(UpdateOrganizationUserRequest request) { var organization = request.Organization; diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml new file mode 100644 index 000000000000..1090ecefb7f5 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + Use the new email to log in to your account. + + + Your new email
+ {{NewEmail}} +
+ + If this change doesn't seem right, contact your admin. + +
+
+
+ + + + + + + + +
+
diff --git a/test/Core.Test/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationViewTests.cs b/test/Core.Test/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationViewTests.cs new file mode 100644 index 000000000000..44d16048298c --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationViewTests.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; + +public class MemberEmailChangedNotificationViewTests +{ + [Fact] + public async Task RenderAsync_PopulatesNewEmailInBothTemplates() + { + var renderer = new HandlebarMailRenderer( + Substitute.For>(), + new GlobalSettings()); + + var (html, text) = await renderer.RenderAsync( + new MemberEmailChangedNotificationView { NewEmail = "new@acme.com" }); + + Assert.Contains("new@acme.com", html); + Assert.Contains("new@acme.com", text); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs index 1998873cb4d1..61a527ff6c73 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2.Validation; @@ -8,6 +9,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -56,6 +58,31 @@ await sutProvider.GetDependency() .ReplaceAsync(organizationUser, Arg.Any>()); } + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenEmailChanged_NotifiesMemberAtPreviousEmail( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "old@claimed.example.com" }; + var request = Setup(sutProvider, organization, organizationUser, newEmail: "new@claimed.example.com"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains("old@claimed.example.com") + && mail.View.NewEmail == "new@claimed.example.com")); + } + [Theory] [BitAutoData] public async Task UpdateUserAsync_WhenNoEmailRequested_DoesNotLoadUserOrChangeEmail( @@ -103,6 +130,9 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .PushSyncSettingsAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmail(default); } [Theory] @@ -146,6 +176,9 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .PushSyncSettingsAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmail(default); } [Theory] @@ -177,6 +210,9 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .ChangeEmailAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmail(default); } [Theory] @@ -279,6 +315,11 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received(1) .PushSyncSettingsAsync(userToUpdate.Id); + await sutProvider.GetDependency() + .Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains("old@claimed.example.com") + && mail.View.NewEmail == "new@claimed.example.com")); } [Theory]