Ghost/core/server/services/mega/mega.js
Nazar Gargol 9ff5fecbaf Fixed knex connection pool errors when scheduling a posts
no issue

- A subquery in mege service that creates email record wasn't using 'options' object needed to track transactions
2019-11-26 17:44:42 +07:00

274 lines
8.2 KiB
JavaScript

const url = require('url');
const moment = require('moment');
const common = require('../../lib/common');
const api = require('../../api');
const membersService = require('../members');
const bulkEmailService = require('../bulk-email');
const models = require('../../models');
const postEmailSerializer = require('./post-email-serializer');
const urlUtils = require('../../lib/url-utils');
const getEmailData = (post, members = []) => {
const emailTmpl = postEmailSerializer.serialize(post);
emailTmpl.from = membersService.config.getEmailFromAddress();
const membersToSendTo = members.filter((member) => {
return membersService.contentGating.checkPostAccess(post, member);
});
const emails = membersToSendTo.map(member => member.email);
const emailData = membersToSendTo.reduce((emailData, member) => {
return Object.assign({
[member.email]: {
unique_id: member.uuid,
unsubscribe_url: createUnsubscribeUrl(member)
}
}, emailData);
}, {});
return {emailTmpl, emails, emailData};
};
const sendEmail = async (post, members) => {
const {emailTmpl, emails, emailData} = getEmailData(post, members);
return bulkEmailService.send(emailTmpl, emails, emailData);
};
const sendTestEmail = async (postModel, emails) => {
const post = await serialize(postModel);
const {emailTmpl} = getEmailData(post);
const emailData = emails.reduce((emailData, email) => {
return Object.assign({
[email]: {
unique_id: 'preview',
unsubscribe_url: createUnsubscribeUrl({})
}
}, emailData);
}, {});
emailTmpl.subject = `${emailTmpl.subject} [Test]`;
return bulkEmailService.send(emailTmpl, emails, emailData);
};
/**
* addEmail
*
* Accepts a post object and creates an email record based on it. Only creates one
* record per post
*
* @param {object} post JSON object
*/
const addEmail = async (post, options) => {
const {members} = await membersService.api.members.list(Object.assign({filter: 'subscribed:true'}, {limit: 'all'}));
const {emailTmpl, emails} = getEmailData(post, members);
// NOTE: don't create email object when there's nobody to send the email to
if (!emails.length) {
return null;
}
const existing = await models.Email.findOne({post_id: post.id});
if (!existing) {
return models.Email.add({
post_id: post.id,
status: 'pending',
email_count: emails.length,
subject: emailTmpl.subject,
html: emailTmpl.html,
plaintext: emailTmpl.plaintext,
submitted_at: moment().toDate()
}, {transacting: options.transacting});
} else {
return existing;
}
};
/**
* 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')
});
};
// NOTE: serialization is needed to make sure we are using current API and do post transformations
// such as image URL transformation from relative to absolute
const serialize = async (model) => {
const frame = {options: {context: {user: true}}};
const apiVersion = model.get('api_version') || 'v3';
const docName = 'posts';
await api.shared
.serializers
.handle
.output(model, {docName: docName, method: 'read'}, api[apiVersion].serializers.output, frame);
return frame.response[docName][0];
};
/**
* createUnsubscribeUrl
*
* Takes a member and returns the url that should be used to unsubscribe
* In case of no member, generates the preview unsubscribe url - `?preview=1`
*
* @param {object} member
* @param {string} member.uuid
*/
function createUnsubscribeUrl(member) {
const siteUrl = urlUtils.getSiteUrl();
const unsubscribeUrl = new URL(siteUrl);
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
if (member.uuid) {
unsubscribeUrl.searchParams.set('uuid', member.uuid);
} else {
unsubscribeUrl.searchParams.set('preview', '1');
}
return unsubscribeUrl.href;
}
/**
* 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) {
throw new common.errors.BadRequestError({
message: 'Unsubscribe failed! Could not find member'
});
}
const {query} = url.parse(req.url, true);
if (!query || !query.uuid) {
throw new common.errors.BadRequestError({
message: (query.preview ? 'Unsubscribe preview' : 'Unsubscribe failed! Could not find member')
});
}
const member = await membersService.api.members.get({
uuid: query.uuid
});
if (!member) {
throw new common.errors.BadRequestError({
message: 'Unsubscribe failed! Could not find member'
});
}
try {
return await membersService.api.members.update({subscribed: false}, {id: member.id});
} catch (err) {
throw new common.errors.InternalServerError({
message: 'Failed to unsubscribe member'
});
}
}
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;
}
const postModel = await models.Post.findOne({id: emailModel.get('post_id')}, {withRelated: ['authors']});
const post = await serialize(postModel);
if (emailModel.get('status') !== 'pending') {
return;
}
const {members} = await membersService.api.members.list(Object.assign({filter: 'subscribed:true'}, {limit: 'all'}));
if (!members.length) {
return;
}
await models.Email.edit({
status: 'submitting'
}, {
id: emailModel.id
});
let meta = [];
let error = null;
try {
// 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
meta = await sendEmail(post, members);
} catch (err) {
common.logging.error(new common.errors.GhostError({
err: err,
context: common.i18n.t('errors.services.mega.requestFailed.error')
}));
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,
error_data: JSON.stringify(failures) // NOTE:need to discuss how we store this
}, {
id: emailModel.id
});
} catch (err) {
common.logging.error(err);
}
}
const statusChangedHandler = (emailModel, options) => {
const emailRetried = emailModel.wasChanged() && (emailModel.get('status') === 'pending') && (emailModel.previous('status') === 'failed');
if (emailRetried) {
pendingEmailHandler(emailModel, options);
}
};
function listen() {
common.events.on('email.added', pendingEmailHandler);
common.events.on('email.edited', statusChangedHandler);
}
// Public API
module.exports = {
listen,
addEmail,
retryFailedEmail,
sendTestEmail,
handleUnsubscribeRequest,
createUnsubscribeUrl
};