diff --git a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs index fe42093111..5c0efeb73f 100644 --- a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs +++ b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs @@ -15,11 +15,13 @@ public class RegisterVerifyEmail : BaseMailModel // so we must land on a redirect connector which will redirect to the finish signup page. // Note 3: The use of a fragment to indicate the redirect url is to prevent the query string from being logged by // proxies and servers. It also helps reduce open redirect vulnerabilities. - public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true", + public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true{3}", WebVaultUrl, Token, - Email); + Email, + !string.IsNullOrEmpty(FromMarketing) ? $"&fromMarketing={FromMarketing}" : string.Empty); public string Token { get; set; } public string Email { get; set; } + public string FromMarketing { get; set; } } diff --git a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs index b623b8cab3..2a224b9eb9 100644 --- a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs @@ -3,5 +3,5 @@ namespace Bit.Core.Auth.UserFeatures.Registration; public interface ISendVerificationEmailForRegistrationCommand { - public Task Run(string email, string? name, bool receiveMarketingEmails); + public Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 5841cd2e62..2e8587eee6 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -44,7 +44,7 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai } - public async Task Run(string email, string? name, bool receiveMarketingEmails) + public async Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing) { if (_globalSettings.DisableUserRegistration) { @@ -92,7 +92,7 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai // If the user doesn't exist, create a new EmailVerificationTokenable and send the user // an email with a link to verify their email address var token = GenerateToken(email, name, receiveMarketingEmails); - await _mailService.SendRegistrationVerificationEmailAsync(email, token); + await _mailService.SendRegistrationVerificationEmailAsync(email, token, fromMarketing); } // User exists but we will return a 200 regardless of whether the email was sent or not; so return null diff --git a/src/Core/Platform/Mail/HandlebarsMailService.cs b/src/Core/Platform/Mail/HandlebarsMailService.cs index a602129886..d57ca400fd 100644 --- a/src/Core/Platform/Mail/HandlebarsMailService.cs +++ b/src/Core/Platform/Mail/HandlebarsMailService.cs @@ -78,7 +78,7 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendRegistrationVerificationEmailAsync(string email, string token) + public async Task SendRegistrationVerificationEmailAsync(string email, string token, string? fromMarketing) { var message = CreateDefaultMessage("Verify Your Email", email); var model = new RegisterVerifyEmail @@ -86,7 +86,8 @@ public class HandlebarsMailService : IMailService Token = WebUtility.UrlEncode(token), Email = WebUtility.UrlEncode(email), WebVaultUrl = _globalSettings.BaseServiceUri.Vault, - SiteName = _globalSettings.SiteName + SiteName = _globalSettings.SiteName, + FromMarketing = WebUtility.UrlEncode(fromMarketing), }; await AddMessageContentAsync(message, "Auth.RegistrationVerifyEmail", model); message.MetaData.Add("SendGridBypassListManagement", true); diff --git a/src/Core/Platform/Mail/IMailService.cs b/src/Core/Platform/Mail/IMailService.cs index 16c5c312fe..e21e1a010f 100644 --- a/src/Core/Platform/Mail/IMailService.cs +++ b/src/Core/Platform/Mail/IMailService.cs @@ -38,7 +38,7 @@ public interface IMailService /// Task Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName); Task SendVerifyEmailEmailAsync(string email, Guid userId, string token); - Task SendRegistrationVerificationEmailAsync(string email, string token); + Task SendRegistrationVerificationEmailAsync(string email, string token, string? fromMarketing); Task SendTrialInitiationSignupEmailAsync( bool isExistingUser, string email, diff --git a/src/Core/Platform/Mail/NoopMailService.cs b/src/Core/Platform/Mail/NoopMailService.cs index da55470db3..7de48e4619 100644 --- a/src/Core/Platform/Mail/NoopMailService.cs +++ b/src/Core/Platform/Mail/NoopMailService.cs @@ -26,7 +26,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendRegistrationVerificationEmailAsync(string email, string hint) + public Task SendRegistrationVerificationEmailAsync(string email, string hint, string? fromMarketing) { return Task.FromResult(0); } diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 108efe79ba..b7d4342c1b 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -109,8 +109,12 @@ public class AccountsController : Controller [HttpPost("register/send-verification-email")] public async Task PostRegisterSendVerificationEmail([FromBody] RegisterSendVerificationEmailRequestModel model) { + // Only pass fromMarketing if the feature flag is enabled + var isMarketingFeatureEnabled = _featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow); + var fromMarketing = isMarketingFeatureEnabled ? model.FromMarketing : null; + var token = await _sendVerificationEmailForRegistrationCommand.Run(model.Email, model.Name, - model.ReceiveMarketingEmails); + model.ReceiveMarketingEmails, fromMarketing); if (token != null) { diff --git a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs index bb4bce08c1..91e8351d2c 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -40,22 +41,55 @@ public class SendVerificationEmailForRegistrationCommandTests .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) .Returns(false); - sutProvider.GetDependency() - .SendRegistrationVerificationEmailAsync(email, Arg.Any()) - .Returns(Task.CompletedTask); - var mockedToken = "token"; sutProvider.GetDependency>() .Protect(Arg.Any()) .Returns(mockedToken); // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null); // Assert await sutProvider.GetDependency() .Received(1) - .SendRegistrationVerificationEmailAsync(email, mockedToken); + .SendRegistrationVerificationEmailAsync(email, mockedToken, null); + Assert.Null(result); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenFromMarketingIsPremium_SendsEmailWithMarketingParameterAndReturnsNull(SutProvider sutProvider, + string email, string name, bool receiveMarketingEmails) + { + // Arrange + sutProvider.GetDependency() + .GetByEmailAsync(email) + .ReturnsNull(); + + sutProvider.GetDependency() + .EnableEmailVerification = true; + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + + var mockedToken = "token"; + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns(mockedToken); + + var fromMarketing = MarketingInitiativeConstants.Premium; + + // Act + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, fromMarketing); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendRegistrationVerificationEmailAsync(email, mockedToken, fromMarketing); Assert.Null(result); } @@ -87,12 +121,12 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(mockedToken); // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null); // Assert await sutProvider.GetDependency() .DidNotReceive() - .SendRegistrationVerificationEmailAsync(email, mockedToken); + .SendRegistrationVerificationEmailAsync(email, mockedToken, null); Assert.Null(result); } @@ -124,7 +158,7 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(mockedToken); // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null); // Assert Assert.Equal(mockedToken, result); @@ -140,7 +174,7 @@ public class SendVerificationEmailForRegistrationCommandTests .DisableUserRegistration = true; // Act & Assert - await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null)); } [Theory] @@ -166,7 +200,7 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(false); // Act & Assert - await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null)); } [Theory] @@ -177,7 +211,7 @@ public class SendVerificationEmailForRegistrationCommandTests sutProvider.GetDependency() .DisableUserRegistration = false; - await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails)); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails, null)); } [Theory] @@ -187,7 +221,7 @@ public class SendVerificationEmailForRegistrationCommandTests { sutProvider.GetDependency() .DisableUserRegistration = false; - await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails)); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails, null)); } [Theory] @@ -210,7 +244,7 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(true); // Act & Assert - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null)); Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); } @@ -246,7 +280,7 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(mockedToken); // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null); // Assert Assert.Equal(mockedToken, result); @@ -270,7 +304,7 @@ public class SendVerificationEmailForRegistrationCommandTests // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + sutProvider.Sut.Run(email, name, receiveMarketingEmails, null)); Assert.Equal("Invalid email address format.", exception.Message); } } diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index d089c8ec57..42e033bdd7 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -241,7 +241,7 @@ public class AccountsControllerTests : IDisposable var token = "fakeToken"; - _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).Returns(token); + _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).Returns(token); // Act var result = await _sut.PostRegisterSendVerificationEmail(model); @@ -264,7 +264,7 @@ public class AccountsControllerTests : IDisposable ReceiveMarketingEmails = receiveMarketingEmails }; - _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).ReturnsNull(); + _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).ReturnsNull(); // Act var result = await _sut.PostRegisterSendVerificationEmail(model); @@ -274,6 +274,55 @@ public class AccountsControllerTests : IDisposable Assert.Equal(204, noContentResult.StatusCode); } + [Theory] + [BitAutoData] + public async Task PostRegisterSendEmailVerification_WhenFeatureFlagEnabled_PassesFromMarketingToCommandAsync( + string email, string name, bool receiveMarketingEmails) + { + // Arrange + var fromMarketing = MarketingInitiativeConstants.Premium; + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + FromMarketing = fromMarketing, + }; + + _featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(true); + + // Act + await _sut.PostRegisterSendVerificationEmail(model); + + // Assert + await _sendVerificationEmailForRegistrationCommand.Received(1) + .Run(email, name, receiveMarketingEmails, fromMarketing); + } + + [Theory] + [BitAutoData] + public async Task PostRegisterSendEmailVerification_WhenFeatureFlagDisabled_PassesNullFromMarketingToCommandAsync( + string email, string name, bool receiveMarketingEmails) + { + // Arrange + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + FromMarketing = MarketingInitiativeConstants.Premium, // model includes FromMarketing: "premium" + }; + + _featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(false); + + // Act + await _sut.PostRegisterSendVerificationEmail(model); + + // Assert + await _sendVerificationEmailForRegistrationCommand.Received(1) + .Run(email, name, receiveMarketingEmails, null); // fromMarketing gets ignored and null gets passed + } + [Theory, BitAutoData] public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser( string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey, diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 97a836cf44..3c0b551908 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -35,7 +35,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase // This allows us to use the official registration flow SubstituteService(service => { - service.SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Any()) + service.SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(Task.CompletedTask) .AndDoes(call => {