From b0760b2134383cc99e1905b4dba0e38b94e8a9d6 Mon Sep 17 00:00:00 2001 From: Justin Baur Date: Wed, 17 Nov 2021 22:06:49 -0500 Subject: [PATCH] Finish emails --- ...izationUserInvitedWithSponsorship.html.hbs | 23 ++++++++ ...izationUserInvitedWithSponsorship.text.hbs | 10 ++++ ...EnterpriseOfferExistingAccountViewModel.cs | 10 ++++ ...esForEnterpriseOfferNewAccountViewModel.cs | 10 ++++ .../FamiliesForEnterpriseOfferViewModel.cs | 9 --- src/Core/Services/IMailService.cs | 4 +- .../Implementations/HandlebarsMailService.cs | 43 ++++++++++----- .../Implementations/OrganizationService.cs | 19 ++++++- .../NoopImplementations/NoopMailService.cs | 4 +- .../Services/HandlebarsMailServiceTests.cs | 5 +- .../Services/OrganizationServiceTests.cs | 4 +- .../2021-11-15_00_MergeKeyConnectorAndFFE.sql | 55 +++++++++++++++++++ 12 files changed, 165 insertions(+), 31 deletions(-) create mode 100644 src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.text.hbs create mode 100644 src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferExistingAccountViewModel.cs create mode 100644 src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferNewAccountViewModel.cs delete mode 100644 src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferViewModel.cs create mode 100644 util/Migrator/DbScripts/2021-11-15_00_MergeKeyConnectorAndFFE.sql diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.html.hbs new file mode 100644 index 000000000..dd1bfbe00 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.html.hbs @@ -0,0 +1,23 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ You have been invited to join the {{OrganizationName}} organization. This link expires on {{ExpirationDate}}. +
+ + Join Organization Now + +
+ If you do not wish to join this organization, you can safely ignore this email. +
+ Did you know? Members of {{OrganizationName}} receive a complimentary Families subscription. Learn more here. +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.text.hbs new file mode 100644 index 000000000..3e0773a91 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.text.hbs @@ -0,0 +1,10 @@ +{{#>BasicTextLayout}} +You have been invited to join the {{OrganizationName}} organization. To accept this invite, click the following link: + +{{{Url}}} + +This link expires on {{ExpirationDate}}. + +If you do not wish to join this organization, you can safely ignore this email. +Did you know? Members of {{OrganizationName}} receive a complimentary Families subscription. Learn more here: https://bitwarden.com/help/article/about-bitwarden-plans/#families-organizations +{{/BasicTextLayout}} diff --git a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferExistingAccountViewModel.cs b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferExistingAccountViewModel.cs new file mode 100644 index 000000000..8d25a3377 --- /dev/null +++ b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferExistingAccountViewModel.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Models.Mail.FamiliesForEnterprise +{ + public class FamiliesForEnterpriseOfferExistingAccountViewModel : BaseMailModel + { + public string SponsorEmail { get; set; } + public string SponsoredEmail { get; set; } + public string SponsorshipToken { get; set; } + public string Url => $"{WebVaultUrl}/?sponsorshipToken={SponsorshipToken}&email={SponsoredEmail}"; + } +} diff --git a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferNewAccountViewModel.cs b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferNewAccountViewModel.cs new file mode 100644 index 000000000..72999fefd --- /dev/null +++ b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferNewAccountViewModel.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Models.Mail.FamiliesForEnterprise +{ + public class FamiliesForEnterpriseOfferNewAccountViewModel : BaseMailModel + { + public string SponsorEmail { get; set; } + public string SponsoredEmail { get; set; } + public string SponsorshipToken { get; set; } + public string Url => $"{WebVaultUrl}/register?sponsorshipToken={SponsorshipToken}&email={SponsoredEmail}"; + } +} diff --git a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferViewModel.cs b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferViewModel.cs deleted file mode 100644 index 19be7e145..000000000 --- a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferViewModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.Core.Models.Mail.FamiliesForEnterprise -{ - public class FamiliesForEnterpriseOfferViewModel : BaseMailModel - { - public string SponsorEmail { get; set; } - public string SponsorshipToken { get; set; } - public string Url => $"{WebVaultUrl}/sponsored/families-for-enterprise?token={SponsorshipToken}"; - } -} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 88b716f49..45d172522 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -18,8 +18,8 @@ namespace Bit.Core.Services Task SendTwoFactorEmailAsync(string email, string token); Task SendNoMasterPasswordHintEmailAsync(string email); Task SendMasterPasswordHintEmailAsync(string email, string hint); - Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token); - Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites); + Task SendOrganizationInviteEmailAsync(string organizationName, bool orgCanSponsor, OrganizationUser orgUser, ExpiringToken token); + Task BulkSendOrganizationInviteEmailAsync(string organizationName, bool orgCanSponsor, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites); Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable ownerEmails); Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable adminEmails); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 39a278657..b0a6a4365 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -205,15 +205,15 @@ namespace Bit.Core.Services await _mailDeliveryService.SendEmailAsync(message); } - public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token) => - BulkSendOrganizationInviteEmailAsync(organizationName, new[] { (orgUser, token) }); + public Task SendOrganizationInviteEmailAsync(string organizationName, bool orgCanSponsor, OrganizationUser orgUser, ExpiringToken token) => + BulkSendOrganizationInviteEmailAsync(organizationName, orgCanSponsor, new[] { (orgUser, token) }); - public async Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites) + public async Task BulkSendOrganizationInviteEmailAsync(string organizationName, bool orgCanSponsor, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites) { MailQueueMessage CreateMessage(string email, object model) { var message = CreateDefaultMessage($"Join {organizationName}", email); - return new MailQueueMessage(message, "OrganizationUserInvited", model); + return new MailQueueMessage(message, orgCanSponsor ? "OrganizationUserInvitedWithSponsorship" : "OrganizationUserInvited", model); } var messageModels = invites.Select(invite => CreateMessage(invite.orgUser.Email, @@ -761,18 +761,33 @@ namespace Bit.Core.Services { var message = CreateDefaultMessage("Finish Activation - Your Free Families Subscription", email); - var model = new FamiliesForEnterpriseOfferViewModel + if (existingAccount) { - SponsorEmail = sponsorEmail, - WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, - SiteName = _globalSettings.SiteName, - SponsorshipToken = token, - }; + var model = new FamiliesForEnterpriseOfferExistingAccountViewModel + { + SponsorEmail = sponsorEmail, + SponsoredEmail = WebUtility.UrlEncode(email), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + SponsorshipToken = token, + }; + + await AddMessageContentAsync(message, "FamiliesForEnterprise.FamiliesForEnterpriseOfferExistingAccount", model); + } + else + { + var model = new FamiliesForEnterpriseOfferNewAccountViewModel + { + SponsorEmail = sponsorEmail, + SponsoredEmail = WebUtility.UrlEncode(email), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + SponsorshipToken = token, + }; + + await AddMessageContentAsync(message, "FamiliesForEnterprise.FamiliesForEnterpriseOfferNewAccount", model); + } - await AddMessageContentAsync(message, existingAccount - ? "FamiliesForEnterprise.FamiliesForEnterpriseOfferExistingAccount" - : "FamiliesForEnterprise.FamiliesForEnterpriseOfferNewAccount", model); - message.Category = "FamiliesForEnterpriseOffer"; await _mailDeliveryService.SendEmailAsync(message); } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index b775eb4bf..40f7ee2ec 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -1239,7 +1239,10 @@ namespace Bit.Core.Services { string MakeToken(OrganizationUser orgUser) => _dataProtector.Protect($"OrganizationUserInvite {orgUser.Id} {orgUser.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - await _mailService.BulkSendOrganizationInviteEmailAsync(organization.Name, + + var orgCanSponsor = CheckOrgCanSponsor(organization); + + await _mailService.BulkSendOrganizationInviteEmailAsync(organization.Name, orgCanSponsor, orgUsers.Select(o => (o, new ExpiringToken(MakeToken(o), DateTime.UtcNow.AddDays(5))))); } @@ -1249,7 +1252,19 @@ namespace Bit.Core.Services var nowMillis = CoreHelpers.ToEpocMilliseconds(now); var token = _dataProtector.Protect( $"OrganizationUserInvite {orgUser.Id} {orgUser.Email} {nowMillis}"); - await _mailService.SendOrganizationInviteEmailAsync(organization.Name, orgUser, new ExpiringToken(token, now.AddDays(5))); + + // TODO: Refactor so that the below line can be used. + // StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise).UsersCanSponsor(organization) + var orgCanSponsor = CheckOrgCanSponsor(organization); + + await _mailService.SendOrganizationInviteEmailAsync(organization.Name, orgCanSponsor, orgUser, new ExpiringToken(token, now.AddDays(5))); + } + + + private static bool CheckOrgCanSponsor(Organization organization) + { + return StaticStore.GetPlan(organization.PlanType).Product == ProductType.Enterprise + && !organization.SelfHost; } public async Task AcceptUserAsync(Guid organizationUserId, User user, string token, diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index c5445fefa..8bc7d498c 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -55,12 +55,12 @@ namespace Bit.Core.Services return Task.FromResult(0); } - public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token) + public Task SendOrganizationInviteEmailAsync(string organizationName, bool orgCanSponsor, OrganizationUser orgUser, ExpiringToken token) { return Task.FromResult(0); } - public Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites) + public Task BulkSendOrganizationInviteEmailAsync(string organizationName, bool orgCanSponsor, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites) { return Task.FromResult(0); } diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index eeef6c85c..dc0dc0bd4 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -35,7 +35,7 @@ namespace Bit.Core.Test.Services ); } - [Fact(Skip = "Only for local development")] + [Fact] public async Task SendAllEmails() { // This test is only opt in and is more for development purposes. @@ -115,6 +115,9 @@ namespace Bit.Core.Test.Services { ("familyUserEmail", typeof(string)), "test@bitwarden.com" }, { ("sponsorEmail", typeof(string)), "test@bitwarden.com" }, { ("familyOrgName", typeof(string)), "Test Org Name" }, + { ("orgCanSponsor", typeof(bool)), true }, + { ("existingAccount", typeof(bool)), true }, + { ("sponsorshipEndDate", typeof(DateTime)), DateTime.UtcNow.AddDays(1)}, }; var globalSettings = new GlobalSettings diff --git a/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index 44c896903..880ced041 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -28,7 +28,7 @@ namespace Bit.Core.Test.Services public class OrganizationServiceTests { // [Fact] - [Theory, PaidOrganizationAutoData] + [Theory, PaidOrganizationAutoData(PlanType.EnterpriseAnnually)] public async Task OrgImportCreateNewUsers(SutProvider sutProvider, Guid userId, Organization org, List existingUsers, List newUsers) { @@ -66,6 +66,7 @@ namespace Bit.Core.Test.Services .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); await sutProvider.GetDependency().Received(1) .BulkSendOrganizationInviteEmailAsync(org.Name, + true, Arg.Is>(messages => messages.Count() == expectedNewUsersCount)); // Send events @@ -124,6 +125,7 @@ namespace Bit.Core.Test.Services .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); await sutProvider.GetDependency().Received(1) .BulkSendOrganizationInviteEmailAsync(org.Name, + false, Arg.Is>(messages => messages.Count() == expectedNewUsersCount)); // Sent events diff --git a/util/Migrator/DbScripts/2021-11-15_00_MergeKeyConnectorAndFFE.sql b/util/Migrator/DbScripts/2021-11-15_00_MergeKeyConnectorAndFFE.sql new file mode 100644 index 000000000..d5f202a12 --- /dev/null +++ b/util/Migrator/DbScripts/2021-11-15_00_MergeKeyConnectorAndFFE.sql @@ -0,0 +1,55 @@ +IF EXISTS(SELECT * FROM sys.views WHERE [Name] = 'OrganizationUserOrganizationDetailsView') + BEGIN + DROP VIEW [dbo].[OrganizationUserOrganizationDetailsView] + END +GO + +CREATE VIEW [dbo].[OrganizationUserOrganizationDetailsView] +AS +SELECT + OU.[UserId], + OU.[OrganizationId], + O.[Name], + O.[Enabled], + O.[PlanType], + O.[UsePolicies], + O.[UseSso], + O.[UseGroups], + O.[UseDirectory], + O.[UseEvents], + O.[UseTotp], + O.[Use2fa], + O.[UseApi], + O.[UseResetPassword], + O.[SelfHost], + O.[UsersGetPremium], + O.[Seats], + O.[MaxCollections], + O.[MaxStorageGb], + O.[Identifier], + OU.[Key], + OU.[ResetPasswordKey], + O.[PublicKey], + O.[PrivateKey], + OU.[Status], + OU.[Type], + SU.[ExternalId] SsoExternalId, + OU.[Permissions], + PO.[ProviderId], + P.[Name] ProviderName, + SS.[Data] SsoConfig, + OS.[FriendlyName] FamilySponsorshipFriendlyName +FROM + [dbo].[OrganizationUser] OU +LEFT JOIN + [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] +LEFT JOIN + [dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId] +LEFT JOIN + [dbo].[ProviderOrganization] PO ON PO.[OrganizationId] = O.[Id] +LEFT JOIN + [dbo].[Provider] P ON P.[Id] = PO.[ProviderId] +LEFT JOIN + [dbo].[SsoConfig] SS ON SS.[OrganizationId] = OU.[OrganizationId] +LEFT JOIN + [dbo].[OrganizationSponsorship] OS ON OS.[SponsoringOrganizationUserID] = OU.[Id]