From 3a93b01350b8af03f12ad6da51ef155ab76ff069 Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Thu, 16 Jul 2026 10:54:22 -0700 Subject: [PATCH 1/2] add v2 SecurityTasksNotification templates --- .../SecurityTasksNotificationV2.html.hbs | 33 +++++++++++++++++++ .../SecurityTasksNotificationV2.text.hbs | 18 ++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/Core/MailTemplates/Handlebars/SecurityTasksNotificationV2.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/SecurityTasksNotificationV2.text.hbs diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotificationV2.html.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotificationV2.html.hbs new file mode 100644 index 000000000000..157815c16028 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotificationV2.html.hbs @@ -0,0 +1,33 @@ +{{#>SecurityTasksHtmlLayout}} + + + + + + + +
+ Keep you and your organization safe by changing passwords that are weak, reused, or exposed in a data breach. +
+ Launch the Bitwarden extension to review your at-risk passwords. +
+ + + + +
+ + Review at-risk passwords + +
+ + + + +
+ {{formatAdminOwnerEmails AdminOwnerEmails}} +
+{{/SecurityTasksHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotificationV2.text.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotificationV2.text.hbs new file mode 100644 index 000000000000..cdcab8cf415f --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotificationV2.text.hbs @@ -0,0 +1,18 @@ +{{#>SecurityTasksHtmlLayout}} +Keep you and your organization safe by changing passwords that are weak, reused, or exposed in a data breach. + +Launch the Bitwarden extension to review your at-risk passwords. + +Review at-risk passwords ({{{ReviewPasswordsUrl}}}) + +{{#if AdminOwnerEmails.[0]}} + {{#if AdminOwnerEmails.[1]}} + This request was initiated by + {{#each AdminOwnerEmails}} + {{#if @last}}and {{/if}}{{this}}{{#unless @last}}, {{/unless}} + {{/each}}. + {{else}} + This request was initiated by {{AdminOwnerEmails.[0]}}. + {{/if}} +{{/if}} +{{/SecurityTasksHtmlLayout}} From b2217dc42c434c1e348d3330c366185f14aeea80 Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Thu, 16 Jul 2026 10:56:06 -0700 Subject: [PATCH 2/2] add vfo1 feature flag gate for v2 templates --- .../Platform/Mail/HandlebarsMailService.cs | 6 +- src/Core/Platform/Mail/IMailService.cs | 2 +- src/Core/Platform/Mail/NoopMailService.cs | 2 +- .../CreateManyTaskNotificationsCommand.cs | 10 ++- .../CreateManyTaskNotificationsCommandTest.cs | 75 +++++++++++++++++++ 5 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 test/Core.Test/Vault/Commands/CreateManyTaskNotificationsCommandTest.cs diff --git a/src/Core/Platform/Mail/HandlebarsMailService.cs b/src/Core/Platform/Mail/HandlebarsMailService.cs index 903379fa74a2..c933ff52a813 100644 --- a/src/Core/Platform/Mail/HandlebarsMailService.cs +++ b/src/Core/Platform/Mail/HandlebarsMailService.cs @@ -1617,8 +1617,10 @@ public async Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable< await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails) + public async Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails, bool useV2Template = false) { + var templateName = useV2Template ? "SecurityTasksNotificationV2" : "SecurityTasksNotification"; + MailQueueMessage CreateMessage(UserSecurityTasksCount notification) { var sanitizedOrgName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false); @@ -1631,7 +1633,7 @@ MailQueueMessage CreateMessage(UserSecurityTasksCount notification) WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, }; message.Category = "SecurityTasksNotification"; - return new MailQueueMessage(message, "SecurityTasksNotification", model); + return new MailQueueMessage(message, templateName, model); } var messageModels = securityTaskNotifications.Select(CreateMessage); await EnqueueMailAsync(messageModels.ToList()); diff --git a/src/Core/Platform/Mail/IMailService.cs b/src/Core/Platform/Mail/IMailService.cs index 6c91943e59fe..48a70b05a0c3 100644 --- a/src/Core/Platform/Mail/IMailService.cs +++ b/src/Core/Platform/Mail/IMailService.cs @@ -144,5 +144,5 @@ Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string #nullable enable Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList); Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string? userName); - Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails); + Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails, bool useV2Template = false); } diff --git a/src/Core/Platform/Mail/NoopMailService.cs b/src/Core/Platform/Mail/NoopMailService.cs index ef8b5f9c78f8..408c0cc08a82 100644 --- a/src/Core/Platform/Mail/NoopMailService.cs +++ b/src/Core/Platform/Mail/NoopMailService.cs @@ -354,7 +354,7 @@ public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails) + public Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails, bool useV2Template = false) { return Task.FromResult(0); } diff --git a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs index fbd957afae88..ef11b1d5f895 100644 --- a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs +++ b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core; using Bit.Core.Enums; using Bit.Core.NotificationCenter.Commands.Interfaces; using Bit.Core.NotificationCenter.Entities; @@ -21,6 +22,7 @@ public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCo private readonly ICreateNotificationCommand _createNotificationCommand; private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IFeatureService _featureService; public CreateManyTaskNotificationsCommand( IGetSecurityTasksNotificationDetailsQuery getSecurityTasksNotificationDetailsQuery, @@ -28,7 +30,8 @@ public CreateManyTaskNotificationsCommand( IMailService mailService, ICreateNotificationCommand createNotificationCommand, IPushNotificationService pushNotificationService, - IOrganizationUserRepository organizationUserRepository) + IOrganizationUserRepository organizationUserRepository, + IFeatureService featureService) { _getSecurityTasksNotificationDetailsQuery = getSecurityTasksNotificationDetailsQuery; _organizationRepository = organizationRepository; @@ -36,6 +39,7 @@ public CreateManyTaskNotificationsCommand( _createNotificationCommand = createNotificationCommand; _pushNotificationService = pushNotificationService; _organizationUserRepository = organizationUserRepository; + _featureService = featureService; } public async Task CreateAsync(Guid orgId, IEnumerable securityTasks) @@ -62,7 +66,9 @@ public async Task CreateAsync(Guid orgId, IEnumerable securityTask // Ensure proper deserialization of emails var orgAdminAndOwnerEmails = orgAdminEmails.Concat(orgOwnerEmails).Distinct().ToList(); - await _mailService.SendBulkSecurityTaskNotificationsAsync(organization, userTaskCount, orgAdminAndOwnerEmails); + var useV2Template = _featureService.IsEnabled(FeatureFlagKeys.VFO1Foundation); + + await _mailService.SendBulkSecurityTaskNotificationsAsync(organization, userTaskCount, orgAdminAndOwnerEmails, useV2Template); // Break securityTaskCiphers into separate lists by user Id var securityTaskCiphersByUser = securityTaskCiphers.GroupBy(x => x.UserId) diff --git a/test/Core.Test/Vault/Commands/CreateManyTaskNotificationsCommandTest.cs b/test/Core.Test/Vault/Commands/CreateManyTaskNotificationsCommandTest.cs new file mode 100644 index 000000000000..b8d40408391b --- /dev/null +++ b/test/Core.Test/Vault/Commands/CreateManyTaskNotificationsCommandTest.cs @@ -0,0 +1,75 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Vault.Commands; + +[SutProviderCustomize] +public class CreateManyTaskNotificationsCommandTest +{ + [Theory] + [BitAutoData] + public async Task CreateAsync_VFO1FoundationDisabled_UsesV1Template( + SutProvider sutProvider, + Organization organization, + ICollection securityTasks, + UserSecurityTaskCipher userSecurityTaskCipher) + { + userSecurityTaskCipher.UserId = Guid.NewGuid(); + Setup(sutProvider, organization, userSecurityTaskCipher, vfo1FoundationEnabled: false); + + await sutProvider.Sut.CreateAsync(organization.Id, securityTasks); + + await sutProvider.GetDependency().Received(1) + .SendBulkSecurityTaskNotificationsAsync( + organization, + Arg.Any>(), + Arg.Any>(), + false); + } + + [Theory] + [BitAutoData] + public async Task CreateAsync_VFO1FoundationEnabled_UsesV2Template( + SutProvider sutProvider, + Organization organization, + ICollection securityTasks, + UserSecurityTaskCipher userSecurityTaskCipher) + { + userSecurityTaskCipher.UserId = Guid.NewGuid(); + Setup(sutProvider, organization, userSecurityTaskCipher, vfo1FoundationEnabled: true); + + await sutProvider.Sut.CreateAsync(organization.Id, securityTasks); + + await sutProvider.GetDependency().Received(1) + .SendBulkSecurityTaskNotificationsAsync( + organization, + Arg.Any>(), + Arg.Any>(), + true); + } + + private static void Setup( + SutProvider sutProvider, + Organization organization, + UserSecurityTaskCipher userSecurityTaskCipher, + bool vfo1FoundationEnabled) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.VFO1Foundation) + .Returns(vfo1FoundationEnabled); + sutProvider.GetDependency() + .GetNotificationDetailsByManyIds(organization.Id, Arg.Any>()) + .Returns(new List { userSecurityTaskCipher }); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + } +}