Added sender email verification flow for newsletters

refs https://github.com/TryGhost/Team/issues/584
refs https://github.com/TryGhost/Team/issues/1498

- updated newsletter save routine in `edit-newsletter` modal to open an email confirmation modal if the API indicates one was sent
  - modal indicates that the previously set or default email will continue to be used until verified
  - response from API when saving looks like `{newsletters: [{...}], meta: {sent_email_verification: ['sender_name]}}`
  - added custom newsletter serializer and updated model so that the `meta` property returned in the response when saving posts is exposed
    - Ember Data only exposes meta on array-response find/query methods
    - https://github.com/emberjs/data/issues/2905
- added `/settings/members-email-labs/?verifyEmail=xyz` query param handling
  - opens email verification modal if param is set and instantly clears the query param to avoid problems with sticky params
  - when the modal opens it makes a `PUT /newsletters/verify-email/` request with the token in the body params, on the API side this works the same as a newsletter update request returning the fully updated newsletter record which is then pushed into the store
- removed unused from/reply address code from `<Settings::MembersEmailLabs>` component and controller
  - setting the values now handled per-newsletter in the edit-newsletter modal
  - verifying email change is handled in the members-email-labs controller
- fixed mirage not outputting pluralized root for "singular" endpoints such as POST/PUT requests to better match our API behaviour
This commit is contained in:
Kevin Ansfield 2022-04-13 19:34:48 +01:00
parent cc6c0acf55
commit e398557a75
13 changed files with 272 additions and 112 deletions

View File

