feat: Email opt-in. #2452
feat: Email opt-in. #2452
Conversation
| } | ||
|
|
||
| MarketingEmailClient.prototype = { | ||
| _request: function (path, type, accessToken, data, headers) { |
rfk
May 21, 2015
Member
Looks like this takes an existing accessToken. What scopes does this token have? ISTM we may want to use a special-purpose scope for this, e.g. basket instead of the usual profile scope.
Looks like this takes an existing accessToken. What scopes does this token have? ISTM we may want to use a special-purpose scope for this, e.g. basket instead of the usual profile scope.
f220102
to
f1dcb00
| @@ -270,6 +274,12 @@ function ( | |||
| }); | |||
| }, | |||
|
|
|||
| initializeMarketingEmailClient: function () { | |||
shane-tomlinson
May 21, 2015
Author
Member
@zaach - Except for fxa-client, I don't think any of these clients save any state. Instead of creating all these clients on startup and passing them around, we could just pass in a list of service urls and have the callers of the clients create them when needed? Dunno.
@zaach - Except for fxa-client, I don't think any of these clients save any state. Instead of creating all these clients on startup and passing them around, we could just pass in a list of service urls and have the callers of the clients create them when needed? Dunno.
zaach
May 27, 2015
Contributor
👍 👍 We could allow both ways (since passing clients is still useful for testing) and default to using the URL list.
shane-tomlinson
May 27, 2015
Author
Member
I dig it.
I dig it.
| function withMarketingEmailClient(method) { | ||
| return function () { | ||
| var self = this; | ||
| return self.getOAuthTokenFromSessionToken(self.get('sessionToken'), 'basket') |
shane-tomlinson
May 21, 2015
Author
Member
@rfk - is this correct?
I've been waffling whether email marketing related functionality should live here and an email-marketing-client is passed in, or whether the email marketing client should be passed to the views and its functions accept an account. This seems like the the more natural of the two because it means account callers do not have to worry about the email marketing client, but I could be convinced otherwise.
@rfk - is this correct?
I've been waffling whether email marketing related functionality should live here and an email-marketing-client is passed in, or whether the email marketing client should be passed to the views and its functions accept an account. This seems like the the more natural of the two because it means account callers do not have to worry about the email marketing client, but I could be convinced otherwise.
rfk
May 22, 2015
Member
We might consider "basket:write" to allow modification of email marketing preferences, by analogy with "profile:write". We don't have much precedent for distinguishing read-only from read/write in scopes, but it seems worthwhile to follow the only precedent we have.
We might consider "basket:write" to allow modification of email marketing preferences, by analogy with "profile:write". We don't have much precedent for distinguishing read-only from read/write in scopes, but it seems worthwhile to follow the only precedent we have.
rfk
May 22, 2015
Member
I've been waffling whether email marketing related functionality should live here and an email-marketing-client is
passed in, or whether the email marketing client should be passed to the views and its functions accept an account.
Tough call.
In isolation, I'd slightly prefer to have an "email marketing client" that takes the account to operate on as an argument, either in the constructor or in each method call. But I have a strong default preference for keeping a low method count on each class. People have criticized my style in the past for preferring "manager objects" a little to much...
But given that the account already handles profile information by exposing profile-manipulation methods that call through to an underlying profile client, I think you made the right call here by keeping the approach consistent. We can consider factoring both of these out if the account object becomes too unweildly under the weight of all its special-purpose methods.
(It's not just about cognitive load either - one concrete downside of making these all methods on the Account, is that we must load the code for all of them even if the user never sees anything to do with their profile or email marketing prefs. Something to think about in the future if we ever want to chunk up our downloads a little more).
I've been waffling whether email marketing related functionality should live here and an email-marketing-client is
passed in, or whether the email marketing client should be passed to the views and its functions accept an account.
Tough call.
In isolation, I'd slightly prefer to have an "email marketing client" that takes the account to operate on as an argument, either in the constructor or in each method call. But I have a strong default preference for keeping a low method count on each class. People have criticized my style in the past for preferring "manager objects" a little to much...
But given that the account already handles profile information by exposing profile-manipulation methods that call through to an underlying profile client, I think you made the right call here by keeping the approach consistent. We can consider factoring both of these out if the account object becomes too unweildly under the weight of all its special-purpose methods.
(It's not just about cognitive load either - one concrete downside of making these all methods on the Account, is that we must load the code for all of them even if the user never sees anything to do with their profile or email marketing prefs. Something to think about in the future if we ever want to chunk up our downloads a little more).
rfk
May 22, 2015
Member
is this correct?
I realized I didn't answer this explicitly: yes, this looks correct, modulo potentially changing it to "basket:write".
An alternative approach would be to request a single accessToken with scope "profile:write basket:write" that we can persist to the account and use for both purposes. I can't think of compelling reasons to favour one over the other though.
is this correct?
I realized I didn't answer this explicitly: yes, this looks correct, modulo potentially changing it to "basket:write".
An alternative approach would be to request a single accessToken with scope "profile:write basket:write" that we can persist to the account and use for both purposes. I can't think of compelling reasons to favour one over the other though.
shane-tomlinson
May 23, 2015
Author
Member
(It's not just about cognitive load either - one concrete downside of making these all methods on the Account, is that we must load the code for all of them even if the user never sees anything to do with their profile or email marketing prefs. Something to think about in the future if we ever want to chunk up our downloads a little more).
I had a similar concern when putting together this patch. app-start.js creates several clients that are passed down through user.js to account.js, or passed into views. Except for fxa-js, none of these clients keep any state AFAICT, though they rely on information stored in the URLs returned from a call to the /config endpoint. It seems to me a more natural approach would be to pass around a "ServiceURLs" or "Configuration" object into User/Account, which could then create the clients as needed, reducing the number of objects instantiated on starutp.
(It's not just about cognitive load either - one concrete downside of making these all methods on the Account, is that we must load the code for all of them even if the user never sees anything to do with their profile or email marketing prefs. Something to think about in the future if we ever want to chunk up our downloads a little more).
I had a similar concern when putting together this patch. app-start.js creates several clients that are passed down through user.js to account.js, or passed into views. Except for fxa-js, none of these clients keep any state AFAICT, though they rely on information stored in the URLs returned from a call to the /config endpoint. It seems to me a more natural approach would be to pass around a "ServiceURLs" or "Configuration" object into User/Account, which could then create the clients as needed, reducing the number of objects instantiated on starutp.
| 'lib/constants', | ||
| 'lib/xhr' | ||
| ], | ||
| function (chai, sinon, MarketingEmailClient, p, Constants, Xhr) { |
pdehaan
May 21, 2015
Contributor
This should probably be xhr (lower) for consistency with other files above.
This should probably be xhr (lower) for consistency with other files above.
shane-tomlinson
May 23, 2015
Author
Member
👍
| VERIFICATION_REDIRECT_NO: 'no', | ||
|
|
||
| MARKETING_EMAIL_OAUTH_SCOPE: 'basket', | ||
| MARKETING_EMAIL_NEWSLETTER_ID: 'newsletter' |
rfk
May 22, 2015
Member
The production value for newsletter id will be "firefox-accounts-journey".
The production value for newsletter id will be "firefox-accounts-journey".
| lastLogin: undefined, | ||
| grantedPermissions: undefined | ||
| uid: undefined, | ||
| verified: undefined |
rfk
May 22, 2015
Member
alphabetization, nice :-)
alphabetization, nice :-)
| var self = this; | ||
| return self.getOAuthTokenFromSessionToken(self.get('sessionToken'), 'basket') | ||
| .then(function (accessToken) { | ||
| return self._marketingEmailClient[method](accessToken); |
rfk
May 22, 2015
Member
If this token is not persisted anywhere, please attempt to destroy it after you're done with it. Like session tokens, these currently live forever unless explicitly destroyed.
If this token is not persisted anywhere, please attempt to destroy it after you're done with it. Like session tokens, these currently live forever unless explicitly destroyed.
shane-tomlinson
May 23, 2015
Author
Member
Ah, I did not know.
Ah, I did not know.
| .then(function () { | ||
| var request = xhrMock.ajax.args[0][0]; | ||
| assert.equal(request.url, BASE_URL + '/unsubscribe'); | ||
| assert.equal(request.type, 'post'); |
rfk
May 22, 2015
Member
Is it worth checking (and can we easily check?) the expected request body as part of this test?
Is it worth checking (and can we easily check?) the expected request body as part of this test?
| optOut: function (accessToken) { | ||
| return this._request('/unsubscribe', 'post', accessToken, { | ||
| //jshint camelcase: false | ||
| newsletter_id: NEWSLETTER_ID |
rfk
May 22, 2015
Member
The body here will need to be { "newsletters": NEWSLETTER_ID }
The body here will need to be { "newsletters": NEWSLETTER_ID }
| optIn: function (accessToken) { | ||
| return this._request('/subscribe', 'post', accessToken, { | ||
| //jshint camelcase: false | ||
| newsletter_id: NEWSLETTER_ID |
rfk
May 22, 2015
Member
I did a bit of testing on these endpoints in production. They seem to expect a trailing slash, like "/subscribe/" rather than "/subscribe". The body here will need to be { "newsletters": NEWSLETTER_ID, "optin": "Y" }, with the assumption that the email will be pulled out of the authz header automatically.
I did a bit of testing on these endpoints in production. They seem to expect a trailing slash, like "/subscribe/" rather than "/subscribe". The body here will need to be { "newsletters": NEWSLETTER_ID, "optin": "Y" }, with the assumption that the email will be pulled out of the authz header automatically.
|
@shane-tomlinson this is looking great! |
| return self.getOAuthTokenFromSessionToken(self.get('sessionToken'), 'basket') | ||
| .then(function (accessToken) { | ||
| return self._marketingEmailClient[method](accessToken); | ||
| }); |
shane-tomlinson
May 23, 2015
Author
Member
For whatever reason, one thing that only occurred to me today is that I should probably call the client once to get all of the initial marketing email data, store that off into a model (either the account, or a separate email-preferences model that is related to the account), and use that one model to populate multiple fields on the /settings/communications_preferences page. Otherwise, two calls will be made to the backend to display that page, once for the user's opt-in preference, once for the user's Basket management URL.
For whatever reason, one thing that only occurred to me today is that I should probably call the client once to get all of the initial marketing email data, store that off into a model (either the account, or a separate email-preferences model that is related to the account), and use that one model to populate multiple fields on the /settings/communications_preferences page. Otherwise, two calls will be made to the backend to display that page, once for the user's opt-in preference, once for the user's Basket management URL.
| return self._getAccessTokenFromSessionToken(self.get('sessionToken')) | ||
| return self.getOAuthTokenFromSessionToken( | ||
| self.get('sessionToken'), | ||
| 'profile:write' |
shane-tomlinson
May 23, 2015
Author
Member
Extract the magic string.
Extract the magic string.
|
|
||
| <ul class="links"> | ||
| <li> | ||
| <a href="#">{{#t}}Email preferences{{/t}}</a> |
shane-tomlinson
May 23, 2015
Author
Member
Add the real link.
Add the real link.
| @@ -92,6 +93,34 @@ define([ | |||
| */ | |||
| getJSON: function (url, data, success) { | |||
| return p.jQueryXHR($.getJSON(url, data, success)); | |||
| }, | |||
|
|
|||
| oauthAjax: function (options) { | |||
shane-tomlinson
May 23, 2015
Author
Member
move this up below ajax
move this up below ajax
| }, | ||
|
|
||
| fetch: function (accessToken) { | ||
| return this._request('/lookup-user', 'get', accessToken); |
zaach
May 24, 2015
Contributor
Shouldn't this also pass in an email address? Same for the other API calls.
Shouldn't this also pass in an email address? Same for the other API calls.
zaach
May 24, 2015
Contributor
Ah, nevermind. I was looking at the current API docs. I assume basket's new API won't use that. (related: I noticed the oauth docs were out of date wrgt the email in the /verify response .)
Ah, nevermind. I was looking at the current API docs. I assume basket's new API won't use that. (related: I noticed the oauth docs were out of date wrgt the email in the /verify response .)
| optIn: function (accessToken, newsletterId) { | ||
| return this._request('/subscribe', 'post', accessToken, { | ||
| //jshint camelcase: false | ||
| newsletter_id: newsletterId |
zaach
May 24, 2015
Contributor
Do we need to pass in lang? Also optin=Y.
Do we need to pass in lang? Also optin=Y.
rfk
May 25, 2015
Member
For FxA users, their "lang" setting in basket is supposed to come from their FxA account data, and is populated by a background process that transfers FxA account data over to basket. There's some corner-cases here if we try to process the opt-in before the background transfer has completed.
I think the safest course of action will be to leave out "lang" on this request, and only enable the opt-in for en-US on initial release. That way if there's a race, the worst that happens is they get default english newsletters until we set their actual language preference, which is what they wanted anyway.
We'll need to sanity-check this with @pmclanahan.
For FxA users, their "lang" setting in basket is supposed to come from their FxA account data, and is populated by a background process that transfers FxA account data over to basket. There's some corner-cases here if we try to process the opt-in before the background transfer has completed.
I think the safest course of action will be to leave out "lang" on this request, and only enable the opt-in for en-US on initial release. That way if there's a race, the worst that happens is they get default english newsletters until we set their actual language preference, which is what they wanted anyway.
We'll need to sanity-check this with @pmclanahan.
| marketing_email_url: { | ||
| doc: 'The url of the Basket Marketing Email Server', | ||
| format: 'url', | ||
| default: 'http://127.0.0.1:1113', |
zaach
May 24, 2015
Contributor
This port is taken by one of the profile server helpers! How about 3031?
This port is taken by one of the profile server helpers! How about 3031?
0657be1
to
fb10e3a
|
|
||
| MARKETING_EMAIL_OAUTH_SCOPE: 'basket:write profile:email', | ||
| MARKETING_EMAIL_NEWSLETTER_ID: 'firefox-accounts-journey', | ||
| MARKETING_EMAIL_MANAGEMENT_BASE_URL: 'https://www.mozilla.org/newsletter/existing/' |
shane-tomlinson
May 25, 2015
Author
Member
This needs to come from configuration.
This needs to come from configuration.
625fb2c
to
d8ad411
669932b
to
9ef2b7a
| json: { | ||
| token: token | ||
| } | ||
| }, function (err, _, body) { |
pdehaan
May 28, 2015
Contributor
Not sure if this little _ variable guy will cause confusion if we end up requiring underscore above in the future. 💥
Not sure if this little _ variable guy will cause confusion if we end up requiring underscore above in the future.
|
Are we still planning on merging this for Train-38, or should we bump milestone to Train-39? |
35f8792
to
f136536
|
So, |
| t('Unsubscribe') : | ||
| t('Subscribe'); | ||
|
|
||
| self.$('button[type=submit]').text(buttonText); |
rfk
May 28, 2015
Member
This logic seems to be getting over-ridden by a re-render of the view. When I click "subscribe" I see the following sequence of button text:
- transitions to spinner while doing the request
- briefly changes to "Unsubscribe"
- changes back to "Subscribe" almost immediately
This logic seems to be getting over-ridden by a re-render of the view. When I click "subscribe" I see the following sequence of button text:
- transitions to spinner while doing the request
- briefly changes to "Unsubscribe"
- changes back to "Subscribe" almost immediately
zaach
May 28, 2015
Contributor
Yeah; I turned on the debugger and I'm not quite sure how the second render gets trigger after the form submit. I put a breakpoint in context but it doesn't get triggered in the render after the submit.
Yeah; I turned on the debugger and I'm not quite sure how the second render gets trigger after the form submit. I put a breakpoint in context but it doesn't get triggered in the render after the submit.
shane-tomlinson
May 28, 2015
Author
Member
a dirty hack is to wrap this line in a setTimeout w/ 100ms delay. dirty. (I'm not online)
a dirty hack is to wrap this line in a setTimeout w/ 100ms delay. dirty. (I'm not online)
shane-tomlinson
May 28, 2015
Author
Member
@zaach - perhaps that's a better solution, re-render with a success ephemeral message. It looks a bit goofy because the success message closes and re-opens every time the user clicks the button, but the button state is correct.
diff --git a/app/scripts/views/settings/communication_preferences.js b/app/scripts/views/settings/communication_preferences.js
index 50712cd..8a13780 100644
--- a/app/scripts/views/settings/communication_preferences.js
+++ b/app/scripts/views/settings/communication_preferences.js
@@ -90,13 +90,8 @@ function (Cocktail, Xss, Constants, MarketingEmailErrors, BaseView, FormView,
var successMessage = isOptedIn ?
t('Subscribed successfully') :
t('Unsubscribed successfully');
- self.displaySuccess(successMessage);
-
- var buttonText = isOptedIn ?
- t('Unsubscribe') :
- t('Subscribe');
-
- self.$('button[type=submit]').text(buttonText);
+ self.ephemeralMessages.set('success', successMessage);
+ self.render();
}, function (err) {
self.displayError(err);
});
@zaach - perhaps that's a better solution, re-render with a success ephemeral message. It looks a bit goofy because the success message closes and re-opens every time the user clicks the button, but the button state is correct.
diff --git a/app/scripts/views/settings/communication_preferences.js b/app/scripts/views/settings/communication_preferences.js
index 50712cd..8a13780 100644
--- a/app/scripts/views/settings/communication_preferences.js
+++ b/app/scripts/views/settings/communication_preferences.js
@@ -90,13 +90,8 @@ function (Cocktail, Xss, Constants, MarketingEmailErrors, BaseView, FormView,
var successMessage = isOptedIn ?
t('Subscribed successfully') :
t('Unsubscribed successfully');
- self.displaySuccess(successMessage);
-
- var buttonText = isOptedIn ?
- t('Unsubscribe') :
- t('Subscribe');
-
- self.$('button[type=submit]').text(buttonText);
+ self.ephemeralMessages.set('success', successMessage);
+ self.render();
}, function (err) {
self.displayError(err);
});
zaach
May 28, 2015
Contributor
Ahh. Maybe slightly cleaner is to have the progress indicator skip restoring the contents of the button if it has changed since the progress indicator was initiated.
Ahh. Maybe slightly cleaner is to have the progress indicator skip restoring the contents of the button if it has changed since the progress indicator was initiated.
shane-tomlinson
May 28, 2015
Author
Member
+ self.render();
And if you add a return on the front of that, the unit tests still pass.
+ return self.render();
+ self.render();And if you add a return on the front of that, the unit tests still pass.
+ return self.render();| @@ -60,6 +64,17 @@ function (Cocktail, FormView, BaseView, CompleteSignUpTemplate, | |||
| return self.fxaClient.verifyCode(uid, code) | |||
| .then(function () { | |||
| self.logScreenEvent('verification.success'); | |||
| var account = self.getAccount(); | |||
|
|
|||
| if (account.get('needsOptedInToMarketingEmail')) { | |||
zaach
May 28, 2015
Contributor
If the user is verifying in a different browser than they signed up with, this will be undefined. We may have to use the resume token if we want to reliably honor the opt-in.
If the user is verifying in a different browser than they signed up with, this will be undefined. We may have to use the resume token if we want to reliably honor the opt-in.
rfk
May 29, 2015
Member
In initial discussions in @shane-tomlinson we decided that it's fine to punt on the different-browser case for the initial version; we'll lose some percentage of people who might otherwise have opted-in, but at least it fails "safely" in that everything else still works.
In initial discussions in @shane-tomlinson we decided that it's fine to punt on the different-browser case for the initial version; we'll lose some percentage of people who might otherwise have opted-in, but at least it fails "safely" in that everything else still works.
rfk
May 29, 2015
Member
(we should of course follow up on it for subsequent versions)
(we should of course follow up on it for subsequent versions)
shane-tomlinson
May 29, 2015
Author
Member
An OAuth token needs to be generated for opt-in, in a 2nd browser the user will not have a sessionToken that can be used to generate an OAuth token. We can get around this in a couple of ways:
- Have the auth server fetch an oauth token and opt in on behalf of the user.
- Have the user authenticate in the 2nd browser, an oauth token can then be created, and the user opts in.
There are probably other ways, but of those two, I'm partial to the 2nd.
An OAuth token needs to be generated for opt-in, in a 2nd browser the user will not have a sessionToken that can be used to generate an OAuth token. We can get around this in a couple of ways:
- Have the auth server fetch an oauth token and opt in on behalf of the user.
- Have the user authenticate in the 2nd browser, an oauth token can then be created, and the user opts in.
There are probably other ways, but of those two, I'm partial to the 2nd.
zaach
May 29, 2015
Contributor
We could attempt to opt-in from the original confirmation tab also, as a stop gap. But I wonder how many people verify in a different browser? I didn't see any explicit metrics being collected around that– but it would be useful to know.
We could attempt to opt-in from the original confirmation tab also, as a stop gap. But I wonder how many people verify in a different browser? I didn't see any explicit metrics being collected around that– but it would be useful to know.
|
Hmm, actually I have one browser window where the mis-alignment only triggers if I shrink the browser window to a certain size and goes away when I make it bigger, and another window where it's always like that across window resizes and page reloads. |
|
Merging now. We'll follow up with PRs to fix the remaining issues. |
|
Awesome!! Will keep an eye on TeamCity |



ref #2443