Ghost/ghost/core/test/utils/batch-email-utils.js
Simon Backx 17ec1e8937
Added email address alignment protections (#19094)
ref GRO-54
fixes GRO-63
fixes GRO-62
fixes GRO-69

When the config `hostSettings:managedEmail:enabled` is enabled, or the
new flag (`newEmailAddresses`) is enabled for self-hosters, we'll start
to check the from addresses of all outgoing emails more strictly.

- Current flow: nothing changes if the managedEmail config is not set or
the `newEmailAddresses` feature flag is not set
- When managedEmail is enabled: never allow to send an email from any
chosen email. We always use `mail.from` for all outgoing emails. Custom
addresses should be set as replyTo instead. Changing the newsletter
sender_email is not allowed anymore (and ignored if it is set).
- When managedEmail is enabled with a custom sending domain: if a from
address doesn't match the sending domain, we'll default to mail.from and
use the original as a replyTo if appropriate and only when no other
replyTo was set. A newsletter sender email addresss can only be set to
an email address on this domain.
- When `newEmailAddresses` is enabled: self hosters are free to set all
email addresses to whatever they want, without verification. In addition
to that, we stop making up our own email addresses and send from
`mail.from` by default instead of generating a `noreply`+ `@` +
`sitedomain.com` address

A more in depth example of all cases can be seen in
`ghost/core/test/integration/services/email-addresses.test.js`

Includes lots of new E2E tests for most new situations. Apart from that,
all email snapshots are changed because the from and replyTo addresses
are now included in snapshots (so we can see unexpected changes in the
future).

Dropped test coverage requirement, because tests were failing coverage
locally, but not in CI

Fixed settings test that set the site title to an array - bug tracked in
GRO-68
2023-11-23 10:25:30 +01:00

220 lines
7.3 KiB
JavaScript

const {fixtureManager, mockManager} = require('./e2e-framework');
const moment = require('moment');
const ObjectId = require('bson-objectid').default;
const models = require('../../core/server/models');
const sinon = require('sinon');
const jobManager = require('../../core/server/services/jobs/job-service');
const escapeRegExp = require('lodash/escapeRegExp');
const should = require('should');
const assert = require('assert/strict');
const getDefaultNewsletter = async function () {
const newsletterSlug = fixtureManager.get('newsletters', 0).slug;
return await models.Newsletter.findOne({slug: newsletterSlug});
};
let postCounter = 0;
async function createPublishedPostEmail(agent, settings = {}, email_recipient_filter) {
const post = {
title: 'A random test post',
status: 'draft',
feature_image_alt: 'Testing sending',
feature_image_caption: 'Testing <b>feature image caption</b>',
created_at: moment().subtract(2, 'days').toISOString(),
updated_at: moment().subtract(2, 'days').toISOString(),
created_by: ObjectId().toHexString(),
updated_by: ObjectId().toHexString(),
...settings
};
const res = await agent.post('posts/')
.body({posts: [post]})
.expectStatus(201);
const id = res.body.posts[0].id;
// Make sure all posts are published in the samre order, with minimum 1s difference (to have consistent ordering when including latests posts)
postCounter += 1;
const updatedPost = {
status: 'published',
updated_at: res.body.posts[0].updated_at,
// Fixed publish date to make sure snapshots are consistent
published_at: moment(new Date(2050, 0, 1, 12, 0, postCounter)).toISOString()
};
const newsletterSlug = fixtureManager.get('newsletters', 0).slug;
await agent.put(`posts/${id}/?newsletter=${newsletterSlug}${email_recipient_filter ? `&email_segment=${email_recipient_filter}` : ''}`)
.body({posts: [updatedPost]})
.expectStatus(200);
const emailModel = await models.Email.findOne({
post_id: id
});
assert(!!emailModel);
return emailModel;
}
let lastEmailModel;
/**
* @typedef {{html: string, plaintext: string, emailModel: any, recipientData: any, from: string, replyTo?: string}} SendEmail
*/
/**
* Try sending an email, and assert that it succeeded
* @returns {Promise<SendEmail>}
*/
async function sendEmail(agent, settings, email_recipient_filter) {
// Prepare a post and email model
const completedPromise = jobManager.awaitCompletion('batch-sending-service-job');
const emailModel = await createPublishedPostEmail(agent, settings, email_recipient_filter);
assert.ok(emailModel.get('subject'));
assert.ok(emailModel.get('from'));
assert.equal(emailModel.get('source_type'), settings && settings.mobiledoc ? 'mobiledoc' : 'lexical');
// Await sending job
await completedPromise;
await emailModel.refresh();
assert.equal(emailModel.get('status'), 'submitted');
lastEmailModel = emailModel;
// Get the email that was sent
return {emailModel, ...(await getLastEmail())};
}
/**
* Try sending an email, and assert that it failed
* @returns {Promise<{emailModel: any}>}
*/
async function sendFailedEmail(agent, settings, email_recipient_filter) {
// Prepare a post and email model
const completedPromise = jobManager.awaitCompletion('batch-sending-service-job');
const emailModel = await createPublishedPostEmail(agent, settings, email_recipient_filter);
assert.ok(emailModel.get('subject'));
assert.ok(emailModel.get('from'));
assert.equal(emailModel.get('source_type'), settings && settings.mobiledoc ? 'mobiledoc' : 'lexical');
// Await sending job
await completedPromise;
await emailModel.refresh();
assert.equal(emailModel.get('status'), 'failed');
lastEmailModel = emailModel;
// Get the email that was sent
return {emailModel};
}
async function retryEmail(agent, emailId) {
await agent.put(`emails/${emailId}/retry`)
.expectStatus(200);
}
/**
* Returns the last email that was sent via the stub, with all recipient variables replaced
* @returns {Promise<SendEmail>}
*/
async function getLastEmail() {
const mailgunCreateMessageStub = mockManager.getMailgunCreateMessageStub();
assert.ok(mailgunCreateMessageStub);
sinon.assert.called(mailgunCreateMessageStub);
const messageData = mailgunCreateMessageStub.lastCall.lastArg;
let html = messageData.html;
let plaintext = messageData.text;
const recipientVariables = JSON.parse(messageData['recipient-variables']);
const recipientData = recipientVariables[Object.keys(recipientVariables)[0]];
for (const [key, value] of Object.entries(recipientData)) {
html = html.replace(new RegExp(`%recipient.${key}%`, 'g'), value);
plaintext = plaintext.replace(new RegExp(`%recipient.${key}%`, 'g'), value);
}
return {
emailModel: lastEmailModel,
...messageData,
html,
plaintext,
recipientData
};
}
function testCleanedSnapshot({html, plaintext}, ignoreReplacements) {
for (const {match, replacement} of ignoreReplacements) {
if (match instanceof RegExp) {
html = html.replace(match, replacement);
plaintext = plaintext.replace(match, replacement);
} else {
html = html.replace(new RegExp(escapeRegExp(match), 'g'), replacement);
plaintext = plaintext.replace(new RegExp(escapeRegExp(match), 'g'), replacement);
}
}
should({html, plaintext}).matchSnapshot();
}
async function matchEmailSnapshot() {
const lastEmail = await getLastEmail();
const defaultNewsletter = await lastEmail.emailModel.getLazyRelation('newsletter');
const linkRegexp = /http:\/\/127\.0\.0\.1:2369\/r\/\w+/g;
const ignoreReplacements = [
{
match: /\d{1,2}\s\w+\s\d{4}/g,
replacement: 'date'
},
{
match: defaultNewsletter.get('uuid'),
replacement: 'requested-newsletter-uuid'
},
{
match: lastEmail.emailModel.get('post_id'),
replacement: 'post-id'
},
{
match: (await lastEmail.emailModel.getLazyRelation('post')).get('uuid'),
replacement: 'post-uuid'
},
{
match: linkRegexp,
replacement: 'http://127.0.0.1:2369/r/xxxxxx'
},
{
match: linkRegexp,
replacement: 'http://127.0.0.1:2369/r/xxxxxx'
}
];
if (lastEmail.recipientData.uuid) {
ignoreReplacements.push({
match: lastEmail.recipientData.uuid,
replacement: 'member-uuid'
});
} else {
// Sometimes uuid is not used if link tracking is disabled
// Need to replace unsubscribe url instead (uuid is missing but it is inside the usubscribe url, causing snapshot updates)
// Need to use unshift to make replacement work before newsletter uuid
ignoreReplacements.unshift({
match: lastEmail.recipientData.unsubscribe_url,
replacement: 'unsubscribe_url'
});
}
testCleanedSnapshot(lastEmail, ignoreReplacements);
}
module.exports = {
getDefaultNewsletter,
sendEmail,
sendFailedEmail,
retryEmail,
matchEmailSnapshot,
getLastEmail
};