Added newsletter from address verification (#14491)

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

- Added newsletter `from` address verification

Co-authored-by: Hannah Wolfe <github.erisds@gmail.com>
This commit is contained in:
Kevin Ansfield 2022-04-22 13:20:44 +01:00 committed by Matt Hanley
parent 298599ce91
commit a8687b35b9
10 changed files with 1042 additions and 87 deletions

View File

@ -5,6 +5,7 @@ const errors = require('@tryghost/errors');
const messages = {
newsletterNotFound: 'Newsletter not found.'
};
const newslettersService = require('../../services/newsletters');
module.exports = {
docName: 'newsletters',
@ -53,7 +54,7 @@ module.exports = {
statusCode: 201,
permissions: true,
async query(frame) {
return models.Newsletter.add(frame.data.newsletters[0], frame.options);
return newslettersService.add(frame.data.newsletters[0], frame.options);
}
},
@ -71,7 +72,19 @@ module.exports = {
},
permissions: true,
async query(frame) {
return models.Newsletter.edit(frame.data.newsletters[0], frame.options);
return newslettersService.edit(frame.data.newsletters[0], frame.options);
}
},
verifyPropertyUpdate: {
permissions: {
method: 'edit'
},
data: [
'token'
],
async query(frame) {
return newslettersService.verifyPropertyUpdate(frame.data.token);
}
}
};

View File

@ -13,7 +13,7 @@ const SingleUseTokenProvider = require('./SingleUseTokenProvider');
const urlUtils = require('../../../shared/url-utils');
const labsService = require('../../../shared/labs');
const offersService = require('../offers');
const getNewslettersServiceInstance = require('../newsletters');
const newslettersService = require('../newsletters');
const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
@ -197,7 +197,7 @@ function createApiInstance(config) {
stripeAPIService: stripeService.api,
offersAPI: offersService.api,
labsService: labsService,
newslettersService: getNewslettersServiceInstance({NewsletterModel: models.Newsletter})
newslettersService: newslettersService
});
return membersApiInstance;

View File

