Skip to content

Commit 5fb69e4

Browse files
authored
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"`
1 parent 9131427 commit 5fb69e4

File tree

10 files changed

+119
-29
lines changed

10 files changed

+119
-29
lines changed

src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ public class RegisterVerifyEmail : BaseMailModel
1515
// so we must land on a redirect connector which will redirect to the finish signup page.
1616
// Note 3: The use of a fragment to indicate the redirect url is to prevent the query string from being logged by
1717
// proxies and servers. It also helps reduce open redirect vulnerabilities.
18-
public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true",
18+
public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true{3}",
1919
WebVaultUrl,
2020
Token,
21-
Email);
21+
Email,
22+
!string.IsNullOrEmpty(FromMarketing) ? $"&fromMarketing={FromMarketing}" : string.Empty);
2223

2324
public string Token { get; set; }
2425
public string Email { get; set; }
26+
public string FromMarketing { get; set; }
2527
}

src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ namespace Bit.Core.Auth.UserFeatures.Registration;
33

44
public interface ISendVerificationEmailForRegistrationCommand
55
{
6-
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails);
6+
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing);
77
}

src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public SendVerificationEmailForRegistrationCommand(
4444

4545
}
4646

47-
public async Task<string?> Run(string email, string? name, bool receiveMarketingEmails)
47+
public async Task<string?> Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing)
4848
{
4949
if (_globalSettings.DisableUserRegistration)
5050
{
@@ -92,7 +92,7 @@ public SendVerificationEmailForRegistrationCommand(
9292
// If the user doesn't exist, create a new EmailVerificationTokenable and send the user
9393
// an email with a link to verify their email address
9494
var token = GenerateToken(email, name, receiveMarketingEmails);
95-
await _mailService.SendRegistrationVerificationEmailAsync(email, token);
95+
await _mailService.SendRegistrationVerificationEmailAsync(email, token, fromMarketing);
9696
}
9797

9898
// User exists but we will return a 200 regardless of whether the email was sent or not; so return null

src/Core/Platform/Mail/HandlebarsMailService.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,16 @@ public async Task SendVerifyEmailEmailAsync(string email, Guid userId, string to
7878
await _mailDeliveryService.SendEmailAsync(message);
7979
}
8080

81-
public async Task SendRegistrationVerificationEmailAsync(string email, string token)
81+
public async Task SendRegistrationVerificationEmailAsync(string email, string token, string? fromMarketing)
8282
{
8383
var message = CreateDefaultMessage("Verify Your Email", email);
8484
var model = new RegisterVerifyEmail
8585
{
8686
Token = WebUtility.UrlEncode(token),
8787
Email = WebUtility.UrlEncode(email),
8888
WebVaultUrl = _globalSettings.BaseServiceUri.Vault,
89-
SiteName = _globalSettings.SiteName
89+
SiteName = _globalSettings.SiteName,
90+
FromMarketing = WebUtility.UrlEncode(fromMarketing),
9091
};
9192
await AddMessageContentAsync(message, "Auth.RegistrationVerifyEmail", model);
9293
message.MetaData.Add("SendGridBypassListManagement", true);

src/Core/Platform/Mail/IMailService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public interface IMailService
3838
/// <returns>Task</returns>
3939
Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName);
4040
Task SendVerifyEmailEmailAsync(string email, Guid userId, string token);
41-
Task SendRegistrationVerificationEmailAsync(string email, string token);
41+
Task SendRegistrationVerificationEmailAsync(string email, string token, string? fromMarketing);
4242
Task SendTrialInitiationSignupEmailAsync(
4343
bool isExistingUser,
4444
string email,

src/Core/Platform/Mail/NoopMailService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public Task SendVerifyEmailEmailAsync(string email, Guid userId, string hint)
2626
return Task.FromResult(0);
2727
}
2828

29-
public Task SendRegistrationVerificationEmailAsync(string email, string hint)
29+
public Task SendRegistrationVerificationEmailAsync(string email, string hint, string? fromMarketing)
3030
{
3131
return Task.FromResult(0);
3232
}

src/Identity/Controllers/AccountsController.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,12 @@ GlobalSettings globalSettings
109109
[HttpPost("register/send-verification-email")]
110110
public async Task<IActionResult> PostRegisterSendVerificationEmail([FromBody] RegisterSendVerificationEmailRequestModel model)
111111
{
112+
// Only pass fromMarketing if the feature flag is enabled
113+
var isMarketingFeatureEnabled = _featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow);
114+
var fromMarketing = isMarketingFeatureEnabled ? model.FromMarketing : null;
115+
112116
var token = await _sendVerificationEmailForRegistrationCommand.Run(model.Email, model.Name,
113-
model.ReceiveMarketingEmails);
117+
model.ReceiveMarketingEmails, fromMarketing);
114118

115119
if (token != null)
116120
{

test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Bit.Core.Auth.Models.Business.Tokenables;
1+
using Bit.Core.Auth.Models.Api.Request.Accounts;
2+
using Bit.Core.Auth.Models.Business.Tokenables;
23
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
34
using Bit.Core.Entities;
45
using Bit.Core.Exceptions;
@@ -40,22 +41,55 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenIsNewUserAndEn
4041
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
4142
.Returns(false);
4243

43-
sutProvider.GetDependency<IMailService>()
44-
.SendRegistrationVerificationEmailAsync(email, Arg.Any<string>())
45-
.Returns(Task.CompletedTask);
44+
var mockedToken = "token";
45+
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
46+
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
47+
.Returns(mockedToken);
48+
49+
// Act
50+
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);
51+
52+
// Assert
53+
await sutProvider.GetDependency<IMailService>()
54+
.Received(1)
55+
.SendRegistrationVerificationEmailAsync(email, mockedToken, null);
56+
Assert.Null(result);
57+
}
58+
59+
[Theory]
60+
[BitAutoData]
61+
public async Task SendVerificationEmailForRegistrationCommand_WhenFromMarketingIsPremium_SendsEmailWithMarketingParameterAndReturnsNull(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
62+
string email, string name, bool receiveMarketingEmails)
63+
{
64+
// Arrange
65+
sutProvider.GetDependency<IUserRepository>()
66+
.GetByEmailAsync(email)
67+
.ReturnsNull();
68+
69+
sutProvider.GetDependency<GlobalSettings>()
70+
.EnableEmailVerification = true;
71+
72+
sutProvider.GetDependency<GlobalSettings>()
73+
.DisableUserRegistration = false;
74+
75+
sutProvider.GetDependency<IOrganizationDomainRepository>()
76+
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
77+
.Returns(false);
4678

4779
var mockedToken = "token";
4880
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
4981
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
5082
.Returns(mockedToken);
5183

84+
var fromMarketing = MarketingInitiativeConstants.Premium;
85+
5286
// Act
53-
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
87+
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, fromMarketing);
5488

