Skip to content

Commit

Permalink
Added sender email verification flow for newsletters
Browse files Browse the repository at this point in the history
refs TryGhost/Product#584
refs TryGhost/Product#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
    - emberjs/data#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
  • Loading branch information
kevinansfield committed Apr 13, 2022
1 parent cc6c0ac commit e398557
Show file tree
Hide file tree
Showing 13 changed files with 272 additions and 112 deletions.
13 changes: 13 additions & 0 deletions ghost/admin/app/components/modals/edit-newsletter.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import Component from '@glimmer/component';
import ConfirmNewsletterEmailModal from './edit-newsletter/confirm-newsletter-email';
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 EditNewsletterModal extends Component {
@service modals;

static modalOptions = {
className: 'fullscreen-modal-full-overlay fullscreen-modal-portal-settings'
};
Expand Down Expand Up @@ -31,8 +35,17 @@ export default class EditNewsletterModal extends Component {
@task
*saveTask() {
try {
const newEmail = this.args.data.newsletter.senderEmail;

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

return result;
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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();
}
}
67 changes: 0 additions & 67 deletions ghost/admin/app/components/settings/members-email-labs.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
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';

const US = {flag: '🇺🇸', name: 'US', baseUrl: 'https://api.mailgun.net/v3'};
const EU = {flag: '🇪🇺', name: 'EU', baseUrl: 'https://api.eu.mailgun.net/v3'};

export default class MembersEmailLabs extends Component {
@service config;
@service ghostPaths;
@service ajax;
@service settings;

// set recipientsSelectValue as a static property because within this
Expand All @@ -19,41 +16,12 @@ export default class MembersEmailLabs extends Component {
// from settings as it would equate to "none"
@tracked recipientsSelectValue = this._getDerivedRecipientsSelectValue();

@tracked showFromAddressConfirmation = false;

mailgunRegions = [US, EU];

replyAddresses = [
{
label: 'Newsletter email address (' + this.fromAddress + ')',
value: 'newsletter'
},
{
label: 'Support email address (' + this.supportAddress + ')',
value: 'support'
}
];

get emailNewsletterEnabled() {
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() {
if (!this.settings.get('mailgunBaseUrl')) {
return US;
Expand All @@ -72,11 +40,6 @@ export default class MembersEmailLabs extends Component {
};
}

@action
toggleFromAddressConfirmation() {
this.showFromAddressConfirmation = !this.showFromAddressConfirmation;
}

@action
setMailgunDomain(event) {
this.settings.set('mailgunDomain', event.target.value);
Expand All @@ -98,11 +61,6 @@ export default class MembersEmailLabs extends Component {
this.settings.set('mailgunBaseUrl', region.baseUrl);
}

@action
setFromAddress(fromAddress) {
this.setEmailAddress('fromAddress', fromAddress);
}

@action
toggleEmailTrackOpens(event) {
if (event) {
Expand All @@ -129,13 +87,6 @@ export default class MembersEmailLabs extends Component {
this.recipientsSelectValue = this._getDerivedRecipientsSelectValue();
}

@action
setReplyAddress(event) {
const newReplyAddress = event.value;

this.settings.set('membersReplyAddress', newReplyAddress);
}

@action
setDefaultEmailRecipients(value) {
// Update the underlying setting properties to match the selected recipients option
Expand Down Expand Up @@ -169,24 +120,6 @@ export default class MembersEmailLabs extends Component {
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() {
const defaultEmailRecipients = this.settings.get('editorDefaultEmailRecipients');
const defaultEmailRecipientsFilter = this.settings.get('editorDefaultEmailRecipientsFilter');
Expand Down
27 changes: 2 additions & 25 deletions ghost/admin/app/controllers/settings/members-email-labs.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
import Controller from '@ember/controller';
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 MembersEmailLabsController extends Controller {
@service config;
@service session;
@service settings;

// from/supportAddress are set here so that they can be reset to saved values on save
// to avoid it looking like they've been saved when they have a separate update process
@tracked fromAddress = '';
@tracked supportAddress = '';
queryParams = ['verifyEmail'];

@action
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'));
}
@tracked verifyEmail = null;

@task({drop: true})
*saveSettings() {
Expand Down
4 changes: 4 additions & 0 deletions ghost/admin/app/models/newsletter.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ export default class Newsletter extends Model.extend(ValidationEngine) {
@attr({defaultValue: 'sans_serif'}) bodyFontCategory;
@attr() footerContent;
@attr({defaultValue: true}) showBadge;

// HACK - not a real model attribute but a workaround for Ember Data not
// exposing meta from save responses
@attr _meta;
}
28 changes: 18 additions & 10 deletions ghost/admin/app/routes/settings/members-email-labs.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AdminRoute from 'ghost-admin/routes/admin';
import ConfirmUnsavedChangesModal from '../../components/modals/confirm-unsaved-changes';
import VerifyNewsletterEmail from '../../components/modals/edit-newsletter/verify-newsletter-email';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';

Expand All @@ -9,30 +10,37 @@ export default class MembersEmailLabsRoute extends AdminRoute {
@service notifications;
@service settings;

queryParams = {
verifyEmail: {
replace: true
}
};

confirmModal = null;
hasConfirmed = false;

beforeModel(transition) {
beforeModel() {
super.beforeModel(...arguments);

if (!this.feature.multipleNewsletters) {
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() {
return this.settings.reload();
}

setupController(controller) {
controller.resetEmailAddresses();
afterModel(model, transition) {
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
Expand Down
27 changes: 27 additions & 0 deletions ghost/admin/app/serializers/newsletter.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit e398557

Please sign in to comment.