@ -0,0 +1,166 @@
module.exports = ({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 email address</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 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 -->
<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; border-color: #15212A;" data-test-verify-link>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 24 hours time.</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

@ -1,10 +1,14 @@
const NewslettersService = require('./service.js');
const SingleUseTokenProvider = require('../members/SingleUseTokenProvider');
const mail = require('../mail');
const models = require('../../models');
const urlUtils = require('../../../shared/url-utils');
/**
* @returns {NewslettersService} instance of the NewslettersService
*/
const getNewslettersServiceInstance = ({NewsletterModel}) => {
return new NewslettersService({NewsletterModel});
};
const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
module.exports = getNewslettersServiceInstance;
module.exports = new NewslettersService({
NewsletterModel: models.Newsletter,
mail,
singleUseTokenProvider: new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY),
urlUtils
});

View File

@ -1,11 +1,70 @@
const _ = require('lodash');
const MagicLink = require('@tryghost/magic-link');
const logging = require('@tryghost/logging');
const verifyEmailTemplate = require('./emails/verify-email');
class NewslettersService {
/**
*
* @param {Object} options
* @param {Object} options.NewsletterModel
* @param {Object} options.mail
* @param {Object} options.singleUseTokenProvider
* @param {Object} options.urlUtils
*/
constructor({NewsletterModel}) {
constructor({NewsletterModel, mail, singleUseTokenProvider, urlUtils}) {
this.NewsletterModel = NewsletterModel;
this.urlUtils = urlUtils;
/* email verification setup */
this.ghostMailer = new mail.GhostMailer();
const {transporter, getSubject, getText, getHTML, getSigninURL} = {
transporter: {
sendMail() {
// noop - overridden in `sendEmailVerificationMagicLink`
}
},
getSubject() {
// not used - overridden in `sendEmailVerificationMagicLink`
return `Verify email address`;
},
getText(url, type, email) {
return `
Hey there,
Please confirm your email address with this link:
${url}
For your security, the link will expire in 24 hours time.
---
Sent to ${email}
If you did not make this request, you can simply delete this message. This email address will not be used.
`;
},
getHTML(url, type, email) {
return verifyEmailTemplate({url, email});
},
getSigninURL(token) {
const adminUrl = urlUtils.urlFor('admin', true);
const signinURL = new URL(adminUrl);
signinURL.hash = `/settings/members-email-labs/?verifyEmail=${token}`;
return signinURL.href;
}
};
this.magicLinkService = new MagicLink({
transporter,
tokenProvider: singleUseTokenProvider,
getSigninURL,
getText,
getHTML,
getSubject
});
}
/**
@ -18,7 +77,109 @@ class NewslettersService {
return newsletters.toJSON();
}
async add(attrs, options) {
// remove any email properties that are not allowed to be set without verification
const {cleanedAttrs, emailsToVerify} = await this.prepAttrsForEmailVerification(attrs);
// add the model now because we need the ID for sending verification emails
const newsletter = await this.NewsletterModel.add(cleanedAttrs, options);
// send any verification emails and respond with the appropriate meta added
return this.respondWithEmailVerification(newsletter, emailsToVerify);
}
async edit(attrs, options) {
// fetch newsletter first so we can compare changed emails
const originalNewsletter = await this.NewsletterModel.findOne(options, {require: true});
const {cleanedAttrs, emailsToVerify} = await this.prepAttrsForEmailVerification(attrs, originalNewsletter);
const updatedNewsletter = await this.NewsletterModel.edit(cleanedAttrs, options);
return this.respondWithEmailVerification(updatedNewsletter, emailsToVerify);
}
async verifyPropertyUpdate(token) {
const data = await this.magicLinkService.getDataFromToken(token);
const {id, property, value} = data;
const attrs = {};
attrs[property] = value;
return this.NewsletterModel.edit(attrs, {id});
}
/* Email verification (private) */
async prepAttrsForEmailVerification(attrs, newsletter) {
const cleanedAttrs = _.cloneDeep(attrs);
const emailsToVerify = [];
for (const property of ['sender_email']) {
const email = cleanedAttrs[property];
const hasChanged = !newsletter || newsletter.get(property) !== email;
if (await this.requiresEmailVerification({email, hasChanged})) {
delete cleanedAttrs[property];
emailsToVerify.push({email, property});
}
}
return {cleanedAttrs, emailsToVerify};
}
async requiresEmailVerification({email, hasChanged}) {
if (!email || !hasChanged) {
return false;
}
// TODO: check other newsletters for known/verified email
return true;
}
async respondWithEmailVerification(newsletter, emailsToVerify) {
if (emailsToVerify.length > 0) {
for (const {email, property} of emailsToVerify) {
await this.sendEmailVerificationMagicLink({id: newsletter.get('id'), email, property});
}
newsletter.meta = {
sent_email_verification: emailsToVerify.map(v => v.property)
};
}
return newsletter;
}
async sendEmailVerificationMagicLink({id, email, property = 'sender_from'}) {
const [,toDomain] = email.split('@');
let fromEmail = `noreply@${toDomain}`;
if (fromEmail === email) {
fromEmail = `no-reply@${toDomain}`;
}
const {ghostMailer} = this;
this.magicLinkService.transporter = {
sendMail(message) {
if (process.env.NODE_ENV !== 'production') {
logging.warn(message.text);
}
let msg = Object.assign({
from: fromEmail,
subject: 'Verify email address',
forceTextContent: true
}, message);
return ghostMailer.send(msg);
}
};
return this.magicLinkService.sendMagicLink({email, tokenData: {id, property, value: email}});
}
}
module.exports = NewslettersService;

View File

@ -315,6 +315,7 @@ module.exports = function apiRoutes() {
router.get('/newsletters', mw.authAdminApi, http(api.newsletters.browse));
router.get('/newsletters/:id', mw.authAdminApi, http(api.newsletters.read));
router.post('/newsletters', mw.authAdminApi, http(api.newsletters.add));
router.put('/newsletters/verifications/', mw.authAdminApi, http(api.newsletters.verifyPropertyUpdate));
router.put('/newsletters/:id', mw.authAdminApi, http(api.newsletters.edit));
return router;

View File

@ -1,5 +1,197 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Newsletters API Can add a newsletter - with custom sender_email 1: [body] 1`] = `
Object {
"meta": Object {
"sent_email_verification": Array [
"sender_email",
],
},
"newsletters": Array [
Object {
"body_font_category": "serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "My test newsletter with custom sender_email",
"sender_email": null,
"sender_name": "Test",
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "my-test-newsletter-with-custom-sender_email",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "serif",
"visibility": "members",
},
],
}
`;
exports[`Newsletters API Can add a newsletter - with custom sender_email 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "628",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": Any<String>,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Newsletters API Can add a newsletter - with custom sender_email 3: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 15,
"next": null,
"page": 1,
"pages": 1,
"prev": null,
"total": 5,
},
},
"newsletters": Array [
Object {
"body_font_category": "sans_serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Default Newsletter",
"sender_email": null,
"sender_name": null,
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "default-newsletter",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "sans_serif",
"visibility": "members",
},
Object {
"body_font_category": "serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "My test newsletter",
"sender_email": null,
"sender_name": "Test",
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "my-test-newsletter",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "serif",
"visibility": "members",
},
Object {
"body_font_category": "serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "My test newsletter with custom sender_email",
"sender_email": null,
"sender_name": "Test",
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "my-test-newsletter-with-custom-sender_email",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "serif",
"visibility": "members",
},
Object {
"body_font_category": "serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Daily newsletter",
"sender_email": "jamie@example.com",
"sender_name": "Jamie",
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "daily-newsletter",
"sort_order": 1,
"status": "active",
"subscribe_on_signup": false,
"title_alignment": "center",
"title_font_category": "serif",
"visibility": "members",
},
Object {
"body_font_category": "serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Weekly newsletter",
"sender_email": "jamie@example.com",
"sender_name": "Jamie",
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "weekly-newsletter",
"sort_order": 2,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "serif",
"visibility": "members",
},
],
}
`;
exports[`Newsletters API Can add a newsletter - with custom sender_email 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2735",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Newsletters API Can add a newsletter 1: [body] 1`] = `
Object {
"newsletters": Array [
@ -10,7 +202,7 @@ Object {
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "My test newsletter",
"sender_email": "test@example.com",
"sender_email": null,
"sender_name": "Test",
"sender_reply_to": "newsletter",
"show_badge": true,
@ -34,7 +226,7 @@ exports[`Newsletters API Can add a newsletter 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "540",
"content-length": "526",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": Any<String>,
@ -86,7 +278,7 @@ Object {
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "My test newsletter",
"sender_email": "test@example.com",
"sender_email": null,
"sender_name": "Test",
"sender_reply_to": "newsletter",
"show_badge": true,
@ -156,7 +348,7 @@ exports[`Newsletters API Can add a newsletter 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2190",
"content-length": "2176",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
@ -173,7 +365,7 @@ Object {
"page": 1,
"pages": 1,
"prev": null,
"total": 4,
"total": 5,
},
},
"newsletters": Array [
@ -207,7 +399,7 @@ Object {
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "My test newsletter",
"sender_email": "test@example.com",
"sender_email": null,
"sender_name": "Test",
"sender_reply_to": "newsletter",
"show_badge": true,
@ -223,6 +415,29 @@ Object {
"title_font_category": "serif",
"visibility": "members",
},
Object {
"body_font_category": "serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "My test newsletter with custom sender_email",
"sender_email": null,
"sender_name": "Test",
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "my-test-newsletter-with-custom-sender_email",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "serif",
"visibility": "members",
},
Object {
"body_font_category": "serif",
"description": null,
@ -277,7 +492,7 @@ exports[`Newsletters API Can browse newsletters 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2190",
"content-length": "2735",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
@ -292,9 +507,9 @@ Object {
"limit": 1,
"next": 2,
"page": 1,
"pages": 4,
"pages": 5,
"prev": null,
"total": 4,
"total": 5,
},
},
"newsletters": Array [
@ -379,6 +594,105 @@ Object {
}
`;
exports[`Newsletters API Can edit newsletters with updated sender_email 1: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 1,
"next": 2,
"page": 1,
"pages": 5,
"prev": null,
"total": 5,
},
},
"newsletters": Array [
Object {
"body_font_category": "sans_serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Updated newsletter name",
"sender_email": null,
"sender_name": null,
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "default-newsletter",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "sans_serif",
"visibility": "members",
},
],
}
`;
exports[`Newsletters API Can edit newsletters with updated sender_email 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "623",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Newsletters API Can edit newsletters with updated sender_email 3: [body] 1`] = `
Object {
"meta": Object {
"sent_email_verification": Array [
"sender_email",
],
},
"newsletters": Array [
Object {
"body_font_category": "sans_serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Updated newsletter name",
"sender_email": null,
"sender_name": null,
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "default-newsletter",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "sans_serif",
"visibility": "members",
},
],
}
`;
exports[`Newsletters API Can edit newsletters with updated sender_email 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "591",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Newsletters API Can read a newsletter 1: [body] 1`] = `
Object {
"newsletters": Array [
@ -421,57 +735,61 @@ Object {
}
`;
exports[`Newsletters API Cannot add newsletter with same name 1: [body] 1`] = `
exports[`Newsletters API Can verify property updates 1: [body] 1`] = `
Object {
"newsletters": Array [
Object {
"body_font_category": "serif",
"body_font_category": "sans_serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "My test newsletter",
"recipient_filter": "members",
"sender_email": "test@example.com",
"sender_name": "Test",
"name": "Updated newsletter name",
"sender_email": "verify@example.com",
"sender_name": null,
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"slug": "my-test-newsletter-2",
"slug": "default-newsletter",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "serif",
"title_font_category": "sans_serif",
"visibility": "members",
},
],
}
`;
exports[`Newsletters API Cannot add newsletter with same name 1: [headers] 1`] = `
exports[`Newsletters API Can verify restricted property updates 1: [body] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "524",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": Any<String>,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Newsletters API Cannot add newsletter with same name 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "524",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": Any<String>,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
"newsletters": Array [
Object {
"body_font_category": "sans_serif",
"description": null,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Updated newsletter name",
"sender_email": "verify@example.com",
"sender_name": null,
"sender_reply_to": "newsletter",
"show_badge": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_title": true,
"slug": "default-newsletter",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_font_category": "sans_serif",
"visibility": "members",
},
],
}
`;

View File

@ -9,6 +9,8 @@ const newsletterSnapshot = {
let agent;
describe('Newsletters API', function () {
let mailMocks;
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('newsletters');
@ -16,6 +18,7 @@ describe('Newsletters API', function () {
});
beforeEach(function () {
mailMocks = mockManager.mockMail();
});
afterEach(function () {
@ -26,7 +29,7 @@ describe('Newsletters API', function () {
const newsletter = {
name: 'My test newsletter',
sender_name: 'Test',
sender_email: 'test@example.com',
sender_email: null,
sender_reply_to: 'newsletter',
status: 'active',
subscribe_on_signup: true,
@ -60,11 +63,57 @@ describe('Newsletters API', function () {
});
});
it('Can add a newsletter - with custom sender_email', async function () {
const newsletter = {
name: 'My test newsletter with custom sender_email',
sender_name: 'Test',
sender_email: 'test@example.com',
sender_reply_to: 'newsletter',
status: 'active',
subscribe_on_signup: true,
title_font_category: 'serif',
body_font_category: 'serif',
show_header_icon: true,
show_header_title: true,
show_badge: true,
sort_order: 0
};
await agent
.post(`newsletters/`)
.body({newsletters: [newsletter]})
.expectStatus(201)
.matchBodySnapshot({
newsletters: new Array(1).fill(newsletterSnapshot),
meta: {
sent_email_verification: ['sender_email']
}
})
.matchHeaderSnapshot({
etag: anyEtag,
location: anyString
});
mockManager.assert.sentEmail({
subject: 'Verify email address',
to: 'test@example.com'
});
await agent.get('newsletters/')
.expectStatus(200)
.matchBodySnapshot({
newsletters: new Array(5).fill(newsletterSnapshot)
})
.matchHeaderSnapshot({
etag: anyEtag
});
});
it('Can browse newsletters', async function () {
await agent.get('newsletters/')
.expectStatus(200)
.matchBodySnapshot({
newsletters: new Array(4).fill(newsletterSnapshot)
newsletters: new Array(5).fill(newsletterSnapshot)
})
.matchHeaderSnapshot({
etag: anyEtag
@ -76,7 +125,8 @@ describe('Newsletters API', function () {
.get(`newsletters/${testUtils.DataGenerator.Content.newsletters[0].id}/`)
.expectStatus(200)
.matchBodySnapshot({
newsletters: new Array(1).fill(newsletterSnapshot)
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
etag: anyEtag
@ -109,4 +159,74 @@ describe('Newsletters API', function () {
etag: anyEtag
});
});
it('Can edit newsletters with updated sender_email', async function () {
const res = await agent.get('newsletters?limit=1')
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
etag: anyEtag
});
const id = res.body.newsletters[0].id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
name: 'Updated newsletter name',
sender_email: 'updated@example.com'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot],
meta: {
sent_email_verification: ['sender_email']
}
})
.matchHeaderSnapshot({
etag: anyEtag
});
mockManager.assert.sentEmail({
subject: 'Verify email address',
to: 'updated@example.com'
});
});
it('Can verify property updates', async function () {
const cheerio = require('cheerio');
const res = await agent.get('newsletters?limit=1')
.expectStatus(200);
const id = res.body.newsletters[0].id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
name: 'Updated newsletter name',
sender_email: 'verify@example.com'
}]
})
.expectStatus(200);
const mailHtml = mailMocks.getCall(0).args[0].html;
const $mailHtml = cheerio.load(mailHtml);
const verifyUrl = new URL($mailHtml('[data-test-verify-link]').attr('href'));
// convert Admin URL hash to native URL for easier token param extraction
const token = (new URL(verifyUrl.hash.replace('#', ''), 'http://example.com')).searchParams.get('verifyEmail');
await agent.put(`newsletters/verifications`)
.body({
token
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
});
});
});

View File

@ -1,43 +1,21 @@
const should = require('should');
const sinon = require('sinon');
const getNewslettersServiceInstance = require('../../../../../core/server/services/newsletters');
const models = require('../../../../../core/server/models');
const assert = require('assert');
describe('Newsletters Service', function () {
let newslettersService;
before(function () {
models.init();
});
afterEach(function () {
sinon.restore();
});
describe('Newsletter Service', function () {
it('Provides expected public API', async function () {
newslettersService = require('../../../../../core/server/services/newsletters');
describe('browse', function () {
it('lists all newsletters', async function () {
const findAllStub = {
toJSON: function () {
return [
{
id: 'newsletter-1'
},
{
id: 'newsletter-2'
}
];
}
};
sinon.stub(models.Newsletter, 'findAll').returns(Promise.resolve(findAllStub));
const NewslettersService = getNewslettersServiceInstance({NewsletterModel: models.Newsletter});
const newsletters = await NewslettersService.browse({});
should(newsletters).deepEqual([
{
id: 'newsletter-1'
},
{
id: 'newsletter-2'
}
]);
assert.ok(newslettersService.browse);
assert.ok(newslettersService.edit);
assert.ok(newslettersService.add);
assert.ok(newslettersService.verifyPropertyUpdate);
});
});
});

View File

@ -0,0 +1,194 @@
const sinon = require('sinon');
const assert = require('assert');
// DI requirements
const models = require('../../../../../core/server/models');
const mail = require('../../../../../core/server/services/mail');
// Mocked utilities
const urlUtils = require('../../../../utils/urlUtils');
const {mockManager} = require('../../../../utils/e2e-framework');
const NewslettersService = require('../../../../../core/server/services/newsletters/service');
class TestTokenProvider {
async create(data) {
return JSON.stringify(data);
}
async validate(token) {
return JSON.parse(token);
}
}
describe('NewslettersService', function () {
let newsletterService, getStub, tokenProvider;
before(function () {
models.init();
tokenProvider = new TestTokenProvider();
newsletterService = new NewslettersService({
NewsletterModel: models.Newsletter,
mail,
singleUseTokenProvider: tokenProvider,
urlUtils: urlUtils.stubUrlUtilsFromConfig()
});
});
beforeEach(function () {
getStub = sinon.stub();
sinon.spy(tokenProvider, 'create');
sinon.spy(tokenProvider, 'validate');
mockManager.mockMail();
});
afterEach(function () {
mockManager.restore();
});
// @TODO replace this with a specific function for fetching all available newsletters
describe('browse', function () {
it('lists all newsletters by calling findAll and toJSON', async function () {
const toJSONStub = sinon.stub();
const findAllStub = sinon.stub(models.Newsletter, 'findAll').returns({toJSON: toJSONStub});
await newsletterService.browse({});
sinon.assert.calledOnce(findAllStub);
sinon.assert.calledOnce(toJSONStub);
});
});
describe('add', function () {
let addStub;
beforeEach(function () {
// Stub add as a function that returns a get
addStub = sinon.stub(models.Newsletter, 'add').returns({get: getStub});
});
it('rejects if called with no data', async function () {
assert.rejects(await newsletterService.add, {name: 'TypeError'});
sinon.assert.notCalled(addStub);
});
it('will attempt to add empty object without verification', async function () {
const result = await newsletterService.add({});
assert.equal(result.meta, undefined); // meta property has not been added
sinon.assert.calledOnceWithExactly(addStub, {}, undefined);
});
it('will pass object and options through to model when there are no fields needing verification', async function () {
const data = {name: 'hello world'};
const options = {foo: 'bar'};
const result = await newsletterService.add(data, options);
assert.equal(result.meta, undefined); // meta property has not been added
sinon.assert.calledOnceWithExactly(addStub, data, options);
});
it('will trigger verification when sender_email is provided', async function () {
const data = {name: 'hello world', sender_email: 'test@example.com'};
const options = {foo: 'bar'};
const result = await newsletterService.add(data, options);
assert.deepEqual(result.meta, {
sent_email_verification: [
'sender_email'
]
});
sinon.assert.calledOnceWithExactly(addStub, {name: 'hello world'}, options);
mockManager.assert.sentEmail({to: 'test@example.com'});
sinon.assert.calledOnceWithExactly(tokenProvider.create, {id: undefined, property: 'sender_email', value: 'test@example.com'});
});
});
describe('edit', function () {
let editStub, findOneStub;
beforeEach(function () {
// Stub edit as a function that returns its first argument
editStub = sinon.stub(models.Newsletter, 'edit').returns({get: getStub});
findOneStub = sinon.stub(models.Newsletter, 'findOne').returns({get: getStub});
});
it('rejects if called with no data', async function () {
assert.rejects(await newsletterService.add, {name: 'TypeError'});
sinon.assert.notCalled(editStub);
});
it('will attempt to add empty object without verification', async function () {
const result = await newsletterService.edit({});
assert.equal(result.meta, undefined); // meta property has not been added
sinon.assert.calledOnceWithExactly(editStub, {}, undefined);
});
it('will pass object and options through to model when there are no fields needing verification', async function () {
const data = {name: 'hello world'};
const options = {foo: 'bar'};
const result = await newsletterService.edit(data, options);
assert.equal(result.meta, undefined); // meta property has not been added
sinon.assert.calledOnceWithExactly(editStub, data, options);
sinon.assert.calledOnceWithExactly(findOneStub, options, {require: true});
});
it('will trigger verification when sender_email is provided', async function () {
const data = {name: 'hello world', sender_email: 'test@example.com'};
const options = {foo: 'bar'};
const result = await newsletterService.edit(data, options);
assert.deepEqual(result.meta, {
sent_email_verification: [
'sender_email'
]
});
sinon.assert.calledOnceWithExactly(editStub, {name: 'hello world'}, options);
sinon.assert.calledOnceWithExactly(findOneStub, options, {require: true});
mockManager.assert.sentEmail({to: 'test@example.com'});
sinon.assert.calledOnceWithExactly(tokenProvider.create, {id: undefined, property: 'sender_email', value: 'test@example.com'});
});
it('will NOT trigger verification when sender_email is provided but is already verified', async function () {
const data = {name: 'hello world', sender_email: 'test@example.com'};
const options = {foo: 'bar'};
// The model says this is already verified
getStub.withArgs('sender_email').returns('test@example.com');
const result = await newsletterService.edit(data, options);
assert.deepEqual(result.meta, undefined);
sinon.assert.calledOnceWithExactly(editStub, {name: 'hello world', sender_email: 'test@example.com'}, options);
sinon.assert.calledOnceWithExactly(findOneStub, options, {require: true});
mockManager.assert.sentEmailCount(0);
});
});
describe('verifyPropertyUpdate', function () {
let editStub;
beforeEach(function () {
editStub = sinon.stub(models.Newsletter, 'edit').returns({get: getStub});
sinon.assert.notCalled(editStub);
});
it('rejects if called with no data', async function () {
assert.rejects(await newsletterService.verifyPropertyUpdate, {name: 'TypeError'});
});
it('Updates model with values from token', async function () {
const token = JSON.stringify({id: 'abc123', property: 'sender_email', value: 'test@example.com'});
await newsletterService.verifyPropertyUpdate(token);
sinon.assert.calledOnceWithExactly(editStub, {sender_email: 'test@example.com'}, {id: 'abc123'});
});
});
});