2019-11-26 20:39:50 +03:00
|
|
|
const _ = require('lodash');
|
2020-07-23 20:30:07 +03:00
|
|
|
const debug = require('ghost-ignition').debug('mega');
|
2019-11-05 13:02:23 +03:00
|
|
|
const url = require('url');
|
2019-11-06 11:12:45 +03:00
|
|
|
const moment = require('moment');
|
2020-04-30 22:26:12 +03:00
|
|
|
const errors = require('@tryghost/errors');
|
2020-05-28 21:30:23 +03:00
|
|
|
const {events, i18n} = require('../../lib/common');
|
|
|
|
const logging = require('../../../shared/logging');
|
2019-11-04 13:53:42 +03:00
|
|
|
const membersService = require('../members');
|
|
|
|
const bulkEmailService = require('../bulk-email');
|
2020-08-12 19:01:59 +03:00
|
|
|
const jobService = require('../jobs');
|
2019-11-04 13:53:42 +03:00
|
|
|
const models = require('../../models');
|
2019-11-05 08:14:54 +03:00
|
|
|
const postEmailSerializer = require('./post-email-serializer');
|
2019-11-04 13:53:42 +03:00
|
|
|
|
2020-08-06 16:19:39 +03:00
|
|
|
const getEmailData = async (postModel, memberModels = []) => {
|
|
|
|
const startTime = Date.now();
|
|
|
|
debug(`getEmailData: starting for ${memberModels.length} members`);
|
2020-04-20 14:24:05 +03:00
|
|
|
const {emailTmpl, replacements} = await postEmailSerializer.serialize(postModel);
|
2020-04-17 12:22:53 +03:00
|
|
|
|
2020-04-20 14:24:05 +03:00
|
|
|
emailTmpl.from = membersService.config.getEmailFromAddress();
|
2020-04-17 12:22:53 +03:00
|
|
|
|
2020-04-20 14:24:05 +03:00
|
|
|
// update templates to use Mailgun variable syntax for replacements
|
|
|
|
replacements.forEach((replacement) => {
|
|
|
|
emailTmpl[replacement.format] = emailTmpl[replacement.format].replace(
|
|
|
|
replacement.match,
|
|
|
|
`%recipient.${replacement.id}%`
|
|
|
|
);
|
2020-04-17 12:22:53 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
const emails = [];
|
|
|
|
const emailData = {};
|
2020-08-06 16:19:39 +03:00
|
|
|
memberModels.forEach((memberModel) => {
|
|
|
|
emails.push(memberModel.get('email'));
|
2020-04-17 12:22:53 +03:00
|
|
|
|
2020-04-20 16:25:58 +03:00
|
|
|
// first_name is a computed property only used here for now
|
2020-04-17 12:22:53 +03:00
|
|
|
// TODO: move into model computed property or output serializer?
|
2020-08-06 16:19:39 +03:00
|
|
|
memberModel.first_name = (memberModel.get('name') || '').split(' ')[0];
|
2020-04-17 12:22:53 +03:00
|
|
|
|
|
|
|
// add static data to mailgun template variables
|
|
|
|
const data = {
|
2020-08-06 16:19:39 +03:00
|
|
|
unique_id: memberModel.uuid,
|
|
|
|
unsubscribe_url: postEmailSerializer.createUnsubscribeUrl(memberModel.get('uuid'))
|
2020-04-17 12:22:53 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
// add replacement data/requested fallback to mailgun template variables
|
2020-04-20 14:24:05 +03:00
|
|
|
replacements.forEach(({id, memberProp, fallback}) => {
|
2020-08-06 16:19:39 +03:00
|
|
|
data[id] = memberModel[memberProp] || fallback || '';
|
2020-04-17 12:22:53 +03:00
|
|
|
});
|
|
|
|
|
2020-08-06 16:19:39 +03:00
|
|
|
emailData[memberModel.get('email')] = data;
|
2020-04-17 12:22:53 +03:00
|
|
|
});
|
2019-11-04 13:53:42 +03:00
|
|
|
|
2020-08-06 16:19:39 +03:00
|
|
|
debug(`getEmailData: done (${Date.now() - startTime}ms)`);
|
2019-11-06 14:32:43 +03:00
|
|
|
return {emailTmpl, emails, emailData};
|
|
|
|
};
|
|
|
|
|
2020-08-06 16:19:39 +03:00
|
|
|
const sendEmail = async (postModel, memberModels) => {
|
|
|
|
const {emailTmpl, emails, emailData} = await getEmailData(postModel, memberModels);
|
2019-11-06 14:32:43 +03:00
|
|
|
|
|
|
|
return bulkEmailService.send(emailTmpl, emails, emailData);
|
2019-11-04 13:53:42 +03:00
|
|
|
};
|
|
|
|
|
2019-11-26 19:07:04 +03:00
|
|
|
const sendTestEmail = async (postModel, toEmails) => {
|
2020-04-17 14:15:26 +03:00
|
|
|
const recipients = await Promise.all(toEmails.map(async (email) => {
|
2020-08-12 16:17:44 +03:00
|
|
|
const member = await membersService.api.members.get({email});
|
2020-08-06 16:19:39 +03:00
|
|
|
return member || new models.Member({email});
|
2020-04-17 14:15:26 +03:00
|
|
|
}));
|
2019-11-27 08:28:21 +03:00
|
|
|
const {emailTmpl, emails, emailData} = await getEmailData(postModel, recipients);
|
2019-12-03 18:26:25 +03:00
|
|
|
emailTmpl.subject = `[Test] ${emailTmpl.subject}`;
|
2019-11-13 20:23:33 +03:00
|
|
|
return bulkEmailService.send(emailTmpl, emails, emailData);
|
2019-11-05 12:09:07 +03:00
|
|
|
};
|
|
|
|
|
2019-11-07 07:10:36 +03:00
|
|
|
/**
|
|
|
|
* addEmail
|
|
|
|
*
|
2019-11-26 19:07:04 +03:00
|
|
|
* Accepts a post model and creates an email record based on it. Only creates one
|
2019-11-07 07:10:36 +03:00
|
|
|
* record per post
|
|
|
|
*
|
2019-11-26 19:07:04 +03:00
|
|
|
* @param {object} postModel Post Model Object
|
2019-11-07 07:10:36 +03:00
|
|
|
*/
|
2019-11-27 13:00:27 +03:00
|
|
|
|
2019-11-26 19:07:04 +03:00
|
|
|
const addEmail = async (postModel, options) => {
|
2019-11-26 20:39:50 +03:00
|
|
|
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
|
2020-08-06 16:19:39 +03:00
|
|
|
const filterOptions = Object.assign({}, knexOptions, {filter: 'subscribed:true', limit: 1});
|
2019-11-26 20:39:50 +03:00
|
|
|
|
2020-08-06 16:19:39 +03:00
|
|
|
if (postModel.get('visibility') === 'paid') {
|
|
|
|
filterOptions.paid = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
const startRetrieve = Date.now();
|
|
|
|
debug('addEmail: retrieving members count');
|
2020-08-12 16:17:44 +03:00
|
|
|
const {meta: {pagination: {total: membersCount}}} = await membersService.api.members.list(Object.assign({}, knexOptions, filterOptions));
|
2020-08-06 16:19:39 +03:00
|
|
|
debug(`addEmail: retrieved members count - ${membersCount} members (${Date.now() - startRetrieve}ms)`);
|
2019-11-06 14:32:43 +03:00
|
|
|
|
2019-11-07 12:00:18 +03:00
|
|
|
// NOTE: don't create email object when there's nobody to send the email to
|
2020-08-06 16:19:39 +03:00
|
|
|
if (membersCount === 0) {
|
2019-11-07 12:00:18 +03:00
|
|
|
return null;
|
|
|
|
}
|
2019-11-26 20:39:50 +03:00
|
|
|
|
2019-11-26 19:07:04 +03:00
|
|
|
const postId = postModel.get('id');
|
2019-11-26 20:39:50 +03:00
|
|
|
const existing = await models.Email.findOne({post_id: postId}, knexOptions);
|
2019-11-06 14:32:43 +03:00
|
|
|
|
|
|
|
if (!existing) {
|
2020-04-20 17:35:33 +03:00
|
|
|
// get email contents and perform replacements using no member data so
|
|
|
|
// we have a decent snapshot of email content for later display
|
|
|
|
const {emailTmpl, replacements} = await postEmailSerializer.serialize(postModel, {isBrowserPreview: true});
|
|
|
|
replacements.forEach((replacement) => {
|
|
|
|
emailTmpl[replacement.format] = emailTmpl[replacement.format].replace(
|
|
|
|
replacement.match,
|
|
|
|
replacement.fallback || ''
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
2019-11-06 14:32:43 +03:00
|
|
|
return models.Email.add({
|
2019-11-26 19:07:04 +03:00
|
|
|
post_id: postId,
|
2019-11-06 14:32:43 +03:00
|
|
|
status: 'pending',
|
2020-08-06 16:19:39 +03:00
|
|
|
email_count: membersCount,
|
2019-11-06 14:32:43 +03:00
|
|
|
subject: emailTmpl.subject,
|
|
|
|
html: emailTmpl.html,
|
|
|
|
plaintext: emailTmpl.plaintext,
|
|
|
|
submitted_at: moment().toDate()
|
2019-11-26 20:39:50 +03:00
|
|
|
}, knexOptions);
|
2019-11-06 14:32:43 +03:00
|
|
|
} else {
|
|
|
|
return existing;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2019-11-18 17:28:54 +03:00
|
|
|
/**
|
|
|
|
* retryFailedEmail
|
|
|
|
*
|
|
|
|
* Accepts an Email model and resets it's fields to trigger retry listeners
|
|
|
|
*
|
|
|
|
* @param {object} model Email model
|
|
|
|
*/
|
|
|
|
const retryFailedEmail = async (model) => {
|
|
|
|
return await models.Email.edit({
|
|
|
|
status: 'pending'
|
|
|
|
}, {
|
|
|
|
id: model.get('id')
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2019-11-05 13:02:23 +03:00
|
|
|
/**
|
|
|
|
* handleUnsubscribeRequest
|
|
|
|
*
|
|
|
|
* Takes a request/response pair and reads the `unsubscribe` query parameter,
|
|
|
|
* using the content to update the members service to set the `subscribed` flag
|
|
|
|
* to false on the member
|
|
|
|
*
|
|
|
|
* If any operation fails, or the request is invalid the function will error - so using
|
|
|
|
* as middleware should consider wrapping with `try/catch`
|
|
|
|
*
|
|
|
|
* @param {Request} req
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
*/
|
|
|
|
async function handleUnsubscribeRequest(req) {
|
|
|
|
if (!req.url) {
|
2020-04-30 22:26:12 +03:00
|
|
|
throw new errors.BadRequestError({
|
2019-11-26 13:02:53 +03:00
|
|
|
message: 'Unsubscribe failed! Could not find member'
|
2019-11-05 13:02:23 +03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
const {query} = url.parse(req.url, true);
|
2019-11-15 12:36:49 +03:00
|
|
|
if (!query || !query.uuid) {
|
2020-04-30 22:26:12 +03:00
|
|
|
throw new errors.BadRequestError({
|
2019-11-26 13:02:53 +03:00
|
|
|
message: (query.preview ? 'Unsubscribe preview' : 'Unsubscribe failed! Could not find member')
|
2019-11-05 13:02:23 +03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
const member = await membersService.api.members.get({
|
2019-11-15 12:36:49 +03:00
|
|
|
uuid: query.uuid
|
2019-11-05 13:02:23 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
if (!member) {
|
2020-04-30 22:26:12 +03:00
|
|
|
throw new errors.BadRequestError({
|
2019-11-26 13:02:53 +03:00
|
|
|
message: 'Unsubscribe failed! Could not find member'
|
2019-11-05 13:02:23 +03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2020-09-02 09:32:34 +03:00
|
|
|
const memberModel = await membersService.api.members.update({subscribed: false}, {id: member.id});
|
|
|
|
return memberModel.toJSON();
|
2019-11-05 13:02:23 +03:00
|
|
|
} catch (err) {
|
2020-04-30 22:26:12 +03:00
|
|
|
throw new errors.InternalServerError({
|
2019-11-05 13:02:23 +03:00
|
|
|
message: 'Failed to unsubscribe member'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-12 19:01:59 +03:00
|
|
|
async function sendEmailJob({emailModel, options}) {
|
2019-11-14 08:15:05 +03:00
|
|
|
const postModel = await models.Post.findOne({id: emailModel.get('post_id')}, {withRelated: ['authors']});
|
2019-11-15 14:25:33 +03:00
|
|
|
let meta = [];
|
2019-11-18 17:28:54 +03:00
|
|
|
let error = null;
|
2020-08-06 16:19:39 +03:00
|
|
|
let startEmailSend = null;
|
2019-11-07 11:53:50 +03:00
|
|
|
|
2019-11-15 14:25:33 +03:00
|
|
|
try {
|
2020-02-13 08:13:36 +03:00
|
|
|
// Check host limit for allowed member count and throw error if over limit
|
2020-07-23 20:30:07 +03:00
|
|
|
await membersService.checkHostLimit();
|
|
|
|
|
|
|
|
// No need to fetch list until after we've passed the check
|
2020-08-06 16:19:39 +03:00
|
|
|
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
|
|
|
|
const filterOptions = Object.assign({}, knexOptions, {filter: 'subscribed:true', limit: 'all'});
|
|
|
|
|
|
|
|
if (postModel.get('visibility') === 'paid') {
|
|
|
|
filterOptions.paid = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
const startRetrieve = Date.now();
|
2020-07-23 20:30:07 +03:00
|
|
|
debug('pendingEmailHandler: retrieving members list');
|
2020-08-12 16:17:44 +03:00
|
|
|
const {data: members} = await membersService.api.members.list(Object.assign({}, knexOptions, filterOptions));
|
2020-08-06 16:19:39 +03:00
|
|
|
debug(`pendingEmailHandler: retrieved members list - ${members.length} members (${Date.now() - startRetrieve}ms)`);
|
2020-07-23 20:30:07 +03:00
|
|
|
|
|
|
|
if (!members.length) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
await models.Email.edit({
|
|
|
|
status: 'submitting'
|
|
|
|
}, {
|
|
|
|
id: emailModel.id
|
|
|
|
});
|
|
|
|
|
2019-11-15 14:25:33 +03:00
|
|
|
// NOTE: meta can contains an array which can be a mix of successful and error responses
|
|
|
|
// needs filtering and saving objects of {error, batchData} form to separate property
|
2020-08-06 16:19:39 +03:00
|
|
|
debug('pendingEmailHandler: sending email');
|
|
|
|
startEmailSend = Date.now();
|
2019-11-26 19:07:04 +03:00
|
|
|
meta = await sendEmail(postModel, members);
|
2020-08-06 16:19:39 +03:00
|
|
|
debug(`pendingEmailHandler: sent email (${Date.now() - startEmailSend}ms)`);
|
2019-11-15 14:25:33 +03:00
|
|
|
} catch (err) {
|
2020-08-06 16:19:39 +03:00
|
|
|
if (startEmailSend) {
|
|
|
|
debug(`pendingEmailHandler: send email failed (${Date.now() - startEmailSend}ms)`);
|
|
|
|
}
|
2020-04-30 22:26:12 +03:00
|
|
|
logging.error(new errors.GhostError({
|
2019-11-15 14:25:33 +03:00
|
|
|
err: err,
|
2020-04-30 22:26:12 +03:00
|
|
|
context: i18n.t('errors.services.mega.requestFailed.error')
|
2019-11-15 14:25:33 +03:00
|
|
|
}));
|
|
|
|
error = err.message;
|
|
|
|
}
|
|
|
|
|
|
|
|
const successes = meta.filter(response => (response instanceof bulkEmailService.SuccessfulBatch));
|
|
|
|
const failures = meta.filter(response => (response instanceof bulkEmailService.FailedBatch));
|
|
|
|
const batchStatus = successes.length ? 'submitted' : 'failed';
|
|
|
|
|
|
|
|
if (!error && failures.length) {
|
|
|
|
error = failures[0].error.message;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (error && error.length > 2000) {
|
|
|
|
error = error.substring(0, 2000);
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
// CASE: the batch partially succeeded
|
|
|
|
await models.Email.edit({
|
|
|
|
status: batchStatus,
|
|
|
|
meta: JSON.stringify(successes),
|
|
|
|
error: error,
|
2020-08-12 19:01:59 +03:00
|
|
|
error_data: JSON.stringify(failures) // NOTE: need to discuss how we store this
|
2019-11-15 14:25:33 +03:00
|
|
|
}, {
|
|
|
|
id: emailModel.id
|
|
|
|
});
|
|
|
|
} catch (err) {
|
2020-04-30 22:26:12 +03:00
|
|
|
logging.error(err);
|
2019-11-15 14:25:33 +03:00
|
|
|
}
|
2019-11-04 13:53:42 +03:00
|
|
|
}
|
|
|
|
|
2020-08-12 19:01:59 +03:00
|
|
|
async function pendingEmailHandler(emailModel, options) {
|
|
|
|
// CASE: do not send email if we import a database
|
|
|
|
// TODO: refactor post.published events to never fire on importing
|
|
|
|
if (options && options.importing) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (emailModel.get('status') !== 'pending') {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
return jobService.addJob(sendEmailJob, {emailModel, options});
|
|
|
|
}
|
|
|
|
|
2019-11-18 17:28:54 +03:00
|
|
|
const statusChangedHandler = (emailModel, options) => {
|
2020-04-17 12:22:53 +03:00
|
|
|
const emailRetried = emailModel.wasChanged()
|
|
|
|
&& emailModel.get('status') === 'pending'
|
|
|
|
&& emailModel.previous('status') === 'failed';
|
2019-11-18 17:28:54 +03:00
|
|
|
|
|
|
|
if (emailRetried) {
|
|
|
|
pendingEmailHandler(emailModel, options);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2019-11-04 13:53:42 +03:00
|
|
|
function listen() {
|
2020-04-30 22:26:12 +03:00
|
|
|
events.on('email.added', pendingEmailHandler);
|
|
|
|
events.on('email.edited', statusChangedHandler);
|
2019-11-04 13:53:42 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// Public API
|
|
|
|
module.exports = {
|
2019-11-05 12:09:07 +03:00
|
|
|
listen,
|
2019-11-06 14:32:43 +03:00
|
|
|
addEmail,
|
2019-11-18 17:28:54 +03:00
|
|
|
retryFailedEmail,
|
2019-11-05 13:02:23 +03:00
|
|
|
sendTestEmail,
|
2019-11-26 19:07:04 +03:00
|
|
|
handleUnsubscribeRequest
|
2019-11-04 13:53:42 +03:00
|
|
|
};
|