5589
// Assert
5690
await sutProvider.GetDependency<IMailService>()
5791
.Received(1)
58-
.SendRegistrationVerificationEmailAsync(email, mockedToken);
92+
.SendRegistrationVerificationEmailAsync(email, mockedToken, fromMarketing);
5993
Assert.Null(result);
6094
}
6195

@@ -87,12 +121,12 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenIsExistingUser
87121
.Returns(mockedToken);
88122

89123
// Act
90-
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
124+
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);
91125

92126
// Assert
93127
await sutProvider.GetDependency<IMailService>()
94128
.DidNotReceive()
95-
.SendRegistrationVerificationEmailAsync(email, mockedToken);
129+
.SendRegistrationVerificationEmailAsync(email, mockedToken, null);
96130
Assert.Null(result);
97131
}
98132

@@ -124,7 +158,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenIsNewUserAndEn
124158
.Returns(mockedToken);
125159

126160
// Act
127-
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
161+
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);
128162

129163
// Assert
130164
Assert.Equal(mockedToken, result);
@@ -140,7 +174,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenOpenRegistrati
140174
.DisableUserRegistration = true;
141175

142176
// Act & Assert
143-
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
177+
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
144178
}
145179

146180
[Theory]
@@ -166,7 +200,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenIsExistingUser
166200
.Returns(false);
167201

168202
// Act & Assert
169-
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
203+
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
170204
}
171205

172206
[Theory]
@@ -177,7 +211,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenNullEmail_Thro
177211
sutProvider.GetDependency<GlobalSettings>()
178212
.DisableUserRegistration = false;
179213

180-
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails));
214+
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails, null));
181215
}
182216

