From 63784e1f5fc0c65e019de868dce35dc7e9a41d3d Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 6 Jan 2026 16:43:36 -0500 Subject: [PATCH] [PM-27882] Add SendOrganizationConfirmationCommand (#6743) --- .../OrganizationConfirmationBaseView.cs | 12 + ...nizationConfirmationEnterpriseTeamsView.cs | 12 + ...nConfirmationEnterpriseTeamsView.html.hbs} | 2 +- ...nConfirmationEnterpriseTeamsView.text.hbs} | 0 .../OrganizationConfirmationFamilyFreeView.cs | 12 + ...zationConfirmationFamilyFreeView.html.hbs} | 2 +- ...zationConfirmationFamilyFreeView.text.hbs} | 0 ...maticallyConfirmOrganizationUserCommand.cs | 29 ++- .../ConfirmOrganizationUserCommand.cs | 29 ++- .../ISendOrganizationConfirmationCommand.cs | 22 ++ .../SendOrganizationConfirmationCommand.cs | 110 ++++++++ src/Core/Constants.cs | 1 + ...ization-confirmation-enterprise-teams.mjml | 2 +- ...organization-confirmation-family-free.mjml | 2 +- .../Families2019RenewalMailView.cs | 2 +- .../Families2020RenewalMailView.cs | 2 +- .../Renewal/Premium/PremiumRenewalMailView.cs | 2 +- ...OrganizationServiceCollectionExtensions.cs | 3 +- src/Core/Platform/Mail/Mailer/BaseMail.cs | 2 +- .../Mail/Mailer/HandlebarMailRenderer.cs | 16 +- .../AutomaticallyConfirmUsersCommandTests.cs | 51 ++++ .../ConfirmOrganizationUserCommandTests.cs | 49 ++++ ...endOrganizationConfirmationCommandTests.cs | 245 ++++++++++++++++++ .../Platform/Mailer/TestMail/TestMailView.cs | 2 +- 24 files changed, 589 insertions(+), 20 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs create mode 100644 src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs rename src/Core/{MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs => AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs} (99%) rename src/Core/{MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs => AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.text.hbs} (100%) create mode 100644 src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs rename src/Core/{MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs => AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs} (99%) rename src/Core/{MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs => AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.text.hbs} (100%) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommandTests.cs diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs new file mode 100644 index 0000000000..f888496fe8 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs @@ -0,0 +1,12 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; + +public abstract class OrganizationConfirmationBaseView : BaseMailView +{ + public required string OrganizationName { get; set; } + public required string TitleFirst { get; set; } + public required string TitleSecondBold { get; set; } + public required string TitleThird { get; set; } + public required string WebVaultUrl { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs new file mode 100644 index 0000000000..6a8a0e18fa --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs @@ -0,0 +1,12 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; + +public class OrganizationConfirmationEnterpriseTeamsView : OrganizationConfirmationBaseView +{ +} + +public class OrganizationConfirmationEnterpriseTeams : BaseMail +{ + public override required string Subject { get; set; } +} diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs similarity index 99% rename from src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs rename to src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs index 29977724d4..8477efff26 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs @@ -178,7 +178,7 @@ - + Log in diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.text.hbs similarity index 100% rename from src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs rename to src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.text.hbs diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs new file mode 100644 index 0000000000..9228ec2208 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs @@ -0,0 +1,12 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; + +public class OrganizationConfirmationFamilyFreeView : OrganizationConfirmationBaseView +{ +} + +public class OrganizationConfirmationFamilyFree : BaseMail +{ + public override required string Subject { get; set; } +} diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs similarity index 99% rename from src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs rename to src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs index 93d4b9cd9c..cbe09d3e93 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs @@ -182,7 +182,7 @@ - + Log in diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.text.hbs similarity index 100% rename from src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs rename to src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.text.hbs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs index 67b5f0da80..1b488677ae 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs @@ -1,5 +1,7 @@ -using Bit.Core.AdminConsole.Models.Data.OrganizationUsers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.Entities; @@ -25,6 +27,8 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi IPushNotificationService pushNotificationService, IPolicyRequirementQuery policyRequirementQuery, ICollectionRepository collectionRepository, + IFeatureService featureService, + ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand, TimeProvider timeProvider, ILogger logger) : IAutomaticallyConfirmOrganizationUserCommand { @@ -143,9 +147,7 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi { var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value); - await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name, - user!.Email, - request.OrganizationUser.AccessSecretsManager); + await SendOrganizationConfirmedEmailAsync(request.Organization!, user!.Email, request.OrganizationUser.AccessSecretsManager); } catch (Exception ex) { @@ -183,4 +185,23 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi Organization = await organizationRepository.GetByIdAsync(request.OrganizationId) }; } + + /// + /// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service, + /// depending on the feature flag. + /// + /// The organization the user was confirmed to. + /// The email address of the confirmed user. + /// Whether the user has access to Secrets Manager. + internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager) + { + if (featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)) + { + await sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); + } + else + { + await mailService.SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager); + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 2fbe1e27f4..0b82ac7ea4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -1,8 +1,10 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -35,7 +37,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly IFeatureService _featureService; private readonly ICollectionRepository _collectionRepository; private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; - + private readonly ISendOrganizationConfirmationCommand _sendOrganizationConfirmationCommand; public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -50,7 +52,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, ICollectionRepository collectionRepository, - IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -66,8 +68,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand _featureService = featureService; _collectionRepository = collectionRepository; _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; + _sendOrganizationConfirmationCommand = sendOrganizationConfirmationCommand; } - public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null) { @@ -170,7 +172,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand orgUser.Email = null; await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); + await SendOrganizationConfirmedEmailAsync(organization, user.Email, orgUser.AccessSecretsManager); succeededUsers.Add(orgUser); result.Add(Tuple.Create(orgUser, "")); } @@ -339,4 +341,23 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); } + + /// + /// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service, + /// depending on the feature flag. + /// + /// The organization the user was confirmed to. + /// The email address of the confirmed user. + /// Whether the user has access to Secrets Manager. + internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager) + { + if (_featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)) + { + await _sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); + } + else + { + await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), userEmail, accessSecretsManager); + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs new file mode 100644 index 0000000000..ae4d7acda5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; + +public interface ISendOrganizationConfirmationCommand +{ + /// + /// Sends an organization confirmation email to the specified user. + /// + /// The organization to send the confirmation email for. + /// The email address of the user to send the confirmation to. + /// Whether the user has access to Secrets Manager. + Task SendConfirmationAsync(Organization organization, string userEmail, bool accessSecretsManager); + + /// + /// Sends organization confirmation emails to multiple users. + /// + /// The organization to send the confirmation emails for. + /// The email addresses of the users to send confirmations to. + /// Whether the users have access to Secrets Manager. + Task SendConfirmationsAsync(Organization organization, IEnumerable userEmails, bool accessSecretsManager); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs new file mode 100644 index 0000000000..392290d3ae --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs @@ -0,0 +1,110 @@ +using System.Net; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; +using Bit.Core.Billing.Enums; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; + +public class SendOrganizationConfirmationCommand(IMailer mailer, GlobalSettings globalSettings) : ISendOrganizationConfirmationCommand +{ + private const string _titleFirst = "You're confirmed as a member of "; + private const string _titleThird = "!"; + + private static string GetConfirmationSubject(string organizationName) => + $"You Have Been Confirmed To {organizationName}"; + private string GetWebVaultUrl(bool accessSecretsManager) => accessSecretsManager + ? globalSettings.BaseServiceUri.VaultWithHashAndSecretManagerProduct + : globalSettings.BaseServiceUri.VaultWithHash; + + public async Task SendConfirmationAsync(Organization organization, string userEmail, bool accessSecretsManager = false) + { + await SendConfirmationsAsync(organization, [userEmail], accessSecretsManager); + } + + public async Task SendConfirmationsAsync(Organization organization, IEnumerable userEmails, bool accessSecretsManager = false) + { + var userEmailsList = userEmails.ToList(); + + if (userEmailsList.Count == 0) + { + return; + } + + var organizationName = WebUtility.HtmlDecode(organization.Name); + + if (IsEnterpriseOrTeamsPlan(organization.PlanType)) + { + await SendEnterpriseTeamsEmailsAsync(userEmailsList, organizationName, accessSecretsManager); + return; + } + + await SendFamilyFreeConfirmEmailsAsync(userEmailsList, organizationName, accessSecretsManager); + } + + private async Task SendEnterpriseTeamsEmailsAsync(List userEmailsList, string organizationName, bool accessSecretsManager) + { + var mail = new OrganizationConfirmationEnterpriseTeams + { + ToEmails = userEmailsList, + Subject = GetConfirmationSubject(organizationName), + View = new OrganizationConfirmationEnterpriseTeamsView + { + OrganizationName = organizationName, + TitleFirst = _titleFirst, + TitleSecondBold = organizationName, + TitleThird = _titleThird, + WebVaultUrl = GetWebVaultUrl(accessSecretsManager) + } + }; + + await mailer.SendEmail(mail); + } + + private async Task SendFamilyFreeConfirmEmailsAsync(List userEmailsList, string organizationName, bool accessSecretsManager) + { + var mail = new OrganizationConfirmationFamilyFree + { + ToEmails = userEmailsList, + Subject = GetConfirmationSubject(organizationName), + View = new OrganizationConfirmationFamilyFreeView + { + OrganizationName = organizationName, + TitleFirst = _titleFirst, + TitleSecondBold = organizationName, + TitleThird = _titleThird, + WebVaultUrl = GetWebVaultUrl(accessSecretsManager) + } + }; + + await mailer.SendEmail(mail); + } + + + private static bool IsEnterpriseOrTeamsPlan(PlanType planType) + { + return planType switch + { + PlanType.TeamsMonthly2019 or + PlanType.TeamsAnnually2019 or + PlanType.TeamsMonthly2020 or + PlanType.TeamsAnnually2020 or + PlanType.TeamsMonthly2023 or + PlanType.TeamsAnnually2023 or + PlanType.TeamsStarter2023 or + PlanType.TeamsMonthly or + PlanType.TeamsAnnually or + PlanType.TeamsStarter or + PlanType.EnterpriseMonthly2019 or + PlanType.EnterpriseAnnually2019 or + PlanType.EnterpriseMonthly2020 or + PlanType.EnterpriseAnnually2020 or + PlanType.EnterpriseMonthly2023 or + PlanType.EnterpriseAnnually2023 or + PlanType.EnterpriseMonthly or + PlanType.EnterpriseAnnually => true, + _ => false + }; + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2f7f8524d7..4b13a97696 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -163,6 +163,7 @@ public static class FeatureFlagKeys "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; + public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template"; public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required"; diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml index 24f85af31c..6d3c46ae67 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml @@ -10,7 +10,7 @@ img-src="https://assets.bitwarden.com/email/v1/spot-enterprise.png" title="You can now share passwords with members of {{OrganizationName}}!" button-text="Log in" - button-url="https://vault.bitwarden.com" + button-url="{{WebVaultUrl}}" /> diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml index 2e48e82f84..2b2d854134 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml @@ -10,7 +10,7 @@ img-src="https://assets.bitwarden.com/email/v1/spot-family-homes.png" title="You can now share passwords with members of {{OrganizationName}}!" button-text="Log in" - button-url="https://vault.bitwarden.com" + button-url="{{WebVaultUrl}}" /> diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs index e3aff02f5d..832a211c8e 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs @@ -12,5 +12,5 @@ public class Families2019RenewalMailView : BaseMailView public class Families2019RenewalMail : BaseMail { - public override string Subject { get => "Your Bitwarden Families renewal is updating"; } + public override string Subject { get; set; } = "Your Bitwarden Families renewal is updating"; } diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs index eb7bef4322..e36c584bdc 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs @@ -9,5 +9,5 @@ public class Families2020RenewalMailView : BaseMailView public class Families2020RenewalMail : BaseMail { - public override string Subject { get => "Your Bitwarden Families renewal is updating"; } + public override string Subject { get; set; } = "Your Bitwarden Families renewal is updating"; } diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs index e231a44467..4006c92a63 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs @@ -11,5 +11,5 @@ public class PremiumRenewalMailView : BaseMailView public class PremiumRenewalMail : BaseMail { - public override string Subject { get => "Your Bitwarden Premium renewal is updating"; } + public override string Subject { get; set; } = "Your Bitwarden Premium renewal is updating"; } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 4d4ab23593..c1ebc65d44 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.V using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; using Bit.Core.Models.Business.Tokenables; @@ -45,7 +46,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; - using V1_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using V2_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; @@ -140,6 +140,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Platform/Mail/Mailer/BaseMail.cs b/src/Core/Platform/Mail/Mailer/BaseMail.cs index 0fd6b79aba..4983510ec8 100644 --- a/src/Core/Platform/Mail/Mailer/BaseMail.cs +++ b/src/Core/Platform/Mail/Mailer/BaseMail.cs @@ -19,7 +19,7 @@ public abstract class BaseMail where TView : BaseMailView /// /// The subject of the email. /// - public abstract string Subject { get; } + public abstract string Subject { get; set; } /// /// An optional category for processing at the upstream email delivery service. diff --git a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs index baba5b8015..8b4e0bd5df 100644 --- a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs +++ b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs @@ -124,8 +124,20 @@ public class HandlebarMailRenderer : IMailRenderer // TODO: Do we still need layouts with MJML? var assembly = typeof(HandlebarMailRenderer).Assembly; - var layoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs"); - handlebars.RegisterTemplate("FullHtmlLayout", layoutSource); + + // Register Full layouts + var fullHtmlLayoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs"); + handlebars.RegisterTemplate("FullHtmlLayout", fullHtmlLayoutSource); + + var fullTextLayoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.text.hbs"); + handlebars.RegisterTemplate("FullTextLayout", fullTextLayoutSource); + + // Register TitleContactUs layouts + var titleContactUsHtmlLayoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.TitleContactUs.html.hbs"); + handlebars.RegisterTemplate("TitleContactUsHtmlLayout", titleContactUsHtmlLayoutSource); + + var titleContactUsTextLayoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.TitleContactUs.text.hbs"); + handlebars.RegisterTemplate("TitleContactUsTextLayout", titleContactUsTextLayoutSource); return handlebars; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs index 1035d5c578..180750a9d0 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities.v2; @@ -727,4 +728,54 @@ public class AutomaticallyConfirmUsersCommandTests Arg.Any>(), organization.Id.ToString()); } + + [Theory] + [BitAutoData] + public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOn_UsesNewMailer( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + const bool accessSecretsManager = true; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) + .Returns(true); + + // Act + await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendConfirmationAsync(organization, userEmail, accessSecretsManager); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationConfirmedEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + const bool accessSecretsManager = false; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) + .Returns(false); + + // Act + await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager); + await sutProvider.GetDependency() + .DidNotReceive() + .SendConfirmationAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index 7ec26cf882..65359b8304 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -809,4 +810,52 @@ public class ConfirmOrganizationUserCommandTests Assert.Empty(result[1].Item2); Assert.Equal(new OtherOrganizationDoesNotAllowOtherMembership().Message, result[2].Item2); } + + [Theory, BitAutoData] + public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOn_UsesNewMailer( + Organization org, + string userEmail, + SutProvider sutProvider) + { + // Arrange + const bool accessSecretsManager = true; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) + .Returns(true); + + // Act + await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(org, userEmail, accessSecretsManager); + + // Assert - verify new mailer is called, not legacy mail service + await sutProvider.GetDependency() + .Received(1) + .SendConfirmationAsync(org, userEmail, accessSecretsManager); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationConfirmedEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService( + Organization org, + string userEmail, + SutProvider sutProvider) + { + // Arrange + const bool accessSecretsManager = false; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) + .Returns(false); + + // Act + await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(org, userEmail, accessSecretsManager); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationConfirmedEmailAsync(org.DisplayName(), userEmail, accessSecretsManager); + await sutProvider.GetDependency() + .DidNotReceive() + .SendConfirmationAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommandTests.cs new file mode 100644 index 0000000000..0368f99825 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommandTests.cs @@ -0,0 +1,245 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; +using Bit.Core.Billing.Enums; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; + +[SutProviderCustomize] +public class SendOrganizationConfirmationCommandTests +{ + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_EnterpriseOrganization_SendsEnterpriseTeamsEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Name = "Test Enterprise Org"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains(userEmail) && + mail.ToEmails.Count() == 1 && + mail.View.OrganizationName == organization.Name && + mail.Subject == GetSubject(organization.Name))); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_TeamsOrganization_SendsEnterpriseTeamsEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.TeamsAnnually; + organization.Name = "Test Teams Org"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains(userEmail) && + mail.ToEmails.Count() == 1 && + mail.View.OrganizationName == organization.Name && + mail.Subject == GetSubject(organization.Name))); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_FamilyOrganization_SendsFamilyFreeEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.FamiliesAnnually; + organization.Name = "Test Family Org"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains(userEmail) && + mail.ToEmails.Count() == 1 && + mail.View.OrganizationName == organization.Name && + mail.Subject == GetSubject(organization.Name))); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_FreeOrganization_SendsFamilyFreeEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.Free; + organization.Name = "Test Free Org"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains(userEmail) && + mail.ToEmails.Count() == 1 && + mail.View.OrganizationName == organization.Name && + mail.Subject == GetSubject(organization.Name))); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationsAsync_MultipleUsers_SendsSingleEmail( + Organization organization, + List userEmails, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Name = "Test Enterprise Org"; + + // Act + await sutProvider.Sut.SendConfirmationsAsync(organization, userEmails, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.SequenceEqual(userEmails) && + mail.View.OrganizationName == organization.Name)); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationsAsync_EmptyUserList_DoesNotSendEmail( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Name = "Test Enterprise Org"; + + // Act + await sutProvider.Sut.SendConfirmationsAsync(organization, [], false); + + // Assert + await sutProvider.GetDependency().DidNotReceive() + .SendEmail(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .SendEmail(Arg.Any()); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_HtmlEncodedOrganizationName_DecodesNameCorrectly( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Name = "Test & Company"; + var expectedDecodedName = "Test & Company"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.View.OrganizationName == expectedDecodedName)); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_AllEnterpriseTeamsPlanTypes_SendsEnterpriseTeamsEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Test all Enterprise and Teams plan types + var enterpriseTeamsPlanTypes = new[] + { + PlanType.TeamsMonthly2019, PlanType.TeamsAnnually2019, + PlanType.TeamsMonthly2020, PlanType.TeamsAnnually2020, + PlanType.TeamsMonthly2023, PlanType.TeamsAnnually2023, + PlanType.TeamsStarter2023, PlanType.TeamsMonthly, + PlanType.TeamsAnnually, PlanType.TeamsStarter, + PlanType.EnterpriseMonthly2019, PlanType.EnterpriseAnnually2019, + PlanType.EnterpriseMonthly2020, PlanType.EnterpriseAnnually2020, + PlanType.EnterpriseMonthly2023, PlanType.EnterpriseAnnually2023, + PlanType.EnterpriseMonthly, PlanType.EnterpriseAnnually + }; + + foreach (var planType in enterpriseTeamsPlanTypes) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Test Org"; + sutProvider.GetDependency().ClearReceivedCalls(); + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .SendEmail(Arg.Any()); + } + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_AllFamilyFreePlanTypes_SendsFamilyFreeEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Test all Family, Free, and Custom plan types + var familyFreePlanTypes = new[] + { + PlanType.Free, PlanType.FamiliesAnnually2019, + PlanType.FamiliesAnnually2025, PlanType.FamiliesAnnually, + PlanType.Custom + }; + + foreach (var planType in familyFreePlanTypes) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Test Org"; + sutProvider.GetDependency().ClearReceivedCalls(); + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .SendEmail(Arg.Any()); + } + } + + private static string GetSubject(string organizationName) => $"You Have Been Confirmed To {organizationName}"; + +} diff --git a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs index e1b98f87d3..0490e77cde 100644 --- a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs +++ b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs @@ -9,5 +9,5 @@ public class TestMailView : BaseMailView public class TestMail : BaseMail { - public override string Subject { get; } = "Test Email"; + public override string Subject { get; set; } = "Test Email"; }