1
0
mirror of https://github.com/bitwarden/server synced 2026-01-11 21:13:50 +00:00

[PM-27882] Add SendOrganizationConfirmationCommand (#6743)

This commit is contained in:
Jimmy Vo
2026-01-06 16:43:36 -05:00
committed by GitHub
parent 530d946857
commit 63784e1f5f
24 changed files with 589 additions and 20 deletions

View File

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

View File

@@ -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<OrganizationConfirmationEnterpriseTeamsView>
{
public override required string Subject { get; set; }
}

View File

@@ -178,7 +178,7 @@
<tbody>
<tr>
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
<a href="https://vault.bitwarden.com" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
<a href="{{{WebVaultUrl}}}" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
Log in
</a>
</td>

View File

@@ -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<OrganizationConfirmationFamilyFreeView>
{
public override required string Subject { get; set; }
}

View File

@@ -182,7 +182,7 @@
<tbody>
<tr>
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
<a href="https://vault.bitwarden.com" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
<a href="{{{WebVaultUrl}}}" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
Log in
</a>
</td>

View File

@@ -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<AutomaticallyConfirmOrganizationUserCommand> 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)
};
}
/// <summary>
/// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service,
/// depending on the feature flag.
/// </summary>
/// <param name="organization">The organization the user was confirmed to.</param>
/// <param name="userEmail">The email address of the confirmed user.</param>
/// <param name="accessSecretsManager">Whether the user has access to Secrets Manager.</param>
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);
}
}
}

View File

@@ -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<OrganizationUser> 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);
}
/// <summary>
/// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service,
/// depending on the feature flag.
/// </summary>
/// <param name="organization">The organization the user was confirmed to.</param>
/// <param name="userEmail">The email address of the confirmed user.</param>
/// <param name="accessSecretsManager">Whether the user has access to Secrets Manager.</param>
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);
}
}
}

View File

@@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
public interface ISendOrganizationConfirmationCommand
{
/// <summary>
/// Sends an organization confirmation email to the specified user.
/// </summary>
/// <param name="organization">The organization to send the confirmation email for.</param>
/// <param name="userEmail">The email address of the user to send the confirmation to.</param>
/// <param name="accessSecretsManager">Whether the user has access to Secrets Manager.</param>
Task SendConfirmationAsync(Organization organization, string userEmail, bool accessSecretsManager);
/// <summary>
/// Sends organization confirmation emails to multiple users.
/// </summary>
/// <param name="organization">The organization to send the confirmation emails for.</param>
/// <param name="userEmails">The email addresses of the users to send confirmations to.</param>
/// <param name="accessSecretsManager">Whether the users have access to Secrets Manager.</param>
Task SendConfirmationsAsync(Organization organization, IEnumerable<string> userEmails, bool accessSecretsManager);
}

View File

@@ -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<string> 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<string> 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<string> 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
};
}
}

View File

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

View File

@@ -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}}"
/>
</mj-wrapper>

View File

@@ -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}}"
/>
</mj-wrapper>

View File

@@ -12,5 +12,5 @@ public class Families2019RenewalMailView : BaseMailView
public class Families2019RenewalMail : BaseMail<Families2019RenewalMailView>
{
public override string Subject { get => "Your Bitwarden Families renewal is updating"; }
public override string Subject { get; set; } = "Your Bitwarden Families renewal is updating";
}

View File

@@ -9,5 +9,5 @@ public class Families2020RenewalMailView : BaseMailView
public class Families2020RenewalMail : BaseMail<Families2020RenewalMailView>
{
public override string Subject { get => "Your Bitwarden Families renewal is updating"; }
public override string Subject { get; set; } = "Your Bitwarden Families renewal is updating";
}

View File

@@ -11,5 +11,5 @@ public class PremiumRenewalMailView : BaseMailView
public class PremiumRenewalMail : BaseMail<PremiumRenewalMailView>
{
public override string Subject { get => "Your Bitwarden Premium renewal is updating"; }
public override string Subject { get; set; } = "Your Bitwarden Premium renewal is updating";
}