183217
[Theory]
@@ -187,7 +221,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenEmptyEmail_Thr
187221
{
188222
sutProvider.GetDependency<GlobalSettings>()
189223
.DisableUserRegistration = false;
190-
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails));
224+
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails, null));
191225
}
192226

193227
[Theory]
@@ -210,7 +244,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenBlockedDomain_
210244
.Returns(true);
211245

212246
// Act & Assert
213-
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
247+
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
214248
Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message);
215249
}
216250

@@ -246,7 +280,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenAllowedDomain_
246280
.Returns(mockedToken);
247281

248282
// Act
249-
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
283+
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);
250284

251285
// Assert
252286
Assert.Equal(mockedToken, result);
@@ -270,7 +304,7 @@ public async Task SendVerificationEmailForRegistrationCommand_InvalidEmailFormat
270304

271305
// Act & Assert
272306
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
273-
sutProvider.Sut.Run(email, name, receiveMarketingEmails));
307+
sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
274308
Assert.Equal("Invalid email address format.", exception.Message);
275309
}
276310
}

test/Identity.Test/Controllers/AccountsControllerTests.cs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ public async Task PostRegisterSendEmailVerification_WhenTokenReturnedFromCommand
241241

242242
var token = "fakeToken";
243243

244-
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).Returns(token);
244+
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).Returns(token);
245245

246246
// Act
247247
var result = await _sut.PostRegisterSendVerificationEmail(model);
@@ -264,7 +264,7 @@ public async Task PostRegisterSendEmailVerification_WhenNoTokenIsReturnedFromCom
264264
ReceiveMarketingEmails = receiveMarketingEmails
265265
};
266266

267-
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).ReturnsNull();
267+
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).ReturnsNull();
268268

269269
// Act
270270
var result = await _sut.PostRegisterSendVerificationEmail(model);
@@ -274,6 +274,55 @@ public async Task PostRegisterSendEmailVerification_WhenNoTokenIsReturnedFromCom
274274
Assert.Equal(204, noContentResult.StatusCode);
275275
}
276276

277+
[Theory]
278+
[BitAutoData]
279+
public async Task PostRegisterSendEmailVerification_WhenFeatureFlagEnabled_PassesFromMarketingToCommandAsync(
280+
string email, string name, bool receiveMarketingEmails)
281+
{
282+
// Arrange
283+
var fromMarketing = MarketingInitiativeConstants.Premium;
284+
var model = new RegisterSendVerificationEmailRequestModel
285+
{
286+
Email = email,
287+
Name = name,
288+
ReceiveMarketingEmails = receiveMarketingEmails,
289+
FromMarketing = fromMarketing,
290+
};
291+
292+
_featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(true);
293+
294+
// Act
295+
await _sut.PostRegisterSendVerificationEmail(model);
296+
297+
// Assert
298+
await _sendVerificationEmailForRegistrationCommand.Received(1)
299+
.Run(email, name, receiveMarketingEmails, fromMarketing);
300+
}
301+
302+
[Theory]
303+
[BitAutoData]
304+
public async Task PostRegisterSendEmailVerification_WhenFeatureFlagDisabled_PassesNullFromMarketingToCommandAsync(
305+
string email, string name, bool receiveMarketingEmails)
306+
{
307+
// Arrange
308+
var model = new RegisterSendVerificationEmailRequestModel
309+
{
310+
Email = email,
311+
Name = name,
312+
ReceiveMarketingEmails = receiveMarketingEmails,
313+
FromMarketing = MarketingInitiativeConstants.Premium, // model includes FromMarketing: "premium"
314+
};
315+
316+
_featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(false);
317+
318+
// Act
319+
await _sut.PostRegisterSendVerificationEmail(model);
320+
321+
// Assert
322+
await _sendVerificationEmailForRegistrationCommand.Received(1)
323+
.Run(email, name, receiveMarketingEmails, null); // fromMarketing gets ignored and null gets passed
324+
}
325+
277326
[Theory, BitAutoData]
278327
public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser(
279328
string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey,

test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
3535
// This allows us to use the official registration flow
3636
SubstituteService<IMailService>(service =>
3737
{
38-
service.SendRegistrationVerificationEmailAsync(Arg.Any<string>(), Arg.Any<string>())
38+
service.SendRegistrationVerificationEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
3939
.ReturnsForAnyArgs(Task.CompletedTask)
4040
.AndDoes(call =>
4141
{

0 commit comments

Comments
 (0)