2019-11-05 13:02:23 +03:00
|
|
|
const url = require('url');
|
2019-11-06 11:12:45 +03:00
|
|
|
const moment = require('moment');
|
2019-11-04 13:53:42 +03:00
|
|
|
const common = require('../../lib/common');
|
|
|
|
const api = require('../../api');
|
|
|
|
const membersService = require('../members');
|
|
|
|
const bulkEmailService = require('../bulk-email');
|
|
|
|
const models = require('../../models');
|
2019-11-05 08:14:54 +03:00
|
|
|
const postEmailSerializer = require('./post-email-serializer');
|
2019-11-06 13:46:30 +03:00
|
|
|
const urlUtils = require('../../lib/url-utils');
|
2019-11-04 13:53:42 +03:00
|
|
|
|
2019-11-13 20:23:33 +03:00
|
|
|
const getEmailData = (post, members = []) => {
|
2019-11-05 08:14:54 +03:00
|
|
|
const emailTmpl = postEmailSerializer.serialize(post);
|
2019-11-08 06:56:57 +03:00
|
|
|
emailTmpl.from = membersService.config.getEmailFromAddress();
|
2019-11-04 13:53:42 +03:00
|
|
|
|
2019-11-06 13:52:45 +03:00
|
|
|
const membersToSendTo = members.filter((member) => {
|
2019-11-05 07:20:07 +03:00
|
|
|
return membersService.contentGating.checkPostAccess(post, member);
|
2019-11-06 13:52:45 +03:00
|
|
|
});
|
|
|
|
const emails = membersToSendTo.map(member => member.email);
|
|
|
|
const emailData = membersToSendTo.reduce((emailData, member) => {
|
|
|
|
return Object.assign({
|
|
|
|
[member.email]: {
|
2019-11-08 06:58:10 +03:00
|
|
|
unique_id: member.uuid,
|
2019-11-06 13:52:45 +03:00
|
|
|
unsubscribe_url: createUnsubscribeUrl(member)
|
|
|
|
}
|
|
|
|
}, emailData);
|
|
|
|
}, {});
|
2019-11-04 13:53:42 +03:00
|
|
|
|
2019-11-06 14:32:43 +03:00
|
|
|
return {emailTmpl, emails, emailData};
|
|
|
|
};
|
|
|
|
|
|
|
|
const sendEmail = async (post, members) => {
|
|
|
|
const {emailTmpl, emails, emailData} = getEmailData(post, members);
|
|
|
|
|
|
|
|
return bulkEmailService.send(emailTmpl, emails, emailData);
|
2019-11-04 13:53:42 +03:00
|
|
|
};
|
|
|
|
|
2019-11-05 12:09:07 +03:00
|
|
|
const sendTestEmail = async (post, emails) => {
|
2019-11-13 20:23:33 +03:00
|
|
|
const {emailTmpl} = getEmailData(post);
|
|
|
|
const emailData = emails.reduce((emailData, email) => {
|
|
|
|
return Object.assign({
|
|
|
|
[email]: {
|
|
|
|
unique_id: '',
|
|
|
|
unsubscribe_url: ''
|
|
|
|
}
|
|
|
|
}, emailData);
|
|
|
|
}, {});
|
2019-11-06 15:20:12 +03:00
|
|
|
emailTmpl.subject = `${emailTmpl.subject} [Test]`;
|
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
|
|
|
|
*
|
|
|
|
* Accepts a post object and creates an email record based on it. Only creates one
|
|
|
|
* record per post
|
|
|
|
*
|
|
|
|
* @param {object} post JSON object
|
|
|
|
*/
|
2019-11-06 14:32:43 +03:00
|
|
|
const addEmail = async (post) => {
|
|
|
|
const {members} = await membersService.api.members.list(Object.assign({filter: 'subscribed:true'}, {limit: 'all'}));
|
|
|
|
const {emailTmpl, emails} = getEmailData(post, members);
|
|
|
|
|
2019-11-07 12:00:18 +03:00
|
|
|
// NOTE: don't create email object when there's nobody to send the email to
|
|
|
|
if (!emails.length) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2019-11-07 13:09:22 +03:00
|
|
|
const existing = await models.Email.findOne({post_id: post.id});
|
2019-11-06 14:32:43 +03:00
|
|
|
|
|
|
|
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()
|
2019-11-07 13:09:22 +03:00
|
|
|
});
|
2019-11-06 14:32:43 +03:00
|
|
|
} else {
|
|
|
|
return existing;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2019-11-04 13:53:42 +03:00
|
|
|
// 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) => {
|
2019-11-05 08:30:30 +03:00
|
|
|
const frame = {options: {context: {user: true}}};
|
2019-11-04 13:53:42 +03:00
|
|
|
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];
|
|
|
|
};
|
|
|
|
|
2019-11-06 13:46:30 +03:00
|
|
|
/**
|
|
|
|
* createUnsubscribeUrl
|
|
|
|
*
|
|
|
|
* Takes a member and returns the url that should be used to unsubscribe
|
|
|
|
*
|
|
|
|
* @param {object} member
|
|
|
|
* @param {string} member.uuid
|
|
|
|
*/
|
|
|
|
function createUnsubscribeUrl(member) {
|
|
|
|
const siteUrl = urlUtils.getSiteUrl();
|
|
|
|
const unsubscribeUrl = new URL(siteUrl);
|
2019-11-15 12:36:49 +03:00
|
|
|
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
|
|
|
|
unsubscribeUrl.searchParams.set('uuid', member.uuid);
|
2019-11-06 13:46:30 +03:00
|
|
|
|
|
|
|
return unsubscribeUrl.href;
|
|
|
|
}
|
|
|
|
|
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) {
|
|
|
|
throw new common.errors.BadRequestError({
|
|
|
|
message: 'Expected unsubscribe param containing token'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
const {query} = url.parse(req.url, true);
|
2019-11-15 12:36:49 +03:00
|
|
|
if (!query || !query.uuid) {
|
2019-11-05 13:02:23 +03:00
|
|
|
throw new common.errors.BadRequestError({
|
|
|
|
message: 'Expected unsubscribe param containing token'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
|
|
|
throw new common.errors.BadRequestError({
|
|
|
|
message: 'Expected valid subscribe param - could not find member'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2019-11-15 12:36:49 +03:00
|
|
|
return await membersService.api.members.update({subscribed: false}, {id: member.id});
|
2019-11-05 13:02:23 +03:00
|
|
|
} catch (err) {
|
|
|
|
throw new common.errors.InternalServerError({
|
|
|
|
message: 'Failed to unsubscribe member'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-11-06 14:32:43 +03:00
|
|
|
async function listener(emailModel, options) {
|
2019-11-04 13:53:42 +03:00
|
|
|
// 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;
|
|
|
|
}
|
2019-11-14 08:15:05 +03:00
|
|
|
const postModel = await models.Post.findOne({id: emailModel.get('post_id')}, {withRelated: ['authors']});
|
2019-11-04 13:53:42 +03:00
|
|
|
|
2019-11-06 14:32:43 +03:00
|
|
|
const post = await serialize(postModel);
|
2019-11-05 07:20:03 +03:00
|
|
|
|
2019-11-06 14:32:43 +03:00
|
|
|
if (emailModel.get('status') !== 'pending') {
|
2019-11-04 13:53:42 +03:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-11-06 11:12:45 +03:00
|
|
|
const {members} = await membersService.api.members.list(Object.assign({filter: 'subscribed:true'}, {limit: 'all'}));
|
|
|
|
|
|
|
|
if (!members.length) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-11-07 11:53:50 +03:00
|
|
|
await models.Email.edit({
|
|
|
|
status: 'submitting'
|
|
|
|
}, {
|
2019-11-07 13:09:22 +03:00
|
|
|
id: emailModel.id
|
2019-11-07 11:53:50 +03:00
|
|
|
});
|
|
|
|
|
2019-11-15 14:25:33 +03:00
|
|
|
let meta = [];
|
|
|
|
let error;
|
2019-11-07 11:53:50 +03:00
|
|
|
|
2019-11-15 14:25:33 +03:00
|
|
|
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);
|
|
|
|
}
|
2019-11-04 13:53:42 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
function listen() {
|
2019-11-06 14:32:43 +03:00
|
|
|
common.events.on('email.added', listener);
|
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-05 13:02:23 +03:00
|
|
|
sendTestEmail,
|
2019-11-06 13:46:30 +03:00
|
|
|
handleUnsubscribeRequest,
|
|
|
|
createUnsubscribeUrl
|
2019-11-04 13:53:42 +03:00
|
|
|
};
|