diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserInvitedWithSponsorship.html.hbs
new file mode 100644
index 0000000000..dd1bfbe006
--- /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 0000000000..3e0773a91a
--- /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 0000000000..8d25a33771
--- /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 0000000000..72999fefd3
--- /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 19be7e145b..0000000000
--- 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 88b716f499..45d1725229 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 39a2786570..b0a6a4365c 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 b775eb4bf7..40f7ee2ecc 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 c5445fefa6..8bc7d498c8 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 eeef6c85c6..dc0dc0bd49 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 44c8969031..880ced0417 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 0000000000..d5f202a125
--- /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]