View File

@@ -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<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
services.AddScoped<ISendOrganizationConfirmationCommand, SendOrganizationConfirmationCommand>();
services.AddScoped<IAdminRecoverAccountCommand, AdminRecoverAccountCommand>();
services.AddScoped<IAutomaticallyConfirmOrganizationUserCommand, AutomaticallyConfirmOrganizationUserCommand>();
services.AddScoped<IAutomaticallyConfirmOrganizationUsersValidator, AutomaticallyConfirmOrganizationUsersValidator>();

View File

@@ -19,7 +19,7 @@ public abstract class BaseMail<TView> where TView : BaseMailView
/// <summary>
/// The subject of the email.
/// </summary>
public abstract string Subject { get; }
public abstract string Subject { get; set; }
/// <summary>
/// An optional category for processing at the upstream email delivery service.

View File

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

View File

@@ -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<IEnumerable<string>>(),
organization.Id.ToString());
}
[Theory]
[BitAutoData]
public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOn_UsesNewMailer(
Organization organization,
string userEmail,
SutProvider<AutomaticallyConfirmOrganizationUserCommand> sutProvider)
{
// Arrange
const bool accessSecretsManager = true;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)
.Returns(true);
// Act
await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager);
// Assert
await sutProvider.GetDependency<ISendOrganizationConfirmationCommand>()
.Received(1)
.SendConfirmationAsync(organization, userEmail, accessSecretsManager);
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendOrganizationConfirmedEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Theory]
[BitAutoData]
public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService(
Organization organization,
string userEmail,
SutProvider<AutomaticallyConfirmOrganizationUserCommand> sutProvider)
{
// Arrange
const bool accessSecretsManager = false;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)
.Returns(false);
// Act
await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager);
await sutProvider.GetDependency<ISendOrganizationConfirmationCommand>()
.DidNotReceive()
.SendConfirmationAsync(Arg.Any<Organization>(), Arg.Any<string>(), Arg.Any<bool>());
}
}

View File

@@ -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<ConfirmOrganizationUserCommand> sutProvider)
{
// Arrange
const bool accessSecretsManager = true;
sutProvider.GetDependency<IFeatureService>()
.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<ISendOrganizationConfirmationCommand>()
.Received(1)
.SendConfirmationAsync(org, userEmail, accessSecretsManager);
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendOrganizationConfirmedEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Theory, BitAutoData]
public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService(
Organization org,
string userEmail,
SutProvider<ConfirmOrganizationUserCommand> sutProvider)
{
// Arrange
const bool accessSecretsManager = false;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)
.Returns(false);
// Act
await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(org, userEmail, accessSecretsManager);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationConfirmedEmailAsync(org.DisplayName(), userEmail, accessSecretsManager);
await sutProvider.GetDependency<ISendOrganizationConfirmationCommand>()
.DidNotReceive()
.SendConfirmationAsync(Arg.Any<Organization>(), Arg.Any<string>(), Arg.Any<bool>());
}
}

View File