@ -1,9 +1,13 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import ConfirmNewsletterEmailModal from './edit-newsletter/confirm-newsletter-email';
import {action} from '@ember/object'; import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency'; import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking'; import {tracked} from '@glimmer/tracking';
export default class EditNewsletterModal extends Component { export default class EditNewsletterModal extends Component {
@service modals;
static modalOptions = { static modalOptions = {
className: 'fullscreen-modal-full-overlay fullscreen-modal-portal-settings' className: 'fullscreen-modal-full-overlay fullscreen-modal-portal-settings'
}; };
@ -31,8 +35,17 @@ export default class EditNewsletterModal extends Component {
@task @task
*saveTask() { *saveTask() {
try { try {
const newEmail = this.args.data.newsletter.senderEmail;
const result = yield this.args.data.newsletter.save(); const result = yield this.args.data.newsletter.save();
if (result._meta?.sent_email_verification) {
yield this.modals.open(ConfirmNewsletterEmailModal, {
newEmail,
currentEmail: this.args.data.newsletter.senderEmail
});
}
this.args.data.afterSave?.(result); this.args.data.afterSave?.(result);
return result; return result;

View File

@ -0,0 +1,26 @@
<div class="modal-content">
<header class="modal-header" data-test-modal="confirm-newsletter-email">
<h1>Confirm newsletter email address</h1>
</header>
<button type="button" class="close" role="button" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>
We've sent a confirmation email to <strong>{{@data.newEmail}}</strong>.
Until the address has been verified newsletters will be sent from the
{{if @data.currentEmail "previous" "default"}} email address
({{full-email-address (or @data.currentEmail "noreply")}}).
</p>
</div>
<div class="modal-footer">
<button
type="button"
class="gh-btn"
{{on "click" @close}}
{{on-key "Enter"}}
>
<span>Ok</span>
</button>
</div>
</div>

View File

@ -0,0 +1,34 @@
<div class="modal-content">
<header class="modal-header" data-test-modal="verify-newsletter-email">
<h1>Verifying newsletter email address</h1>
</header>
<button type="button" class="close" role="button" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
{{#if this.verifyEmailTask.isRunning}}
<div class="flex justify-center flex-auto">
<div class="gh-loading-spinner"></div>
</div>
{{else if this.newsletter}}
<p>
Success! From address for newsletter
"<LinkTo @route="settings.members-email-labs.edit-newsletter" @model={{this.newsletter.id}}>{{this.newsletter.name}}</LinkTo>"
changed to <strong>{{this.newsletter.senderEmail}}</strong>
</p>
{{else if this.error}}
<p>Verification failed:</p>
<p>{{this.error}}</p>
{{/if}}
</div>
<div class="modal-footer">
<button
type="button"
class="gh-btn"
{{on "click" @close}}
{{on-key "Enter"}}
>
<span>Ok</span>
</button>
</div>
</div>

View File

@ -0,0 +1,49 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class VerifyNewsletterEmail extends Component {
@service ajax;
@service ghostPaths;
@service router;
@service store;
@tracked error = null;
@tracked newsletter = null;
constructor() {
super(...arguments);
this.verifyEmailTask.perform(this.args.data.token);
this.router.on('routeDidChange', this.handleRouteChange);
}
willDestroy() {
super.willDestroy(...arguments);
this.router.off('routeDidChange', this.handleRouteChange);
}
@task
*verifyEmailTask(token) {
try {
const url = this.ghostPaths.url.api('newsletters', 'verify-email');
const response = yield this.ajax.put(url, {data: {token}});
if (response.newsletters) {
this.store.pushPayload('newsletter', response);
const newsletter = this.store.peekRecord('newsletter', response.newsletters[0].id);
this.newsletter = newsletter;
}
} catch (e) {
this.error = e.message;
}
}
@action
handleRouteChange() {
this.args.close();
}
}

View File

@ -1,7 +1,6 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import {action} from '@ember/object'; import {action} from '@ember/object';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking'; import {tracked} from '@glimmer/tracking';
const US = {flag: '🇺🇸', name: 'US', baseUrl: 'https://api.mailgun.net/v3'}; const US = {flag: '🇺🇸', name: 'US', baseUrl: 'https://api.mailgun.net/v3'};
@ -9,8 +8,6 @@ const EU = {flag: '🇪🇺', name: 'EU', baseUrl: 'https://api.eu.mailgun.net/v
export default class MembersEmailLabs extends Component { export default class MembersEmailLabs extends Component {
@service config; @service config;
@service ghostPaths;
@service ajax;
@service settings; @service settings;
// set recipientsSelectValue as a static property because within this // set recipientsSelectValue as a static property because within this
@ -19,41 +16,12 @@ export default class MembersEmailLabs extends Component {
// from settings as it would equate to "none" // from settings as it would equate to "none"
@tracked recipientsSelectValue = this._getDerivedRecipientsSelectValue(); @tracked recipientsSelectValue = this._getDerivedRecipientsSelectValue();
@tracked showFromAddressConfirmation = false;
mailgunRegions = [US, EU]; mailgunRegions = [US, EU];
replyAddresses = [
{
label: 'Newsletter email address (' + this.fromAddress + ')',
value: 'newsletter'
},
{
label: 'Support email address (' + this.supportAddress + ')',
value: 'support'
}
];
get emailNewsletterEnabled() { get emailNewsletterEnabled() {
return this.settings.get('editorDefaultEmailRecipients') !== 'disabled'; return this.settings.get('editorDefaultEmailRecipients') !== 'disabled';
} }
get emailPreviewVisible() {
return this.recipientsSelectValue !== 'none';
}
get selectedReplyAddress() {
return this.replyAddresses.findBy('value', this.settings.get('membersReplyAddress'));
}
get disableUpdateFromAddressButton() {
const savedFromAddress = this.settings.get('membersFromAddress') || '';
if (!savedFromAddress.includes('@') && this.config.emailDomain) {
return !this.fromAddress || (this.fromAddress === `${savedFromAddress}@${this.config.emailDomain}`);
}
return !this.fromAddress || (this.fromAddress === savedFromAddress);
}
get mailgunRegion() { get mailgunRegion() {
if (!this.settings.get('mailgunBaseUrl')) { if (!this.settings.get('mailgunBaseUrl')) {
return US; return US;
@ -72,11 +40,6 @@ export default class MembersEmailLabs extends Component {
}; };
} }
@action
toggleFromAddressConfirmation() {
this.showFromAddressConfirmation = !this.showFromAddressConfirmation;
}
@action @action
setMailgunDomain(event) { setMailgunDomain(event) {
this.settings.set('mailgunDomain', event.target.value); this.settings.set('mailgunDomain', event.target.value);
@ -98,11 +61,6 @@ export default class MembersEmailLabs extends Component {
this.settings.set('mailgunBaseUrl', region.baseUrl); this.settings.set('mailgunBaseUrl', region.baseUrl);
} }
@action
setFromAddress(fromAddress) {
this.setEmailAddress('fromAddress', fromAddress);
}
@action @action
toggleEmailTrackOpens(event) { toggleEmailTrackOpens(event) {
if (event) { if (event) {
@ -129,13 +87,6 @@ export default class MembersEmailLabs extends Component {
this.recipientsSelectValue = this._getDerivedRecipientsSelectValue(); this.recipientsSelectValue = this._getDerivedRecipientsSelectValue();
} }
@action
setReplyAddress(event) {
const newReplyAddress = event.value;
this.settings.set('membersReplyAddress', newReplyAddress);
}
@action @action
setDefaultEmailRecipients(value) { setDefaultEmailRecipients(value) {
// Update the underlying setting properties to match the selected recipients option // Update the underlying setting properties to match the selected recipients option
@ -169,24 +120,6 @@ export default class MembersEmailLabs extends Component {
this.settings.set('editorDefaultEmailRecipientsFilter', filter); this.settings.set('editorDefaultEmailRecipientsFilter', filter);
} }
@task({drop: true})
*updateFromAddress() {
let url = this.ghostPaths.url.api('/settings/members/email');
try {
const response = yield this.ajax.post(url, {
data: {
email: this.fromAddress,
type: 'fromAddressUpdate'
}
});
this.toggleFromAddressConfirmation();
return response;
} catch (e) {
// Failed to send email, retry
return false;
}
}
_getDerivedRecipientsSelectValue() { _getDerivedRecipientsSelectValue() {
const defaultEmailRecipients = this.settings.get('editorDefaultEmailRecipients'); const defaultEmailRecipients = this.settings.get('editorDefaultEmailRecipients');
const defaultEmailRecipientsFilter = this.settings.get('editorDefaultEmailRecipientsFilter'); const defaultEmailRecipientsFilter = this.settings.get('editorDefaultEmailRecipientsFilter');

View File

@ -1,37 +1,14 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import {action} from '@ember/object';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency'; import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking'; import {tracked} from '@glimmer/tracking';
export default class MembersEmailLabsController extends Controller { export default class MembersEmailLabsController extends Controller {
@service config;
@service session;
@service settings; @service settings;
// from/supportAddress are set here so that they can be reset to saved values on save queryParams = ['verifyEmail'];
// to avoid it looking like they've been saved when they have a separate update process
@tracked fromAddress = '';
@tracked supportAddress = '';
@action @tracked verifyEmail = null;
setEmailAddress(property, email) {
this[property] = email;
}
parseEmailAddress(address) {
const emailAddress = address || 'noreply';
// Adds default domain as site domain
if (emailAddress.indexOf('@') < 0 && this.config.emailDomain) {
return `${emailAddress}@${this.config.emailDomain}`;
}
return emailAddress;
}
resetEmailAddresses() {
this.fromAddress = this.parseEmailAddress(this.settings.get('membersFromAddress'));
this.supportAddress = this.parseEmailAddress(this.settings.get('membersSupportAddress'));
}
@task({drop: true}) @task({drop: true})
*saveSettings() { *saveSettings() {

View File

@ -27,4 +27,8 @@ export default class Newsletter extends Model.extend(ValidationEngine) {
@attr({defaultValue: 'sans_serif'}) bodyFontCategory; @attr({defaultValue: 'sans_serif'}) bodyFontCategory;
@attr() footerContent; @attr() footerContent;
@attr({defaultValue: true}) showBadge; @attr({defaultValue: true}) showBadge;
// HACK - not a real model attribute but a workaround for Ember Data not
// exposing meta from save responses
@attr _meta;
} }

View File

@ -1,5 +1,6 @@
import AdminRoute from 'ghost-admin/routes/admin'; import AdminRoute from 'ghost-admin/routes/admin';
import ConfirmUnsavedChangesModal from '../../components/modals/confirm-unsaved-changes'; import ConfirmUnsavedChangesModal from '../../components/modals/confirm-unsaved-changes';
import VerifyNewsletterEmail from '../../components/modals/edit-newsletter/verify-newsletter-email';
import {action} from '@ember/object'; import {action} from '@ember/object';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
@ -9,30 +10,37 @@ export default class MembersEmailLabsRoute extends AdminRoute {
@service notifications; @service notifications;
@service settings; @service settings;
queryParams = {
verifyEmail: {
replace: true
}
};
confirmModal = null; confirmModal = null;
hasConfirmed = false; hasConfirmed = false;
beforeModel(transition) { beforeModel() {
super.beforeModel(...arguments); super.beforeModel(...arguments);
if (!this.feature.multipleNewsletters) { if (!this.feature.multipleNewsletters) {
return this.transitionTo('settings.members-email'); return this.transitionTo('settings.members-email');
} }
if (transition.to.queryParams?.fromAddressUpdate === 'success') {
this.notifications.showAlert(
`Newsletter email address has been updated`,
{type: 'success', key: 'members.settings.from-address.updated'}
);
}
} }
model() { model() {
return this.settings.reload(); return this.settings.reload();
} }
setupController(controller) { afterModel(model, transition) {
controller.resetEmailAddresses(); if (transition.to.queryParams.verifyEmail) {
this.modals.open(VerifyNewsletterEmail, {
token: transition.to.queryParams.verifyEmail
});
// clear query param so it doesn't linger and cause problems re-entering route
transition.abort();
return this.transitionTo('settings.members-email-labs', {queryParams: {verifyEmail: null}});
}
} }
@action @action

View File

@ -0,0 +1,27 @@
/* eslint-disable camelcase */
import ApplicationSerializer from './application';
export default class MemberSerializer extends ApplicationSerializer {
// HACK: Ember Data doesn't expose `meta` properties consistently
// - https://github.com/emberjs/data/issues/2905
//
// We need the `meta` data returned when saving so we extract it and dump
// it onto the model as an attribute then delete it again when serializing.
normalizeResponse() {
const json = super.normalizeResponse(...arguments);
if (json.meta && json.data.attributes) {
json.data.attributes._meta = json.meta;
}
return json;
}
serialize() {
const json = super.serialize(...arguments);
delete json._meta;
return json;
}
}

View File

@ -20,11 +20,7 @@
<section class="view-container settings-debug"> <section class="view-container settings-debug">
<div class="gh-setting-liquid-section"> <div class="gh-setting-liquid-section">
<Settings::MembersEmailLabs <Settings::MembersEmailLabs />
@fromAddress={{this.fromAddress}}
@supportAddress={{this.supportAddress}}
@setEmailAddress={{this.setEmailAddress}}
/>
</div> </div>
</section> </section>
</section> </section>

View File

@ -1,8 +1,77 @@
import {camelize} from '@ember/string';
import {paginatedResponse} from '../utils'; import {paginatedResponse} from '../utils';
export default function mockNewsletters(server) { export default function mockNewsletters(server) {
server.post('/newsletters/');
server.get('/newsletters/', paginatedResponse('newsletters')); server.get('/newsletters/', paginatedResponse('newsletters'));
server.get('/newsletters/:id/'); server.get('/newsletters/:id/');
server.put('/newsletters/:id/');
server.post('/newsletters/', function ({newsletters}) {
const attrs = this.normalizedRequestAttrs();
// sender email can't be set without verification
const senderEmail = attrs.senderEmail;
attrs.senderEmail = null;
const newsletter = newsletters.create(attrs);
// workaround for mirage output of meta
const collection = newsletters.where({id: newsletter.id});
if (senderEmail) {
collection.meta = {
sent_email_verification: ['sender_email']
};
}
return collection;
});
server.put('/newsletters/:id/', function ({newsletters}, {params}) {
const attrs = this.normalizedRequestAttrs();
const newsletter = newsletters.find(params.id);
const previousSenderEmail = newsletter.senderEmail;
const newSenderEmail = attrs.senderEmail;
// sender email can't be changed without verification
if (newSenderEmail && newSenderEmail !== previousSenderEmail) {
attrs.senderEmail = previousSenderEmail;
}
newsletter.update(attrs);
// workaround for mirage output of meta
const collection = newsletters.where({id: newsletter.id});
if (newSenderEmail && newSenderEmail !== previousSenderEmail) {
collection.meta = {
sent_email_verification: ['sender_email']
};
const tokenData = {
id: newsletter.id,
email: newSenderEmail,
type: 'sender_email'
};
const token = btoa(JSON.stringify(tokenData));
const baseUrl = window.location.href.replace(window.location.hash, '');
const verifyUrl = `${baseUrl}settings/members-email-labs/?verifyEmail=${token}`;
// eslint-disable-next-line
console.warn('Verification email sent. Mocked verification URL:', verifyUrl);
}
return collection;
});
// verify email update
server.put('/newsletters/verify-email/', function ({newsletters}, request) {
const requestBody = JSON.parse(request.requestBody);
const tokenData = JSON.parse(atob(requestBody.token));
const newsletter = newsletters.find(tokenData.id);
newsletter[camelize(tokenData.type)] = tokenData.email;
return newsletter.save();
});
} }

View File

@ -9,4 +9,30 @@ export default function (server) {
server.create('integration', {name: 'Demo'}); server.create('integration', {name: 'Demo'});
server.createList('member', 125); server.createList('member', 125);
// sites always have a default newsletter
server.create('newsletter', {
name: 'Site title',
slug: 'site-title',
description: 'Default newsletter created during setup',
senderName: 'Site title',
senderEmail: null,
senderReplyTo: 'newsletter',
status: 'active',
recipientFilter: null,
subscribeOnSignup: true,
sortOrder: 0,
headerImage: null,
showHeaderIcon: true,
showHeaderTitle: true,
titleFontCategory: 'sans_serif',
titleAlignment: 'center',
showFeatureImage: true,
bodyFontCategory: 'sans_serif',
footerContent: null,
showBadge: true
});
} }

View File

@ -24,9 +24,7 @@ export default RestSerializer.extend({
}, },
serialize(object, request) { serialize(object, request) {
// Ember expects pluralized responses for the post, user, and invite models, if (this.isModel(object)) {
// and this shortcut will ensure that those models are pluralized
if (this.isModel(object) && ['post', 'user', 'invite'].includes(object.modelName)) {
object = new Collection(object.modelName, [object]); object = new Collection(object.modelName, [object]);
} }