mirror of
https://github.com/bitwarden/server
synced 2026-02-17 18:09:11 +00:00
[PM-21740 / PM-27878] Update join organization email templates (#6941)
* Add new feature flag for updating the join organization email templates * Add new MJML email template for organization invite to existing users * Add new MJML email template for organization invite to new users * Add new MJML email template for organization invite to existing families * Add new MJML email template for organization invite to families for new users * Add new MJML email template for organization invite to free users * Add OrganizationInviteBaseView model for organization invite email templates * Add required properties for email title customization in OrganizationInviteBaseView * Add new MJML email templates for organization invites to existing and new users, including families and free users. * Enhance SendInvitesRequest to include optional InvitingUserId and update related methods in OrganizationService for improved user invitation tracking. * Refactor organization invite email handling to support customized templates for existing and new users, incorporating inviting user information and enhancing the SendInvitesRequest structure. * Refactor OrganizationInviteBaseView and SendOrganizationInvitesCommand to remove unnecessary properties, streamlining the organization invite email structure and focusing on essential information for user invitations. * Refactor SendOrganizationInvitesCommand to improve email invitation logic by removing the OrganizationCategory enum and consolidating plan type checks. Introduce a new method for formatting expiration dates to enhance code clarity and maintainability. * Update organization invite email templates to enhance styling * Enhance SendOrganizationInvitesCommand to include additional plan types for organization invites, allowing support for TeamsStarter, TeamsStarter2023, and Custom plans in the invitation logic. * Add tests for SendOrganizationInvitesCommand to validate email sending logic for various plan types, including Enterprise, Teams, Families, Free, and Custom plans, based on user existence and feature flags. * Update organization invite email templates to improve styling and layout consistency across various user types, including adjustments to padding, font weights, and vertical alignment for a more polished appearance. * Refactor organization invite email templates to improve styling consistency and layout across various user types, including adjustments to padding, font families, and visibility of icon rows for a more polished appearance. * [PM-30610] Break shared components into AC versions * Revert changes to shared MJML components * Refactor organization invite email templates to use admin console MJML components * Update organization invite email templates to utilize new admin console MJML components * Enhance organization invite email templates by adding bullet point for mobile views * Update organization invite email templates to improve layout and visibility of bullet points by changing display properties and adding inline text spans. --------- Co-authored-by: Jimmy Vo <huynhmaivo82@gmail.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationInvite;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
@@ -10,6 +11,8 @@ using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
|
||||
@@ -20,6 +23,7 @@ using Bit.Test.Common.Fakes;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
using CoreGlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
|
||||
@@ -111,4 +115,391 @@ public class SendOrganizationInvitesCommandTests
|
||||
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
||||
info.OrganizationName == organization.Name));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually, false)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2023, false)]
|
||||
[BitAutoData(PlanType.TeamsAnnually, false)]
|
||||
[BitAutoData(PlanType.TeamsStarter, false)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually, true)]
|
||||
[BitAutoData(PlanType.TeamsAnnually, true)]
|
||||
public async Task SendInvitesAsync_WithFeatureFlag_EnterpriseAndTeamsPlans_SendsEnterpriseTemplate(
|
||||
PlanType planType,
|
||||
bool userExists,
|
||||
Organization organization,
|
||||
OrganizationUser invite,
|
||||
User invitingUser,
|
||||
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
SetupSutProvider(sutProvider);
|
||||
|
||||
// Arrange
|
||||
organization.PlanType = planType;
|
||||
invite.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetManyByEmailsAsync(Arg.Any<IEnumerable<string>>())
|
||||
.Returns(userExists ? [new User { Email = invite.Email }] : []);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(invitingUser.Id)
|
||||
.Returns(invitingUser);
|
||||
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization, false, invitingUser.Id));
|
||||
|
||||
// Assert
|
||||
if (userExists)
|
||||
{
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Any<OrganizationInviteEnterpriseTeamsExistingUser>());
|
||||
}
|
||||
else
|
||||
{
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Any<OrganizationInviteEnterpriseTeamsNewUser>());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.FamiliesAnnually, false)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2025, false)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually, true)]
|
||||
public async Task SendInvitesAsync_WithFeatureFlag_FamiliesPlans_SendsFamiliesTemplate(
|
||||
PlanType planType,
|
||||
bool userExists,
|
||||
Organization organization,
|
||||
OrganizationUser invite,
|
||||
User invitingUser,
|
||||
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
SetupSutProvider(sutProvider);
|
||||
|
||||
// Arrange
|
||||
organization.PlanType = planType;
|
||||
invite.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetManyByEmailsAsync(Arg.Any<IEnumerable<string>>())
|
||||
.Returns(userExists ? [new User { Email = invite.Email }] : []);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(invitingUser.Id)
|
||||
.Returns(invitingUser);
|
||||
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization, false, invitingUser.Id));
|
||||
|
||||
// Assert
|
||||
if (userExists)
|
||||
{
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Any<OrganizationInviteFamiliesExistingUser>());
|
||||
}
|
||||
else
|
||||
{
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Any<OrganizationInviteFamiliesNewUser>());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendInvitesAsync_WithFeatureFlag_FreePlan_SendsFreeTemplate(
|
||||
Organization organization,
|
||||
OrganizationUser invite,
|
||||
User invitingUser,
|
||||
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
SetupSutProvider(sutProvider);
|
||||
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.Free;
|
||||
invite.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetManyByEmailsAsync(Arg.Any<IEnumerable<string>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(invitingUser.Id)
|
||||
.Returns(invitingUser);
|
||||
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization, false, invitingUser.Id));
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Any<OrganizationInviteFree>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendInvitesAsync_WithFeatureFlag_CustomPlan_SendsEnterpriseTemplate(
|
||||
Organization organization,
|
||||
OrganizationUser invite,
|
||||
User invitingUser,
|
||||
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
SetupSutProvider(sutProvider);
|
||||
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.Custom;
|
||||
invite.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetManyByEmailsAsync(Arg.Any<IEnumerable<string>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(invitingUser.Id)
|
||||
.Returns(invitingUser);
|
||||
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization, false, invitingUser.Id));
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Any<OrganizationInviteEnterpriseTeamsNewUser>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendInvitesAsync_WithFeatureFlagEnabled_UsesNewMailer(
|
||||
Organization organization,
|
||||
OrganizationUser invite,
|
||||
User invitingUser,
|
||||
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
SetupSutProvider(sutProvider);
|
||||
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetManyByEmailsAsync(Arg.Any<IEnumerable<string>>())
|
||||
.Returns([new User { Email = invite.Email }]);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(invitingUser.Id)
|
||||
.Returns(invitingUser);
|
||||
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization, false, invitingUser.Id));
|
||||
|
||||
// Assert - verify new mailer is called, not legacy mail service
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.Received(1)
|
||||
.SendEmail(Arg.Any<OrganizationInviteEnterpriseTeamsExistingUser>());
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendOrganizationInviteEmailsAsync(Arg.Any<OrganizationInvitesInfo>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendInvitesAsync_WithFeatureFlagDisabled_UsesLegacyMailService(
|
||||
Organization organization,
|
||||
OrganizationUser invite,
|
||||
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
|
||||
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
|
||||
sutProvider.Create();
|
||||
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization));
|
||||
|
||||
// Assert - verify legacy mail service is called, not new mailer
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendOrganizationInviteEmailsAsync(Arg.Any<OrganizationInvitesInfo>());
|
||||
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.DidNotReceive()
|
||||
.SendEmail(Arg.Any<BaseMail<OrganizationInviteBaseView>>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendInvitesAsync_WithInvitingUserId_PopulatesInviterEmail(
|
||||
Organization organization,
|
||||
OrganizationUser invite,
|
||||
User invitingUser,
|
||||
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
SetupSutProvider(sutProvider);
|
||||
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetManyByEmailsAsync(Arg.Any<IEnumerable<string>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(invitingUser.Id)
|
||||
.Returns(invitingUser);
|
||||
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization, false, invitingUser.Id));
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Is<OrganizationInviteEnterpriseTeamsNewUser>(mail =>
|
||||
mail.View.InviterEmail == invitingUser.Email));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendInvitesAsync_WithNullInvitingUserId_SendsEmailWithoutInviter(
|
||||
Organization organization,
|
||||
OrganizationUser invite,
|
||||
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
SetupSutProvider(sutProvider);
|
||||
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetManyByEmailsAsync(Arg.Any<IEnumerable<string>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
// Act - pass null for InvitingUserId
|
||||
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization, false, null));
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Is<OrganizationInviteEnterpriseTeamsNewUser>(mail =>
|
||||
mail.View.InviterEmail == null));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendInvitesAsync_WithNonExistentInvitingUserId_SendsEmailWithoutInviter(
|
||||
Organization organization,
|
||||
OrganizationUser invite,
|
||||
Guid nonExistentUserId,
|
||||
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
SetupSutProvider(sutProvider);
|
||||
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetManyByEmailsAsync(Arg.Any<IEnumerable<string>>())
|
||||
.Returns([]);
|
||||
|
||||
// Mock GetByIdAsync to return null for non-existent user
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(nonExistentUserId)
|
||||
.ReturnsNull();
|
||||
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization, false, nonExistentUserId));
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Is<OrganizationInviteEnterpriseTeamsNewUser>(mail =>
|
||||
mail.View.InviterEmail == null));
|
||||
}
|
||||
|
||||
private void SetupSutProvider(SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
|
||||
sutProvider.SetDependency(new CoreGlobalSettings { BaseServiceUri = new CoreGlobalSettings.BaseServiceUriSettings(new CoreGlobalSettings()) }, "globalSettings");
|
||||
sutProvider.Create();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user