1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

feat(marketing-initiated-premium): (Auth) [PM-27541] Add optional marketing param to email verification link (#6604)

Adds an optional `&fromMarketing=premium` query parameter to the verification email link.

Feature flag: `"pm-26140-marketing-initiated-premium-flow"`
This commit is contained in:
rr-bw
2025-11-24 15:06:16 -08:00
committed by GitHub
parent 9131427622
commit 5fb69e42b0
10 changed files with 120 additions and 30 deletions

View File

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

View File

@@ -3,5 +3,5 @@ namespace Bit.Core.Auth.UserFeatures.Registration;
public interface ISendVerificationEmailForRegistrationCommand
{
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails);
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing);
}

View File

@@ -44,7 +44,7 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
}
public async Task<string?> Run(string email, string? name, bool receiveMarketingEmails)
public async Task<string?> 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

View File

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

View File

@@ -38,7 +38,7 @@ public interface IMailService
/// <returns>Task</returns>
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,

View File

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

View File

@@ -109,8 +109,12 @@ public class AccountsController : Controller
[HttpPost("register/send-verification-email")]
public async Task<IActionResult> 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)
{

View File

@@ -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<string>())
.Returns(false);
sutProvider.GetDependency<IMailService>()
.SendRegistrationVerificationEmailAsync(email, Arg.Any<string>())
.Returns(Task.CompletedTask);
var mockedToken = "token";
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
.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<IMailService>()
.Received(1)
.SendRegistrationVerificationEmailAsync(email, mockedToken);
.SendRegistrationVerificationEmailAsync(email, mockedToken, null);
Assert.Null(result);
}
[Theory]
[BitAutoData]
public async Task SendVerificationEmailForRegistrationCommand_WhenFromMarketingIsPremium_SendsEmailWithMarketingParameterAndReturnsNull(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
string email, string name, bool receiveMarketingEmails)
{
// Arrange
sutProvider.GetDependency<IUserRepository>()
.GetByEmailAsync(email)
.ReturnsNull();
sutProvider.GetDependency<GlobalSettings>()
.EnableEmailVerification = true;
sutProvider.GetDependency<GlobalSettings>()
.DisableUserRegistration = false;
sutProvider.GetDependency<IOrganizationDomainRepository>()
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
.Returns(false);
var mockedToken = "token";
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(mockedToken);
var fromMarketing = MarketingInitiativeConstants.Premium;
// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, fromMarketing);
// Assert
await sutProvider.GetDependency<IMailService>()
.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<IMailService>()
.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<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
}
[Theory]
@@ -166,7 +200,7 @@ public class SendVerificationEmailForRegistrationCommandTests
.Returns(false);
// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
}
[Theory]
@@ -177,7 +211,7 @@ public class SendVerificationEmailForRegistrationCommandTests
sutProvider.GetDependency<GlobalSettings>()
.DisableUserRegistration = false;
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails));
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails, null));
}
[Theory]
@@ -187,7 +221,7 @@ public class SendVerificationEmailForRegistrationCommandTests
{
sutProvider.GetDependency<GlobalSettings>()
.DisableUserRegistration = false;
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails));
await Assert.ThrowsAsync<ArgumentNullException>(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<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<BadRequestException>(() =>
sutProvider.Sut.Run(email, name, receiveMarketingEmails));
sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
Assert.Equal("Invalid email address format.", exception.Message);
}
}

View File

@@ -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,

View File

@@ -35,7 +35,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
// This allows us to use the official registration flow
SubstituteService<IMailService>(service =>
{
service.SendRegistrationVerificationEmailAsync(Arg.Any<string>(), Arg.Any<string>())
service.SendRegistrationVerificationEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.ReturnsForAnyArgs(Task.CompletedTask)
.AndDoes(call =>
{