@@ -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<SendOrganizationConfirmationCommand> sutProvider)
{
// Arrange
organization.PlanType = PlanType.EnterpriseAnnually;
organization.Name = "Test Enterprise Org";
// Act
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
// Assert
await sutProvider.GetDependency<IMailer>().Received(1)
.SendEmail(Arg.Is<OrganizationConfirmationEnterpriseTeams>(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<SendOrganizationConfirmationCommand> sutProvider)
{
// Arrange
organization.PlanType = PlanType.TeamsAnnually;
organization.Name = "Test Teams Org";
// Act
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
// Assert
await sutProvider.GetDependency<IMailer>().Received(1)
.SendEmail(Arg.Is<OrganizationConfirmationEnterpriseTeams>(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<SendOrganizationConfirmationCommand> sutProvider)
{
// Arrange
organization.PlanType = PlanType.FamiliesAnnually;
organization.Name = "Test Family Org";
// Act
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
// Assert
await sutProvider.GetDependency<IMailer>().Received(1)
.SendEmail(Arg.Is<OrganizationConfirmationFamilyFree>(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<SendOrganizationConfirmationCommand> sutProvider)
{
// Arrange
organization.PlanType = PlanType.Free;
organization.Name = "Test Free Org";
// Act
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
// Assert
await sutProvider.GetDependency<IMailer>().Received(1)
.SendEmail(Arg.Is<OrganizationConfirmationFamilyFree>(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<string> userEmails,
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
{
// Arrange
organization.PlanType = PlanType.EnterpriseAnnually;
organization.Name = "Test Enterprise Org";
// Act
await sutProvider.Sut.SendConfirmationsAsync(organization, userEmails, false);
// Assert
await sutProvider.GetDependency<IMailer>().Received(1)
.SendEmail(Arg.Is<OrganizationConfirmationEnterpriseTeams>(mail =>
mail.ToEmails.SequenceEqual(userEmails) &&
mail.View.OrganizationName == organization.Name));
}
[Theory]
[OrganizationCustomize, BitAutoData]
public async Task SendConfirmationsAsync_EmptyUserList_DoesNotSendEmail(
Organization organization,
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
{
// Arrange
organization.PlanType = PlanType.EnterpriseAnnually;
organization.Name = "Test Enterprise Org";
// Act
await sutProvider.Sut.SendConfirmationsAsync(organization, [], false);
// Assert
await sutProvider.GetDependency<IMailer>().DidNotReceive()
.SendEmail(Arg.Any<OrganizationConfirmationEnterpriseTeams>());
await sutProvider.GetDependency<IMailer>().DidNotReceive()
.SendEmail(Arg.Any<OrganizationConfirmationFamilyFree>());
}
[Theory]
[OrganizationCustomize, BitAutoData]
public async Task SendConfirmationAsync_HtmlEncodedOrganizationName_DecodesNameCorrectly(
Organization organization,
string userEmail,
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
{
// Arrange
organization.PlanType = PlanType.EnterpriseAnnually;
organization.Name = "Test &amp; Company";
var expectedDecodedName = "Test & Company";
// Act
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
// Assert
await sutProvider.GetDependency<IMailer>().Received(1)
.SendEmail(Arg.Is<OrganizationConfirmationEnterpriseTeams>(mail =>
mail.View.OrganizationName == expectedDecodedName));
}
[Theory]
[OrganizationCustomize, BitAutoData]
public async Task SendConfirmationAsync_AllEnterpriseTeamsPlanTypes_SendsEnterpriseTeamsEmail(
Organization organization,
string userEmail,
SutProvider<SendOrganizationConfirmationCommand> 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<IMailer>().ClearReceivedCalls();
// Act
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
// Assert
await sutProvider.GetDependency<IMailer>().Received(1)
.SendEmail(Arg.Any<OrganizationConfirmationEnterpriseTeams>());
await sutProvider.GetDependency<IMailer>().DidNotReceive()
.SendEmail(Arg.Any<OrganizationConfirmationFamilyFree>());
}
}
[Theory]
[OrganizationCustomize, BitAutoData]
public async Task SendConfirmationAsync_AllFamilyFreePlanTypes_SendsFamilyFreeEmail(
Organization organization,
string userEmail,
SutProvider<SendOrganizationConfirmationCommand> 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<IMailer>().ClearReceivedCalls();
// Act
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
// Assert
await sutProvider.GetDependency<IMailer>().Received(1)
.SendEmail(Arg.Any<OrganizationConfirmationFamilyFree>());
await sutProvider.GetDependency<IMailer>().DidNotReceive()
.SendEmail(Arg.Any<OrganizationConfirmationEnterpriseTeams>());
}
}
private static string GetSubject(string organizationName) => $"You Have Been Confirmed To {organizationName}";
}

View File

@@ -9,5 +9,5 @@ public class TestMailView : BaseMailView
public class TestMail : BaseMail<TestMailView>
{
public override string Subject { get; } = "Test Email";
public override string Subject { get; set; } = "Test Email";
}