0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

Allowed updating from address domain for member emails

refs https://github.com/TryGhost/Ghost/issues/11414

Confirms if the fromAddress for sending member emails is valid and accessible using magic link flow, allowing owners to update full from address including domain change.

- Extends member service to handle magic link generation and validation for email update
- Updates existing setting endpoint to not directly update from address
- Adds new endpoint to send magic link to new address
- Adds new endpoint for validating the magic link when clicked and update the new email for from address
- Adds new email template for from address update email
This commit is contained in:
Rish 2020-06-05 21:50:04 +05:30 committed by Rishabh Garg
parent 4200eaf1f7
commit bca41e1877
10 changed files with 369 additions and 2 deletions

View file

@ -1,5 +1,6 @@
const Promise = require('bluebird'); const Promise = require('bluebird');
const _ = require('lodash'); const _ = require('lodash');
const validator = require('validator');
const models = require('../../models'); const models = require('../../models');
const routing = require('../../../frontend/services/routing'); const routing = require('../../../frontend/services/routing');
const {i18n} = require('../../lib/common'); const {i18n} = require('../../lib/common');
@ -80,6 +81,75 @@ module.exports = {
} }
}, },
validateMembersFromEmail: {
options: [
'token'
],
permissions: false,
validation: {
options: {
token: {
required: true
}
}
},
async query(frame) {
// This is something you have to do if you want to use the "framework" with access to the raw req/res
frame.response = async function (req, res) {
try {
const updatedFromAddress = membersService.settings.getEmailFromToken({token: frame.options.token});
if (updatedFromAddress) {
let subscriptionSetting = settingsCache.get('members_subscription_settings', {resolve: false});
const settingsValue = subscriptionSetting.value ? JSON.parse(subscriptionSetting.value) : {};
settingsValue.fromAddress = updatedFromAddress;
return models.Settings.edit({
key: 'members_subscription_settings',
value: JSON.stringify(settingsValue)
}).then(() => {
// Redirect to Ghost-Admin settings page
const adminLink = membersService.settings.getAdminRedirectLink();
res.redirect(adminLink);
});
} else {
return Promise.reject(new BadRequestError({
message: 'Invalid token!'
}));
}
} catch (err) {
return Promise.reject(new BadRequestError({
err,
message: 'Invalid token!'
}));
}
};
}
},
updateMembersFromEmail: {
permissions: {
method: 'edit'
},
async query(frame) {
const email = frame.data.from_address;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new BadRequestError({
message: i18n.t('errors.api.settings.invalidEmailReceived')
});
}
try {
// Send magic link to update fromAddress
await membersService.settings.sendFromAddressUpdateMagicLink({
email
});
} catch (err) {
throw new BadRequestError({
err,
message: i18n.t('errors.mail.failedSendingEmail.error')
});
}
}
},
edit: { edit: {
headers: { headers: {
cacheInvalidate: true cacheInvalidate: true

View file

@ -1,5 +1,6 @@
const _ = require('lodash'); const _ = require('lodash');
const url = require('./utils/url'); const url = require('./utils/url');
const settingsCache = require('../../../../../services/settings/cache');
module.exports = { module.exports = {
read(apiConfig, frame) { read(apiConfig, frame) {
@ -55,6 +56,17 @@ module.exports = {
const {apiKey = '', domain = '', baseUrl = '', provider = 'mailgun'} = setting.value ? JSON.parse(setting.value) : {}; const {apiKey = '', domain = '', baseUrl = '', provider = 'mailgun'} = setting.value ? JSON.parse(setting.value) : {};
setting.value = JSON.stringify({apiKey, domain, baseUrl, provider}); setting.value = JSON.stringify({apiKey, domain, baseUrl, provider});
} }
//CASE: Ensure we don't update fromAddress for member as that goes through magic link flow
if (setting.key === 'members_subscription_settings') {
const memberSubscriptionSettings = setting.value ? JSON.parse(setting.value) : {};
let subscriptionSettingCache = settingsCache.get('members_subscription_settings', {resolve: false});
const settingsCacheValue = subscriptionSettingCache.value ? JSON.parse(subscriptionSettingCache.value) : {};
memberSubscriptionSettings.fromAddress = settingsCacheValue.fromAddress;
setting.value = JSON.stringify(memberSubscriptionSettings);
}
}); });
// CASE: deprecated, won't accept // CASE: deprecated, won't accept

View file

@ -38,8 +38,13 @@ class MembersConfigProvider {
*/ */
getEmailFromAddress() { getEmailFromAddress() {
const subscriptionSettings = this._settingsCache.get('members_subscription_settings') || {}; const subscriptionSettings = this._settingsCache.get('members_subscription_settings') || {};
const fromAddress = subscriptionSettings.fromAddress || 'noreply';
return `${subscriptionSettings.fromAddress || 'noreply'}@${this._getDomain()}`; // Any fromAddress without domain uses site domain, like default setting `noreply`
if (fromAddress.indexOf('@') < 0) {
return `${fromAddress}@${this._getDomain()}`;
}
return fromAddress;
} }
getPublicPlans() { getPublicPlans() {

View file

@ -0,0 +1,168 @@
module.exports = ({siteTitle, email, url}) => `
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>📫 Confirm your subscription to ${siteTitle}</title>
<style>
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.recipient-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
hr {
border-width: 0;
height: 0;
margin-top: 34px;
margin-bottom: 34px;
border-bottom-width: 1px;
border-bottom-color: #EEF5F8;
}
</style>
</head>
<body class="" style="background-color: #F4F8FB; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #F4F8FB;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 600px; padding: 10px; width: 600px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">You're tap away from updating your email address!</span>
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 40px 50px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 25px; margin: 0; margin-bottom: 15px;">Hey there,</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">Please confirm your email address with this link:</p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr>
<td align="left" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-bottom: 35px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #15212A; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #15212A; border: solid 1px #15212A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; text-transform: capitalize; border-color: #15212A;">Confirm email address</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 25px;">For your security, the link will expire in 10 minutes time.</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 30px;">All the best!<br/>The team at ${siteTitle}</p>
<hr/>
<p style="word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 5px;">You can also copy & paste this URL into your browser:</p>
<p style="word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; line-height: 21px; margin-top: 0; color: #738A94;">${url}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 5px; padding-top: 15px; font-size: 13px; line-height: 21px; color: #738A94; text-align: center;">
If you did not make this request, you can simply delete this message.<br/>This email address will not be used.
</td>
</tr>
<tr>
<td class="content-block" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 13px; color: #738A94; text-align: center;">
<span class="recipient-link" style="color: #738A94; font-size: 13px; text-align: center;">Sent to <a href="mailto:${email}" style="text-decoration: underline; color: #738A94; font-size: 13px; text-align: center;">${email}</a></span>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>
`;

View file

@ -2,6 +2,7 @@ const MembersSSR = require('@tryghost/members-ssr');
const MembersConfigProvider = require('./config'); const MembersConfigProvider = require('./config');
const createMembersApiInstance = require('./api'); const createMembersApiInstance = require('./api');
const createMembersSettingsInstance = require('./settings');
const {events} = require('../../lib/common'); const {events} = require('../../lib/common');
const logging = require('../../../shared/logging'); const logging = require('../../../shared/logging');
const urlUtils = require('../../../shared/url-utils'); const urlUtils = require('../../../shared/url-utils');
@ -18,6 +19,7 @@ const membersConfig = new MembersConfigProvider({
}); });
let membersApi; let membersApi;
let membersSettings;
// Bind to events to automatically keep subscription info up-to-date from settings // Bind to events to automatically keep subscription info up-to-date from settings
events.on('settings.edited', function updateSettingFromModel(settingModel) { events.on('settings.edited', function updateSettingFromModel(settingModel) {
@ -50,6 +52,13 @@ const membersService = {
return membersApi; return membersApi;
}, },
get settings() {
if (!membersSettings) {
membersSettings = createMembersSettingsInstance(membersConfig);
}
return membersSettings;
},
ssr: MembersSSR({ ssr: MembersSSR({
cookieSecure: urlUtils.isSSL(urlUtils.getSiteUrl()), cookieSecure: urlUtils.isSSL(urlUtils.getSiteUrl()),
cookieKeys: [settingsCache.get('theme_session_secret')], cookieKeys: [settingsCache.get('theme_session_secret')],

View file

@ -0,0 +1,99 @@
const MagicLink = require('@tryghost/magic-link');
const {URL} = require('url');
const path = require('path');
const urlUtils = require('../../../shared/url-utils');
const settingsCache = require('../settings/cache');
const logging = require('../../../shared/logging');
const mail = require('../mail');
const updateEmailTemplate = require('./emails/updateEmail');
const ghostMailer = new mail.GhostMailer();
function createSettingsInstance(config) {
const {transporter, getSubject, getText, getHTML, getSigninURL} = {
transporter: {
sendMail(message) {
if (process.env.NODE_ENV !== 'production') {
logging.warn(message.text);
}
let msg = Object.assign({
subject: 'Update email address',
forceTextContent: true
}, message);
return ghostMailer.send(msg);
}
},
getSubject() {
const siteTitle = settingsCache.get('title');
return `📫 Confirm your email update for ${siteTitle}`;
},
getText(url, type, email) {
const siteTitle = settingsCache.get('title');
return `
Hey there,
You're one tap away from updating your email at ${siteTitle} please confirm your email address with this link:
${url}
For your security, the link will expire in 10 minutes time.
All the best!
The team at ${siteTitle}
---
Sent to ${email}
If you did not make this request, you can simply delete this message. You will not be subscribed.
`;
},
getHTML(url, type, email) {
const siteTitle = settingsCache.get('title');
return updateEmailTemplate({url, email, siteTitle});
},
getSigninURL(token, type) {
const signinURL = new URL(getApiUrl({version: 'v3', type: 'admin'}));
signinURL.pathname = path.join(signinURL.pathname, '/settings/members/email/');
signinURL.searchParams.set('token', token);
signinURL.searchParams.set('action', type);
return signinURL.href;
}
};
const getApiUrl = ({version, type}) => {
return urlUtils.urlFor('api', {version: version, versionType: type}, true);
};
const magicLinkService = new MagicLink({
transporter,
secret: config.getAuthSecret(),
getSigninURL,
getText,
getHTML,
getSubject
});
const sendFromAddressUpdateMagicLink = ({email, payload = {}}) => {
return magicLinkService.sendMagicLink({email, payload, subject: email, type: 'updateFromAddress'});
};
const getEmailFromToken = ({token}) => {
return magicLinkService.getUserFromToken(token);
};
const getAdminRedirectLink = () => {
const adminUrl = urlUtils.urlFor('admin', true);
return urlUtils.urlJoin(adminUrl, 'settings/labs/?fromAddressUpdate=success');
};
return {
sendFromAddressUpdateMagicLink,
getEmailFromToken,
getAdminRedirectLink
};
}
module.exports = createSettingsInstance;

View file

@ -351,6 +351,7 @@
"settings": { "settings": {
"problemFindingSetting": "Problem finding setting: {key}", "problemFindingSetting": "Problem finding setting: {key}",
"accessCoreSettingFromExtReq": "Attempted to access core setting from external request", "accessCoreSettingFromExtReq": "Attempted to access core setting from external request",
"invalidEmailReceived": "Please send a valid email",
"activeThemeSetViaAPI": { "activeThemeSetViaAPI": {
"error": "Attempted to change active_theme via settings API", "error": "Attempted to change active_theme via settings API",
"help": "Please activate theme via the themes API endpoints instead" "help": "Please activate theme via the themes API endpoints instead"

View file

@ -61,6 +61,8 @@ module.exports = function apiRoutes() {
router.get('/settings', mw.authAdminApi, http(apiCanary.settings.browse)); router.get('/settings', mw.authAdminApi, http(apiCanary.settings.browse));
router.get('/settings/:key', mw.authAdminApi, http(apiCanary.settings.read)); router.get('/settings/:key', mw.authAdminApi, http(apiCanary.settings.read));
router.put('/settings', mw.authAdminApi, http(apiCanary.settings.edit)); router.put('/settings', mw.authAdminApi, http(apiCanary.settings.edit));
router.get('/settings/members/email', http(apiCanary.settings.validateMembersFromEmail));
router.post('/settings/members/email', mw.authAdminApi, http(apiCanary.settings.updateMembersFromEmail));
// ## Users // ## Users
router.get('/users', mw.authAdminApi, http(apiCanary.users.browse)); router.get('/users', mw.authAdminApi, http(apiCanary.users.browse));

View file

@ -51,6 +51,7 @@
"@tryghost/kg-default-cards": "2.0.2", "@tryghost/kg-default-cards": "2.0.2",
"@tryghost/kg-markdown-html-renderer": "2.0.0", "@tryghost/kg-markdown-html-renderer": "2.0.0",
"@tryghost/kg-mobiledoc-html-renderer": "3.0.0", "@tryghost/kg-mobiledoc-html-renderer": "3.0.0",
"@tryghost/magic-link": "^0.4.8",
"@tryghost/members-api": "0.20.0", "@tryghost/members-api": "0.20.0",
"@tryghost/members-ssr": "0.8.0", "@tryghost/members-ssr": "0.8.0",
"@tryghost/mw-session-from-token": "0.1.4", "@tryghost/mw-session-from-token": "0.1.4",

View file

@ -478,7 +478,7 @@
"@tryghost/magic-link@^0.4.8": "@tryghost/magic-link@^0.4.8":
version "0.4.8" version "0.4.8"
resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-0.4.8.tgz#70cd6fa7aeb433a8bb09eefab68b35db6a8d85e8" resolved "https://registry.npmjs.org/@tryghost/magic-link/-/magic-link-0.4.8.tgz#70cd6fa7aeb433a8bb09eefab68b35db6a8d85e8"
integrity sha512-WvaXzT0v0T6Au0HRQS5wcoeEqjKT+qHZi9Lo9yCOppxra7QqSywpxrv+SZI3GOQo8yjZ1moVcqF4T5YB0UH+dQ== integrity sha512-WvaXzT0v0T6Au0HRQS5wcoeEqjKT+qHZi9Lo9yCOppxra7QqSywpxrv+SZI3GOQo8yjZ1moVcqF4T5YB0UH+dQ==
dependencies: dependencies:
bluebird "^3.5.5" bluebird "^3.5.5"