Merge branch 'mega'

This commit is contained in:
Kevin Ansfield 2019-11-27 12:12:27 +00:00
commit f9f2d36f53
72 changed files with 3040 additions and 254 deletions

View File

@ -0,0 +1,27 @@
const ParentRouter = require('./ParentRouter');
const controllers = require('./controllers');
/**
* @description Unsubscribe Router.
*
* "/unsubscribe/" -> Unsubscribe Router
*/
class UnsubscribeRouter extends ParentRouter {
constructor() {
super('UnsubscribeRouter');
// @NOTE: hardcoded, not configurable
this.route = {value: '/unsubscribe/'};
this._registerRoutes();
}
/**
* @description Register all routes of this router.
* @private
*/
_registerRoutes() {
this.mountRoute(this.route.value, controllers.unsubscribe);
}
}
module.exports = UnsubscribeRouter;

View File

@ -8,6 +8,7 @@ const CollectionRouter = require('./CollectionRouter');
const TaxonomyRouter = require('./TaxonomyRouter');
const PreviewRouter = require('./PreviewRouter');
const ParentRouter = require('./ParentRouter');
const UnsubscribeRouter = require('./UnsubscribeRouter');
const defaultApiVersion = 'v3';
@ -52,7 +53,7 @@ module.exports.init = (options = {start: false}) => {
* The routers are created in a specific order. This order defines who can get a resource first or
* who can dominant other routers.
*
* 1. Preview Router: Is the strongest and is an inbuilt feature, which you can never override.
* 1. Preview + Unsubscribe Routers: Strongest inbuilt features, which you can never override.
* 2. Static Routes: Very strong, because you can override any urls and redirect to a static route.
* 3. Taxonomies: Stronger than collections, because it's an inbuilt feature.
* 4. Collections
@ -62,8 +63,11 @@ module.exports.init = (options = {start: false}) => {
module.exports.start = (apiVersion) => {
const RESOURCE_CONFIG = require(`./config/${apiVersion}`);
const previewRouter = new PreviewRouter(RESOURCE_CONFIG);
const unsubscribeRouter = new UnsubscribeRouter();
siteRouter.mountRouter(unsubscribeRouter.router());
registry.setRouter('unsubscribeRouter', unsubscribeRouter);
const previewRouter = new PreviewRouter(RESOURCE_CONFIG);
siteRouter.mountRouter(previewRouter.router());
registry.setRouter('previewRouter', previewRouter);

View File

@ -21,5 +21,9 @@ module.exports = {
get static() {
return require('./static');
},
get unsubscribe() {
return require('./unsubscribe');
}
};

View File

@ -0,0 +1,31 @@
const debug = require('ghost-ignition').debug('services:routing:controllers:unsubscribe');
const path = require('path');
const megaService = require('../../../../server/services/mega');
const labsService = require('../../../../server/services/labs');
const helpers = require('../../../services/routing/helpers');
module.exports = async function unsubscribeController(req, res, next) {
debug('unsubscribeController');
if (!labsService.isSet('members')) {
return next();
}
let data = {};
try {
data.member = await megaService.mega.handleUnsubscribeRequest(req);
} catch (err) {
data.error = err.message;
}
const templateName = 'unsubscribe';
res.routerOptions = {
type: 'custom',
templates: templateName,
defaultTemplate: path.resolve(__dirname, '../../../views/', templateName)
};
return helpers.renderer(req, res, data);
};

View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>
{{#if member}}Successfully Unsubscribed{{/if}}
{{#if error}}Unsubscribe Failed{{/if}}
</title>
<link rel="stylesheet" href="{{asset "public/ghost.css" hasMinFile="true"}}" />
</head>
<body>
<div class="gh-app">
<div class="gh-viewport">
<main class="gh-main" role="main">
<div class="gh-flow">
<header class="gh-flow-head gh-flow-head-unsubscribe">
<nav class="gh-flow-nav">
<a href="{{@site.url}}" class="gh-flow-back-plain">
<!--?xml version="1.0" encoding="UTF-8" standalone="no"?-->
<svg width="17px" height="27px" viewBox="0 0 17 27" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"
stroke-linecap="round" stroke-linejoin="round">
<g id="Stroke-42" transform="translate(2.000000, 2.000000)" stroke-width="3"
stroke="#7d878a">
<polyline points="13.5401 0.4256 0.3971 11.9256 13.5401 23.4256"></polyline>
</g>
</g>
</svg>
Back to {{@site.title}}
</a>
</nav>
</header>
<div class="gh-flow-content-wrap">
<section class="gh-flow-content gh-flow-content-unsubscribe">
<p>
{{#if error}}
{{error}}
{{else}}
{{#if member}}<span class="gh-flow-em">{{member.email}}</span> has been successfully unsubscribed{{/if}}
{{/if}}
</p>
</section>
</div>
</div>
</main>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,69 @@
const models = require('../../models');
const common = require('../../lib/common');
const mega = require('../../services/mega');
module.exports = {
docName: 'email_preview',
read: {
options: [
'fields'
],
validation: {
options: {
fields: ['html', 'plaintext', 'subject']
}
},
data: [
'id',
'status'
],
permissions: true,
query(frame) {
const options = Object.assign(frame.options, {formats: 'html,plaintext', withRelated: ['authors', 'posts_meta']});
const data = Object.assign(frame.data, {status: 'all'});
return models.Post.findOne(data, options)
.then((model) => {
if (!model) {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.posts.postNotFound')
});
}
return mega.postEmailSerializer.serialize(model, {isBrowserPreview: true});
});
}
},
sendTestEmail: {
statusCode: 200,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
const options = Object.assign(frame.options, {status: 'all'});
let model = await models.Post.findOne(options, {withRelated: ['authors']});
if (!model) {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.posts.postNotFound')
});
}
const {emails = []} = frame.data;
const response = await mega.mega.sendTestEmail(model, emails);
if (response && response[0] && response[0].error) {
throw new common.errors.EmailError({
message: response[0].error.message
});
}
return response;
}
}
};

View File

@ -0,0 +1,59 @@
const models = require('../../models');
const common = require('../../lib/common');
const megaService = require('../../services/mega');
module.exports = {
docName: 'emails',
read: {
options: [
'fields'
],
validation: {
options: {
fields: ['html', 'plaintext', 'subject']
}
},
data: [
'id'
],
permissions: true,
query(frame) {
return models.Email.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.models.email.emailNotFound')
});
}
return model;
});
}
},
retry: {
data: [
'id'
],
permissions: true,
query(frame) {
return models.Email.findOne(frame.data, frame.options)
.then(async (model) => {
if (!model) {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.models.email.emailNotFound')
});
}
if (model.get('status') !== 'failed') {
throw new common.errors.IncorrectUsageError({
message: common.i18n.t('errors.models.email.retryNotAllowed')
});
}
return await megaService.mega.retryFailedEmail(model);
});
}
}
};

View File

@ -107,6 +107,14 @@ module.exports = {
return shared.pipeline(require('./actions'), localUtils);
},
get email_preview() {
return shared.pipeline(require('./email-preview'), localUtils);
},
get emails() {
return shared.pipeline(require('./email'), localUtils);
},
get site() {
return shared.pipeline(require('./site'), localUtils);
},

View File

@ -1,7 +1,8 @@
const models = require('../../models');
const common = require('../../lib/common');
const urlUtils = require('../../lib/url-utils');
const allowedIncludes = ['tags', 'authors', 'authors.roles'];
const {mega} = require('../../services/mega');
const allowedIncludes = ['tags', 'authors', 'authors.roles', 'email'];
const unsafeAttrs = ['status', 'authors', 'visibility'];
module.exports = {
@ -84,7 +85,8 @@ module.exports = {
headers: {},
options: [
'include',
'source'
'source',
'send_email_when_published'
],
validation: {
options: {
@ -119,6 +121,7 @@ module.exports = {
'include',
'id',
'source',
'send_email_when_published',
// NOTE: only for internal context
'forUpdate',
'transacting'
@ -141,6 +144,27 @@ module.exports = {
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options)
.then(async (model) => {
if (!model.get('send_email_when_published')) {
return model;
}
const postPublished = model.wasChanged() && (model.get('status') === 'published') && (model.previous('status') !== 'published');
if (postPublished) {
let postEmail = model.relations.email;
if (!postEmail) {
const email = await mega.addEmail(model, frame.options);
model.set('email', email);
} else if (postEmail && postEmail.get('status') === 'failed') {
const email = await mega.retryFailedEmail(postEmail);
model.set('email', email);
}
}
return model;
})
.then((model) => {
if (
model.get('status') === 'published' && model.wasChanged() ||

View File

@ -37,7 +37,7 @@ function defaultRelations(frame) {
return false;
}
frame.options.withRelated = ['tags', 'authors', 'authors.roles'];
frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email'];
}
function setDefaultOrder(frame) {

View File

@ -49,6 +49,12 @@ module.exports = {
if (['cover_image', 'icon', 'logo'].includes(setting.key)) {
setting = url.forSetting(setting);
}
//CASE: Ensure we don't store calculated fields `isEnabled/Config` in bulk email settings
if (setting.key === 'bulk_email_settings') {
const {apiKey = '', domain = '', baseUrl = '', provider = 'mailgun'} = setting.value ? JSON.parse(setting.value) : {};
setting.value = JSON.stringify({apiKey, domain, baseUrl, provider});
}
});
// CASE: deprecated, won't accept

View File

@ -0,0 +1,7 @@
module.exports = {
read(emailPreview, apiConfig, frame) {
frame.response = {
email_previews: [emailPreview]
};
}
};

View File

@ -0,0 +1,11 @@
module.exports = {
read(email, apiConfig, frame) {
frame.response = {
emails: [email.toJSON(frame.options)]
};
},
get retry() {
return this.read;
}
};

View File

@ -101,5 +101,13 @@ module.exports = {
get site() {
return require('./site');
},
get email_preview() {
return require('./email-preview');
},
get emails() {
return require('./emails');
}
};

View File

@ -12,7 +12,7 @@ module.exports = {
if (models.meta) {
frame.response = {
pages: models.data.map(model => mapper.mapPost(model, frame)),
pages: models.data.map(model => mapper.mapPage(model, frame)),
meta: models.meta
};
@ -20,7 +20,7 @@ module.exports = {
}
frame.response = {
pages: [mapper.mapPost(models, frame)]
pages: [mapper.mapPage(models, frame)]
};
}
};

View File

@ -1,5 +1,6 @@
const _ = require('lodash');
const localUtils = require('../../../index');
const config = require('../../../../../../config');
const tag = (attrs, frame) => {
if (localUtils.isContentAPI(frame)) {
@ -138,7 +139,28 @@ const action = (attrs) => {
}
};
const settings = (attrs) => {
if (_.isArray(attrs)) {
attrs.forEach((attr) => {
if (attr.key === 'bulk_email_settings') {
const {provider, apiKey, domain, baseUrl} = attr.value ? JSON.parse(attr.value) : {};
const bulkEmailConfig = config.get('bulkEmail');
const hasMailgunConfig = !!(bulkEmailConfig && bulkEmailConfig.mailgun);
const hasMailgunSetting = !!(apiKey && baseUrl && domain);
attr.value = JSON.stringify({
provider, apiKey, domain, baseUrl,
isEnabled: (hasMailgunConfig || hasMailgunSetting),
isConfig: hasMailgunConfig
});
}
});
}
};
module.exports.post = post;
module.exports.tag = tag;
module.exports.author = author;
module.exports.action = action;
module.exports.settings = settings;

View File

@ -60,6 +60,10 @@ const mapPost = (model, frame) => {
if (relation === 'authors' && jsonModel.authors) {
jsonModel.authors = jsonModel.authors.map(author => mapUser(author, frame));
}
if (relation === 'email' && _.isEmpty(jsonModel.email)) {
jsonModel.email = null;
}
});
}
@ -75,9 +79,19 @@ const mapPost = (model, frame) => {
return jsonModel;
};
const mapPage = (model, frame) => {
const jsonModel = mapPost(model, frame);
delete jsonModel.email_subject;
delete jsonModel.send_email_when_published;
return jsonModel;
};
const mapSettings = (attrs, frame) => {
url.forSettings(attrs);
extraAttrs.forSettings(attrs, frame);
clean.settings(attrs, frame);
// NOTE: The cleanup of deprecated ghost_head/ghost_foot has to happen here
// because codeinjection_head/codeinjection_foot are assigned on a previous
@ -125,6 +139,7 @@ const mapMember = (model, frame) => {
};
module.exports.mapPost = mapPost;
module.exports.mapPage = mapPage;
module.exports.mapUser = mapUser;
module.exports.mapTag = mapTag;
module.exports.mapIntegration = mapIntegration;

View File

@ -1,37 +1,11 @@
const _ = require('lodash');
const membersService = require('../../../../../../services/members');
const labs = require('../../../../../../services/labs');
// Checks if request should hide members only content
function hideMembersOnlyContent({visibility}, frame) {
const PERMIT_CONTENT = false;
const BLOCK_CONTENT = true;
if (visibility === 'public') {
return PERMIT_CONTENT;
}
const requestFromMember = frame.original.context.member;
if (!requestFromMember) {
return BLOCK_CONTENT;
} else if (visibility === 'members') {
return PERMIT_CONTENT;
}
const memberHasPlan = !!(_.get(frame, 'original.context.member.stripe.subscriptions', [])).length;
if (visibility === 'paid' && memberHasPlan) {
return PERMIT_CONTENT;
}
return BLOCK_CONTENT;
}
const forPost = (attrs, frame) => {
if (labs.isSet('members')) {
const hideFormatsData = hideMembersOnlyContent(attrs, frame);
const memberHasAccess = membersService.contentGating.checkPostAccess(attrs, frame.original.context.member);
if (hideFormatsData) {
if (!memberHasAccess) {
['plaintext', 'html'].forEach((field) => {
attrs[field] = '';
});

View File

@ -25,6 +25,9 @@
"minLength": 0,
"maxLength": 2000
},
"subscribed": {
"type": "boolean"
},
"id": {
"strip": true
},

View File

@ -99,6 +99,10 @@
"type": ["string", "null"],
"maxLength": 500
},
"email_subject": {
"type": ["string", "null"],
"maxLength": 300
},
"custom_template": {
"type": ["string", "null"],
"maxLength": 100
@ -152,6 +156,12 @@
},
"plaintext": {
"strip": true
},
"email": {
"strip": true
},
"send_email_when_published": {
"strip": true
}
}
},

View File

@ -1,37 +1,11 @@
const _ = require('lodash');
const membersService = require('../../../../../../services/members');
const labs = require('../../../../../../services/labs');
// Checks if request should hide members only content
function hideMembersOnlyContent({visibility}, frame) {
const PERMIT_CONTENT = false;
const BLOCK_CONTENT = true;
if (visibility === 'public') {
return PERMIT_CONTENT;
}
const requestFromMember = frame.original.context.member;
if (!requestFromMember) {
return BLOCK_CONTENT;
} else if (visibility === 'members') {
return PERMIT_CONTENT;
}
const memberHasPlan = !!(_.get(frame, 'original.context.member.stripe.subscriptions', [])).length;
if (visibility === 'paid' && memberHasPlan) {
return PERMIT_CONTENT;
}
return BLOCK_CONTENT;
}
const forPost = (attrs, frame) => {
if (labs.isSet('members')) {
const hideFormatsData = hideMembersOnlyContent(attrs, frame);
const memberHasAccess = membersService.contentGating.checkPostAccess(attrs, frame.original.context.member);
if (hideFormatsData) {
if (!memberHasAccess) {
['plaintext', 'html'].forEach((field) => {
attrs[field] = '';
});

View File

@ -0,0 +1,25 @@
const commands = require('../../../schema').commands;
module.exports.up = commands.createColumnMigration({
table: 'posts',
column: 'send_email_when_published',
dbIsInCorrectState(columnExists) {
return columnExists === true;
},
operation: commands.addColumn,
operationVerb: 'Adding'
});
module.exports.down = commands.createColumnMigration({
table: 'posts',
column: 'send_email_when_published',
dbIsInCorrectState(columnExists) {
return columnExists === false;
},
operation: commands.dropColumn,
operationVerb: 'Removing'
});
module.exports.config = {
transaction: true
};

View File

@ -0,0 +1,25 @@
const commands = require('../../../schema').commands;
module.exports.up = commands.createColumnMigration({
table: 'posts_meta',
column: 'email_subject',
dbIsInCorrectState(columnExists) {
return columnExists === true;
},
operation: commands.addColumn,
operationVerb: 'Adding'
});
module.exports.down = commands.createColumnMigration({
table: 'posts_meta',
column: 'email_subject',
dbIsInCorrectState(columnExists) {
return columnExists === false;
},
operation: commands.dropColumn,
operationVerb: 'Removing'
});
module.exports.config = {
transaction: true
};

View File

@ -0,0 +1,51 @@
const _ = require('lodash');
const utils = require('../../../schema/fixtures/utils');
const permissions = require('../../../../services/permissions');
const logging = require('../../../../lib/common/logging');
const resources = ['email_preview'];
const _private = {};
_private.getPermissions = function getPermissions(resource) {
return utils.findModelFixtures('Permission', {object_type: resource});
};
_private.printResult = function printResult(result, message) {
if (result.done === result.expected) {
logging.info(message);
} else {
logging.warn(`(${result.done}/${result.expected}) ${message}`);
}
};
module.exports.config = {
transaction: true
};
module.exports.up = (options) => {
const localOptions = _.merge({
context: {internal: true}
}, options);
return Promise.map(resources, (resource) => {
const modelToAdd = _private.getPermissions(resource);
return utils.addFixturesForModel(modelToAdd, localOptions)
.then(result => _private.printResult(result, `Adding permissions fixtures for ${resource}s`))
.then(() => permissions.init(localOptions));
});
};
module.exports.down = (options) => {
const localOptions = _.merge({
context: {internal: true}
}, options);
return Promise.map(resources, (resource) => {
const modelToRemove = _private.getPermissions(resource);
// permission model automatically cleans up permissions_roles on .destroy()
return utils.removeFixturesForModel(modelToRemove, localOptions)
.then(result => _private.printResult(result, `Removing permissions fixtures for ${resource}s`));
});
};

View File

@ -0,0 +1,44 @@
const common = require('../../../../lib/common');
const commands = require('../../../schema').commands;
const createLog = type => msg => common.logging[type](msg);
function createColumnMigration({table, column, dbIsInCorrectState, operation, operationVerb}) {
return function columnMigrations({transacting}) {
return transacting.schema.hasColumn(table, column)
.then(dbIsInCorrectState)
.then((isInCorrectState) => {
const log = createLog(isInCorrectState ? 'warn' : 'info');
log(`${operationVerb} ${table}.${column}`);
if (!isInCorrectState) {
return operation(table, column, transacting);
}
});
};
}
module.exports.up = createColumnMigration({
table: 'members',
column: 'subscribed',
dbIsInCorrectState(columnExists) {
return columnExists === true;
},
operation: commands.addColumn,
operationVerb: 'Adding'
});
module.exports.down = createColumnMigration({
table: 'members',
column: 'subscribed',
dbIsInCorrectState(columnExists) {
return columnExists === false;
},
operation: commands.dropColumn,
operationVerb: 'Removing'
});
module.exports.config = {
transaction: true
};

View File

@ -0,0 +1,35 @@
const common = require('../../../../lib/common');
const commands = require('../../../schema').commands;
const table = 'emails';
const message1 = 'Adding table: ' + table;
const message2 = 'Dropping table: ' + table;
module.exports.up = (options) => {
const connection = options.connection;
return connection.schema.hasTable(table)
.then(function (exists) {
if (exists) {
common.logging.warn(message1);
return;
}
common.logging.info(message1);
return commands.createTable(table, connection);
});
};
module.exports.down = (options) => {
const connection = options.connection;
return connection.schema.hasTable(table)
.then(function (exists) {
if (!exists) {
common.logging.warn(message2);
return;
}
common.logging.info(message2);
return commands.deleteTable(table, connection);
});
};

View File

@ -0,0 +1,51 @@
const _ = require('lodash');
const utils = require('../../../schema/fixtures/utils');
const permissions = require('../../../../services/permissions');
const logging = require('../../../../lib/common/logging');
const resources = ['email'];
const _private = {};
_private.getPermissions = function getPermissions(resource) {
return utils.findModelFixtures('Permission', {object_type: resource});
};
_private.printResult = function printResult(result, message) {
if (result.done === result.expected) {
logging.info(message);
} else {
logging.warn(`(${result.done}/${result.expected}) ${message}`);
}
};
module.exports.config = {
transaction: true
};
module.exports.up = (options) => {
const localOptions = _.merge({
context: {internal: true}
}, options);
return Promise.map(resources, (resource) => {
const modelToAdd = _private.getPermissions(resource);
return utils.addFixturesForModel(modelToAdd, localOptions)
.then(result => _private.printResult(result, `Adding permissions fixtures for ${resource}`))
.then(() => permissions.init(localOptions));
});
};
module.exports.down = (options) => {
const localOptions = _.merge({
context: {internal: true}
}, options);
return Promise.map(resources, (resource) => {
const modelToRemove = _private.getPermissions(resource);
// permission model automatically cleans up permissions_roles on .destroy()
return utils.removeFixturesForModel(modelToRemove, localOptions)
.then(result => _private.printResult(result, `Removing permissions fixtures for ${resource}s`));
});
};

View File

@ -0,0 +1,44 @@
const common = require('../../../../lib/common');
const commands = require('../../../schema').commands;
const createLog = type => msg => common.logging[type](msg);
function createColumnMigration({table, column, dbIsInCorrectState, operation, operationVerb}) {
return function columnMigrations({transacting}) {
return transacting.schema.hasColumn(table, column)
.then(dbIsInCorrectState)
.then((isInCorrectState) => {
const log = createLog(isInCorrectState ? 'warn' : 'info');
log(`${operationVerb} ${table}.${column}`);
if (!isInCorrectState) {
return operation(table, column, transacting);
}
});
};
}
module.exports.up = createColumnMigration({
table: 'members',
column: 'uuid',
dbIsInCorrectState(columnExists) {
return columnExists === true;
},
operation: commands.addColumn,
operationVerb: 'Adding'
});
module.exports.down = createColumnMigration({
table: 'members',
column: 'uuid',
dbIsInCorrectState(columnExists) {
return columnExists === false;
},
operation: commands.dropColumn,
operationVerb: 'Removing'
});
module.exports.config = {
transaction: true
};

View File

@ -0,0 +1,22 @@
const common = require('../../../../lib/common');
const uuid = require('uuid');
module.exports = {
config: {
transaction: true
},
async up(options) {
const conn = options.connection || options.transacting;
const membersWithoutUUID = await conn.select('id').from('members').whereNull('uuid');
common.logging.info(`Adding uuid field value to ${membersWithoutUUID.length} members.`);
for (const member of membersWithoutUUID) {
await conn('members').update('uuid', uuid.v4()).where('id', member.id);
}
},
async down() {
// noop
}
};

View File

@ -0,0 +1,51 @@
const _ = require('lodash');
const utils = require('../../../schema/fixtures/utils');
const permissions = require('../../../../services/permissions');
const logging = require('../../../../lib/common/logging');
const resources = ['email'];
const _private = {};
_private.getPermissions = function getPermissions(resource) {
return utils.findModelFixtures('Permission', {object_type: resource});
};
_private.printResult = function printResult(result, message) {
if (result.done === result.expected) {
logging.info(message);
} else {
logging.warn(`(${result.done}/${result.expected}) ${message}`);
}
};
module.exports.config = {
transaction: true
};
module.exports.up = (options) => {
const localOptions = _.merge({
context: {internal: true}
}, options);
return Promise.map(resources, (resource) => {
const modelToAdd = _private.getPermissions(resource);
return utils.addFixturesForModel(modelToAdd, localOptions)
.then(result => _private.printResult(result, `Adding permissions fixtures for ${resource}`))
.then(() => permissions.init(localOptions));
});
};
module.exports.down = (options) => {
const localOptions = _.merge({
context: {internal: true}
}, options);
return Promise.map(resources, (resource) => {
const modelToRemove = _private.getPermissions(resource);
// permission model automatically cleans up permissions_roles on .destroy()
return utils.removeFixturesForModel(modelToRemove, localOptions)
.then(result => _private.printResult(result, `Removing permissions fixtures for ${resource}s`));
});
};

View File

@ -0,0 +1,25 @@
const commands = require('../../../schema').commands;
module.exports.up = commands.createColumnMigration({
table: 'emails',
column: 'error_data',
dbIsInCorrectState(columnExists) {
return columnExists === true;
},
operation: commands.addColumn,
operationVerb: 'Adding'
});
module.exports.down = commands.createColumnMigration({
table: 'emails',
column: 'error_data',
dbIsInCorrectState(columnExists) {
return columnExists === false;
},
operation: commands.dropColumn,
operationVerb: 'Removing'
});
module.exports.config = {
transaction: true
};

View File

@ -203,5 +203,10 @@
"members_subscription_settings": {
"defaultValue": "{\"isPaid\":false,\"fromAddress\":\"noreply\",\"allowSelfSignup\":true,\"paymentProcessors\":[{\"adapter\":\"stripe\",\"config\":{\"secret_token\":\"\",\"public_token\":\"\",\"product\":{\"name\":\"Ghost Subscription\"},\"plans\":[{\"name\":\"Monthly\",\"currency\":\"usd\",\"interval\":\"month\",\"amount\":\"\"},{\"name\":\"Yearly\",\"currency\":\"usd\",\"interval\":\"year\",\"amount\":\"\"}]}}]}"
}
},
"bulk_email": {
"bulk_email_settings": {
"defaultValue": "{\"provider\":\"mailgun\", \"apiKey\": \"\", \"domain\": \"\", \"baseUrl\": \"\"}"
}
}
}

View File

@ -362,6 +362,31 @@
"name": "Backup database",
"action_type": "backupContent",
"object_type": "db"
},
{
"name": "Email preview",
"action_type": "read",
"object_type": "email_preview"
},
{
"name": "Send test email",
"action_type": "sendTestEmail",
"object_type": "email_preview"
},
{
"name": "Browse emails",
"action_type": "browse",
"object_type": "email"
},
{
"name": "Read emails",
"action_type": "read",
"object_type": "email"
},
{
"name": "Retry emails",
"action_type": "retry",
"object_type": "email"
}
]
},
@ -556,7 +581,9 @@
"integration": "all",
"api_key": "all",
"action": "all",
"member": "all"
"member": "all",
"email_preview": "all",
"email": "all"
},
"DB Backup Integration": {
"db": "all"
@ -578,7 +605,9 @@
"redirect": "all",
"webhook": "all",
"action": "all",
"member": "all"
"member": "all",
"email_preview": "all",
"email": "all"
},
"Editor": {
"notification": "all",
@ -589,7 +618,9 @@
"user": "all",
"role": "all",
"invite": "all",
"theme": ["browse"]
"theme": ["browse"],
"email_preview": "all",
"email": "all"
},
"Author": {
"post": ["browse", "read", "add"],
@ -598,7 +629,9 @@
"tag": ["browse", "read", "add"],
"user": ["browse", "read"],
"role": ["browse"],
"theme": ["browse"]
"theme": ["browse"],
"email_preview": "read",
"email": "read"
},
"Contributor": {
"post": ["browse", "read", "add"],
@ -607,7 +640,9 @@
"tag": ["browse", "read"],
"user": ["browse", "read"],
"role": ["browse"],
"theme": ["browse"]
"theme": ["browse"],
"email_preview": "read",
"email": "read"
}
}
},

View File

@ -29,6 +29,7 @@ module.exports = {
defaultTo: 'public',
validations: {isIn: [['public', 'members', 'paid']]}
},
send_email_when_published: {type: 'bool', nullable: true, defaultTo: false},
/**
* @deprecated: `author_id`, might be removed in Ghost 3.0
* If we keep it, then only, because you can easier query post.author_id than posts_authors[*].sort_order.
@ -61,7 +62,8 @@ module.exports = {
twitter_title: {type: 'string', maxlength: 300, nullable: true},
twitter_description: {type: 'string', maxlength: 500, nullable: true},
meta_title: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}},
meta_description: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 500}}}
meta_description: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 500}}},
email_subject: {type: 'string', maxlength: 300, nullable: true}
},
users: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
@ -150,7 +152,7 @@ module.exports = {
maxlength: 50,
nullable: false,
defaultTo: 'core',
validations: {isIn: [['core', 'blog', 'theme', 'app', 'plugin', 'private', 'members']]}
validations: {isIn: [['core', 'blog', 'theme', 'app', 'plugin', 'private', 'members', 'bulk_email']]}
},
created_at: {type: 'dateTime', nullable: false},
created_by: {type: 'string', maxlength: 24, nullable: false},
@ -321,9 +323,11 @@ module.exports = {
},
members: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
uuid: {type: 'string', maxlength: 36, nullable: true, unique: true, validations: {isUUID: true}},
email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},
name: {type: 'string', maxlength: 191, nullable: true},
note: {type: 'string', maxlength: 2000, nullable: true},
subscribed: {type: 'bool', nullable: true, defaultTo: true},
created_at: {type: 'dateTime', nullable: false},
created_by: {type: 'string', maxlength: 24, nullable: false},
updated_at: {type: 'dateTime', nullable: true},
@ -372,5 +376,30 @@ module.exports = {
// @NOTE: The context object can be used to store information about an action e.g. diffs, meta
context: {type: 'text', maxlength: 1000000000, nullable: true},
created_at: {type: 'dateTime', nullable: false}
},
emails: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
post_id: {type: 'string', maxlength: 24, nullable: false, index: true, unique: true},
uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}},
status: {
type: 'string',
maxlength: 50,
nullable: false,
defaultTo: 'pending',
validations: {isIn: [['pending', 'submitting', 'submitted', 'failed']]}
},
error: {type: 'string', maxlength: 2000, nullable: true},
error_data: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
meta: {type: 'text', maxlength: 65535, nullable: true},
stats: {type: 'text', maxlength: 65535, nullable: true},
email_count: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0},
subject: {type: 'string', maxlength: 300, nullable: true},
html: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
plaintext: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
submitted_at: {type: 'dateTime', nullable: false},
created_at: {type: 'dateTime', nullable: false},
created_by: {type: 'string', maxlength: 24, nullable: false},
updated_at: {type: 'dateTime', nullable: true},
updated_by: {type: 'string', maxlength: 24, nullable: true}
}
};

View File

@ -33,6 +33,7 @@ function initialiseServices() {
apps = require('./services/apps'),
xmlrpc = require('./services/xmlrpc'),
slack = require('./services/slack'),
{mega} = require('./services/mega'),
webhooks = require('./services/webhooks'),
scheduling = require('./adapters/scheduling');
@ -43,6 +44,7 @@ function initialiseServices() {
permissions.init(),
xmlrpc.listen(),
slack.listen(),
mega.listen(),
webhooks.listen(),
apps.init(),
scheduling.init({
@ -50,12 +52,12 @@ function initialiseServices() {
active: config.get('scheduling').active,
// NOTE: When changing API version need to consider how to migrate custom scheduling adapters
// that rely on URL to lookup persisted scheduled records (jobs, etc.). Ref: https://github.com/TryGhost/Ghost/pull/10726#issuecomment-489557162
apiUrl: urlUtils.urlFor('api', {version: 'v2', versionType: 'admin'}, true),
apiUrl: urlUtils.urlFor('api', {version: 'v3', versionType: 'admin'}, true),
internalPath: config.get('paths').internalSchedulingPath,
contentPath: config.getContentPath('scheduling')
})
).then(function () {
debug('XMLRPC, Slack, Webhooks, Apps, Scheduling, Permissions done');
debug('XMLRPC, Slack, MEGA, Webhooks, Apps, Scheduling, Permissions done');
// Initialise analytics events
if (config.get('segment:key')) {

View File

@ -0,0 +1,57 @@
const uuid = require('uuid');
const ghostBookshelf = require('./base');
const Email = ghostBookshelf.Model.extend({
tableName: 'emails',
defaults: function defaults() {
return {
uuid: uuid.v4(),
status: 'pending',
stats: JSON.stringify({
delivered: 0,
failed: 0,
opened: 0,
clicked: 0,
unsubscribed: 0,
complaints: 0
})
};
},
emitChange: function emitChange(event, options) {
const eventToTrigger = 'email' + '.' + event;
ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
},
onCreated: function onCreated(model, attrs, options) {
ghostBookshelf.Model.prototype.onCreated.apply(this, arguments);
model.emitChange('added', options);
},
onUpdated: function onUpdated(model, attrs, options) {
ghostBookshelf.Model.prototype.onUpdated.apply(this, arguments);
model.emitChange('edited', options);
},
onDestroyed: function onDestroyed(model, options) {
ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments);
model.emitChange('deleted', options);
}
}, {
post() {
return this.belongsTo('Post');
}
});
const Emails = ghostBookshelf.Collection.extend({
model: Email
});
module.exports = {
Email: ghostBookshelf.model('Email', Email),
Emails: ghostBookshelf.collection('Emails', Emails)
};

View File

@ -36,7 +36,8 @@ models = [
'action',
'posts-meta',
'member-stripe-customer',
'stripe-customer-subscription'
'stripe-customer-subscription',
'email'
];
function init() {

View File

@ -1,8 +1,16 @@
const ghostBookshelf = require('./base');
const uuid = require('uuid');
const Member = ghostBookshelf.Model.extend({
tableName: 'members',
defaults() {
return {
subscribed: true,
uuid: uuid.v4()
};
},
emitChange: function emitChange(event, options) {
const eventToTrigger = 'member' + '.' + event;
ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);

View File

@ -144,11 +144,18 @@ pagination = function pagination(bookshelf) {
// Get the table name and idAttribute for this model
var tableName = _.result(this.constructor.prototype, 'tableName'),
idAttribute = _.result(this.constructor.prototype, 'idAttribute'),
self = this,
self = this;
let countPromise;
if (options.transacting) {
countPromise = this.query().clone().transacting(options.transacting).select(
bookshelf.knex.raw('count(distinct ' + tableName + '.' + idAttribute + ') as aggregate')
);
} else {
countPromise = this.query().clone().select(
bookshelf.knex.raw('count(distinct ' + tableName + '.' + idAttribute + ') as aggregate')
);
}
// #### Pre count clauses
// Add any where or join clauses which need to be included with the aggregate query

View File

@ -49,6 +49,7 @@ Post = ghostBookshelf.Model.extend({
}
return {
send_email_when_published: false,
uuid: uuid.v4(),
status: 'draft',
featured: false,
@ -456,6 +457,24 @@ Post = ghostBookshelf.Model.extend({
}
}
// send_email_when_published is read-only and should only be set using a query param when publishing/scheduling
if (options.send_email_when_published && this.hasChanged('status') && (newStatus === 'published' || newStatus === 'scheduled')) {
this.set('send_email_when_published', true);
}
// ensure draft posts have the send_email_when_published reset unless an email has already been sent
if (newStatus === 'draft' && this.hasChanged('status')) {
ops.push(function ensureSendEmailWhenPublishedIsUnchanged() {
return self.related('email').fetch({transacting: options.transacting}).then((email) => {
if (email) {
self.set('send_email_when_published', true);
} else {
self.set('send_email_when_published', false);
}
});
});
}
// If a title is set, not the same as the old title, a draft post, and has never been published
if (prevTitle !== undefined && newTitle !== prevTitle && newStatus === 'draft' && !publishedAt) {
ops.push(function updateSlug() {
@ -575,6 +594,10 @@ Post = ghostBookshelf.Model.extend({
return this.hasOne('PostsMeta', 'post_id');
},
email: function email() {
return this.hasOne('Email', 'post_id');
},
/**
* @NOTE:
* If you are requesting models with `columns`, you try to only receive some fields of the model/s.
@ -755,7 +778,7 @@ Post = ghostBookshelf.Model.extend({
findPage: ['status'],
findAll: ['columns', 'filter'],
destroy: ['destroyAll', 'destroyBy'],
edit: ['filter']
edit: ['filter', 'send_email_when_published']
};
// The post model additionally supports having a formats option

View File

@ -691,6 +691,33 @@ h2 {
.gh-flow-back:hover {
border: #dae1e3 1px solid;
}
.gh-flow-back-plain {
position: absolute;
top: 0;
left: 0;
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
margin: 0 0 0 3%;
padding: 2px 9px 2px 5px;
color: #7d878a;
font-weight: 300;
transition: all 0.3s ease;
text-decoration: none;
}
.gh-flow-back-plain svg {
margin-right: 4px;
height: 12px;
line-height: 14px;
}
.gh-flow-back-plain svg path {
stroke: #7d878a;
stroke-width: 1.2px;
}
.gh-flow-back-plain:hover {
color: #15212A;
}
.gh-flow-nav {
position: relative;
-ms-flex: 1;
@ -714,10 +741,21 @@ h2 {
padding-left: 35px;
}
.gh-flow-content-unsubscribe {
font-weight: 300;
}
@media (max-width: 500px) {
.gh-flow-head-unsubscribe {
padding-top: 2.8vh;
}
.gh-flow-content {
font-size: 4vw;
}
.gh-flow-content-unsubscribe {
font-size: 1.8rem;
line-height: 1.6em;
}
}
.gh-flow-content header {
margin: 0 auto;
@ -747,6 +785,9 @@ h2 {
line-height: 1.4em;
font-weight: 100;
}
.gh-flow-em {
font-weight: 500;
}
/* Sign in
/* ---------------------------------------------------------- */

View File

@ -0,0 +1,118 @@
const _ = require('lodash');
const common = require('../../lib/common');
const mailgunProvider = require('./mailgun');
const configService = require('../../config');
const settingsCache = require('../settings/cache');
/**
* An object representing batch request result
* @typedef { Object } BatchResultBase
* @property { string } data - data that is returned from Mailgun or one which Mailgun was called with
*/
class BatchResultBase {
}
class SuccessfulBatch extends BatchResultBase {
constructor(data) {
super();
this.data = data;
}
}
class FailedBatch extends BatchResultBase {
constructor(error, data) {
super();
// give a better error message for the invalid credentials state
if (error.message === 'Forbidden') {
error.message = 'Invalid Mailgun credentials';
}
this.error = error;
this.data = data;
}
}
/**
* An email address
* @typedef { string } EmailAddress
*/
/**
* An object representing an email to send
* @typedef { Object } Email
* @property { string } html - The html content of the email
* @property { string } subject - The subject of the email
*/
module.exports = {
SuccessfulBatch,
FailedBatch,
/**
* @param {Email} message - The message to send
* @param {[EmailAddress]} recipients - the recipients to send the email to
* @param {[object]} recipientData - list of data keyed by email to inject into the email
* @returns {Promise<Array<BatchResultBase>>} An array of promises representing the success of the batch email sending
*/
async send(message, recipients, recipientData = {}) {
let BATCH_SIZE = 1000;
const mailgunInstance = mailgunProvider.getInstance();
if (!mailgunInstance) {
return;
}
let fromAddress = message.from;
if (/@localhost$/.test(message.from) || /@ghost.local$/.test(message.from)) {
fromAddress = 'localhost@example.com';
common.logging.warn(`Rewriting bulk email from address ${message.from} to ${fromAddress}`);
BATCH_SIZE = 2;
}
const blogTitle = settingsCache.get('title');
fromAddress = blogTitle ? `${blogTitle}<${fromAddress}>` : fromAddress;
const chunkedRecipients = _.chunk(recipients, BATCH_SIZE);
return Promise.mapSeries(chunkedRecipients, (toAddresses) => {
const recipientVariables = {};
toAddresses.forEach((email) => {
recipientVariables[email] = recipientData[email];
});
const batchData = {
to: toAddresses,
from: fromAddress,
'recipient-variables': recipientVariables
};
const bulkEmailConfig = configService.get('bulkEmail');
if (bulkEmailConfig && bulkEmailConfig.mailgun && bulkEmailConfig.mailgun.tag) {
Object.assign(batchData, {
'o:tag': [bulkEmailConfig.mailgun.tag, 'bulk-email']
});
}
const messageData = Object.assign({}, message, batchData);
return new Promise((resolve) => {
mailgunInstance.messages().send(messageData, (error, body) => {
if (error) {
// NOTE: logging an error here only but actual handling should happen in more sophisticated batch retry handler
// REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#errors
common.logging.warn(new common.errors.GhostError({
err: error,
context: common.i18n.t('errors.services.mega.requestFailed.error')
}));
// NOTE: these are generated variables, so can be regenerated when retry is done
const data = _.omit(batchData, ['recipient-variables']);
resolve(new FailedBatch(error, data));
} else {
resolve(new SuccessfulBatch(body));
}
});
});
});
}
};

View File

@ -0,0 +1,41 @@
const {URL} = require('url');
const mailgun = require('mailgun-js');
const common = require('../../lib/common');
const configService = require('../../config');
const settingsCache = require('../settings/cache');
function createMailgun(config) {
const baseUrl = new URL(config.baseUrl);
return mailgun({
apiKey: config.apiKey,
domain: config.domain,
protocol: baseUrl.protocol,
host: baseUrl.host,
port: baseUrl.port,
endpoint: baseUrl.pathname,
retry: 5
});
}
function getInstance() {
const bulkEmailConfig = configService.get('bulkEmail');
const bulkEmailSetting = settingsCache.get('bulk_email_settings');
const hasMailgunConfig = !!(bulkEmailConfig && bulkEmailConfig.mailgun);
const hasMailgunSetting = !!(bulkEmailSetting && bulkEmailSetting.apiKey && bulkEmailSetting.baseUrl && bulkEmailSetting.domain);
if (!hasMailgunConfig && !hasMailgunSetting) {
common.logging.warn(`Bulk email service is not configured`);
} else {
try {
let mailgunConfig = hasMailgunConfig ? bulkEmailConfig.mailgun : bulkEmailSetting;
return createMailgun(mailgunConfig);
} catch (err) {
common.logging.warn(`Bulk email service is not configured`);
}
}
return null;
}
module.exports = {
getInstance: getInstance
};

View File

@ -0,0 +1,10 @@
module.exports = {
get mega() {
return require('./mega');
},
get postEmailSerializer() {
return require('./post-email-serializer');
}
};

View File

@ -0,0 +1,231 @@
const _ = require('lodash');
const url = require('url');
const moment = require('moment');
const common = require('../../lib/common');
const membersService = require('../members');
const bulkEmailService = require('../bulk-email');
const models = require('../../models');
const postEmailSerializer = require('./post-email-serializer');
const getEmailData = async (postModel, recipients = []) => {
const emailTmpl = await postEmailSerializer.serialize(postModel);
emailTmpl.from = membersService.config.getEmailFromAddress();
const emails = recipients.map(recipient => recipient.email);
const emailData = recipients.reduce((emailData, recipient) => {
return Object.assign({
[recipient.email]: {
unique_id: recipient.uuid,
unsubscribe_url: postEmailSerializer.createUnsubscribeUrl(recipient.uuid)
}
}, emailData);
}, {});
return {emailTmpl, emails, emailData};
};
const sendEmail = async (postModel, members) => {
const membersToSendTo = members.filter((member) => {
return membersService.contentGating.checkPostAccess(postModel.toJSON(), member);
});
const {emailTmpl, emails, emailData} = await getEmailData(postModel, membersToSendTo);
return bulkEmailService.send(emailTmpl, emails, emailData);
};
const sendTestEmail = async (postModel, toEmails) => {
const recipients = toEmails.map((email) => {
return {email};
});
const {emailTmpl, emails, emailData} = await getEmailData(postModel, recipients);
emailTmpl.subject = `${emailTmpl.subject} [Test]`;
return bulkEmailService.send(emailTmpl, emails, emailData);
};
/**
* addEmail
*
* Accepts a post model and creates an email record based on it. Only creates one
* record per post
*
* @param {object} postModel Post Model Object
*/
const addEmail = async (postModel, options) => {
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
const {members} = await membersService.api.members.list(Object.assign(knexOptions, {filter: 'subscribed:true'}, {limit: 'all'}));
const {emailTmpl, emails} = await getEmailData(postModel, members);
// NOTE: don't create email object when there's nobody to send the email to
if (!emails.length) {
return null;
}
const postId = postModel.get('id');
const existing = await models.Email.findOne({post_id: postId}, knexOptions);
if (!existing) {
return models.Email.add({
post_id: postId,
status: 'pending',
email_count: emails.length,
subject: emailTmpl.subject,
html: emailTmpl.html,
plaintext: emailTmpl.plaintext,
submitted_at: moment().toDate()
}, knexOptions);
} 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')
});
};
/**
* 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']});
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(postModel, 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
};

View File

@ -0,0 +1,80 @@
const juice = require('juice');
const template = require('./template');
const settingsCache = require('../../services/settings/cache');
const urlUtils = require('../../lib/url-utils');
const moment = require('moment');
const cheerio = require('cheerio');
const api = require('../../api');
const {URL} = require('url');
const getSite = () => {
return Object.assign({}, settingsCache.getPublic(), {
url: urlUtils.urlFor('home', true)
});
};
/**
* createUnsubscribeUrl
*
* Takes a member uuid and returns the url that should be used to unsubscribe
* In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
*
* @param {string} uuid
*/
const createUnsubscribeUrl = (uuid) => {
const siteUrl = urlUtils.getSiteUrl();
const unsubscribeUrl = new URL(siteUrl);
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
if (uuid) {
unsubscribeUrl.searchParams.set('uuid', uuid);
} else {
unsubscribeUrl.searchParams.set('preview', '1');
}
return unsubscribeUrl.href;
};
// 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 serializePostModel = async (model) => {
const frame = {options: {context: {user: true}, formats: 'html, plaintext'}};
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];
};
const serialize = async (postModel, options = {isBrowserPreview: false}) => {
const post = await serializePostModel(postModel);
post.published_at = post.published_at ? moment(post.published_at).format('DD MMM YYYY') : moment().format('DD MMM YYYY');
post.authors = post.authors && post.authors.map(author => author.name).join(',');
post.html = post.html || '';
if (post.posts_meta) {
post.email_subject = post.posts_meta.email_subject;
}
let htmlTemplate = template({post, site: getSite()});
if (options.isBrowserPreview) {
const previewUnsubscribeUrl = createUnsubscribeUrl();
htmlTemplate = htmlTemplate.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
}
let juicedHtml = juice(htmlTemplate);
// Force all links to open in new tab
let _cheerio = cheerio.load(juicedHtml);
_cheerio('a').attr('target','_blank');
juicedHtml = _cheerio.html();
return {
subject: post.email_subject || post.title,
html: juicedHtml,
plaintext: post.plaintext
};
};
module.exports = {
serialize,
createUnsubscribeUrl
};

View File

@ -0,0 +1,876 @@
/* eslint indent: warn, no-irregular-whitespace: warn */
module.exports = ({post, site}) => {
const date = new Date();
return `<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>${post.title}</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
/*All the styling goes here*/
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
background-color: #fff;
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: 18px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
color: #15212A;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
}
table td {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
font-size: 18px;
vertical-align: top;
color: #15212A;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background-color: #fff;
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 600px;
width: 600px;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 600px;
}
/* -------------------------------------
POST CONTENT
------------------------------------- */
hr {
position: relative;
display: block;
width: 100%;
margin: 3em 0;
padding: 0;
height: 1px;
border: 0;
border-top: 1px solid #e5eff5;
}
p,
ul,
ol,
dl,
blockquote {
margin: 0 0 1.5em 0;
line-height: 1.6em;
}
ol,
ul {
padding-left: 1.3em;
padding-right: 1.5em;
}
ol ol,
ul ul,
ul ol,
ol ul {
margin: 0.5em 0 1em;
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
ul,
ol {
max-width: 100%;
}
li {
margin: 0.5em 0;
padding-left: 0.3em;
line-height: 1.6em;
}
dt {
float: left;
margin: 0 20px 0 0;
width: 120px;
color: #15212A;
font-weight: 500;
text-align: right;
}
dd {
margin: 0 0 5px 0;
text-align: left;
}
blockquote {
margin: 2em 0;
padding: 0 25px 0 25px;
border-left: #15212A 2px solid;
font-style: italic;
font-size: 20px;
line-height: 1.75em;
letter-spacing: -0.2px;
}
blockquote p {
margin: 0.8em 0;
font-size: 1.2em;
font-weight: 300;
}
blockquote small {
display: inline-block;
margin: 0.8em 0 0.8em 1.5em;
font-size: 0.9em;
opacity: 0.8;
}
blockquote cite {
font-weight: bold;
}
blockquote cite a {
font-weight: normal;
}
a {
color: #15212A;
text-decoration: none;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
line-height: 1.15em;
font-weight: 600;
text-rendering: optimizeLegibility;
}
h1 {
margin: 1.5em 0 0.5em 0;
font-size: 42px;
font-weight: 600;
}
h2 {
margin: 1.5em 0 0.5em 0;
font-size: 32px;
line-height: 1.22em;
}
h3 {
margin: 1.5em 0 0.5em 0;
font-size: 26px;
line-height: 1.25em;
}
h4 {
margin: 1.8em 0 0.5em 0;
font-size: 21px;
line-height: 1.3em;
}
h5 {
margin: 2em 0 0.5em 0;
font-size: 19px;
line-height: 1.4em;
}
h6 {
margin: 2em 0 0.5em 0;
font-size: 19px;
line-height: 1.4em;
font-weight: 700;
}
strong {
font-weight: 700;
}
figure {
margin: 0 0 1.5em;
padding: 0;
}
figcaption {
text-align: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
font-size: 14px;
padding-top: 5px;
}
code {
font-size: 0.9em;
}
pre {
white-space: pre-wrap;
background: #15212A;
padding: 15px;
border-radius: 3px;
line-height: 1.2em;
color: #ffffff;
}
p code {
background: #F2F7FA;
word-break: break-all;
padding: 1px 7px;
border-radius: 3px;
}
figure blockquote p {
font-size: 1em;
}
.site-icon {
padding-bottom: 10px;
padding-top: 20px;
text-align: center;
border-radius: 3px;
}
.site-icon img {
width: 48px;
height: 48px;
border-radius: 3px;
}
.site-info {
padding-top: 50px;
border-bottom: 1px solid #e5eff5;
}
.site-url {
color: #15212A;
font-size: 16px;
letter-spacing: -0.1px;
font-weight: 700;
text-transform: uppercase;
text-align: center;
padding-bottom: 50px;
}
.post-title {
padding-bottom: 10px;
font-size: 42px;
line-height: 1.1em;
font-weight: 600;
text-align: center;
}
.post-title-link {
color: #15212A;
display: block;
text-align: center;
margin-top: 50px;
}
.post-meta,
.view-online {
padding-bottom: 50px;
white-space: nowrap;
color: #738a94;
font-size: 13px;
letter-spacing: 0.2px;
text-transform: uppercase;
text-align: center;
}
.view-online {
text-align: right;
}
.view-online-link {
word-wrap: none;
white-space: nowrap;
color: #15212A;
}
.feature-image {
padding-bottom: 30px;
width: 100%;
}
.post-content {
max-width: 600px !important;
font-family: Georgia, serif;
font-size: 18px;
line-height: 1.5em;
color: #23323D;
padding-bottom: 20px;
border-bottom: 1px solid #e5eff5;
}
.post-content a {
color: #08121A;
text-decoration: underline;
}
.kg-bookmark-card {
width: 100%;
background: #ffffff;
}
.kg-bookmark-card a {
text-decoration: none;
}
.kg-card + .kg-bookmark-card {
margin-top: 0;
}
.kg-bookmark-container {
display: flex;
min-height: 148px;
color: #15212A;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
text-decoration: none;
border-radius: 3px;
border: 1px solid #e5eff5;
}
.kg-bookmark-content {
flex-grow: 1;
padding: 20px;
}
.kg-bookmark-title {
color: #15212A;
font-size: 15px;
line-height: 1.5em;
font-weight: 600;
}
.kg-bookmark-description {
display: -webkit-box;
overflow-y: hidden;
margin-top: 12px;
max-height: 40px;
color: #738a94;
font-size: 13px;
line-height: 1.5em;
font-weight: 400;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.kg-bookmark-thumbnail {
position: relative;
min-width: 140px;
max-width: 180px;
max-height: 100%;
}
.kg-bookmark-thumbnail img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 0 3px 3px 0;
object-fit: cover;
}
.kg-bookmark-metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 14px;
color: #15212A;
font-size: 13px;
font-weight: 400;
}
.kg-bookmark-icon {
margin-right: 8px;
width: 22px;
height: 22px;
}
.kg-bookmark-author {
line-height: 1.5em;
}
.kg-bookmark-author:after {
content: "•";
margin: 0 6px;
}
.kg-bookmark-publisher {
overflow: hidden;
max-width: 240px;
line-height: 1.5em;
text-overflow: ellipsis;
white-space: nowrap;
}
.kg-gallery-container {
margin-top: -20px;
}
.kg-gallery-image img {
width: 100% !important;
height: auto !important;
padding-top: 20px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #ffffff;
border-radius: 3px;
width: 100%;
}
.wrapper {
box-sizing: border-box;
padding: 0 20px;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.footer {
color: #738a94;
margin-top: 20px;
text-align: center;
font-size: 13px;
padding-bottom: 40px;
padding-top: 50px;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%;
}
.btn>tbody>tr>td {
padding-bottom: 15px;
}
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #3498db;
border-radius: 5px;
box-sizing: border-box;
color: #3498db;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #3498db;
}
.btn-primary a {
background-color: #3498db;
border-color: #3498db;
color: #ffffff;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] {
width: 100%;
min-width: 100%;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span {
font-size: 16px !important;
}
table[class=body] pre {
white-space: pre-wrap !important;
word-break: break-word !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 0 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;
}
table[class=body] .site-icon img {
width: 40px !important;
height: 40px !important;
}
table[class=body] .site-url a {
font-size: 14px !important;
padding-bottom: 15px !important;
}
table[class=body] .post-meta {
white-space: normal !important;
font-size: 12px !important;
line-height: 1.5em;
}
table[class=body] .view-online-link,
table[class=body] .footer,
table[class=body] .footer a {
font-size: 12px !important;
}
table[class=body] .post-title a {
font-size: 32px !important;
line-height: 1.15em !important;
}
table[class=body] .kg-bookmark-card {
width: 90vw !important;
}
table[class=body] .kg-bookmark-thumbnail {
display: none !important;
}
table[class=body] .kg-bookmark-metadata span {
font-size: 13px !important;
}
table[class=body] .kg-embed-card {
max-width: 90vw !important;
}
table[class=body] h1 {
font-size: 32px !important;
line-height: 1.3em !important;
}
table[class=body] h2 {
font-size: 26px !important;
line-height: 1.22em !important;
}
table[class=body] h3 {
font-size: 21px !important;
line-height: 1.25em !important;
}
table[class=body] h4 {
font-size: 19px !important;
line-height: 1.3em !important;
}
table[class=body] h5 {
font-size: 16px !important;
line-height: 1.4em !important;
}
table[class=body] h6 {
font-size: 16px !important;
line-height: 1.4em !important;
}
table[class=body] blockquote {
font-size: 17px !important;
line-height: 1.6em !important;
margin-bottom: 0 !important;
padding-left: 15px !important;
}
table[class=body] blockquote + * {
margin-top: 1.5em !important;
}
table[class=body] hr {
margin: 2em 0 !important;
}
table[class=body] figcaption,
table[class=body] figcaption a {
font-size: 13px !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%;
}
.apple-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;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body class="">
<span class="preheader">${ post.excerpt ? post.excerpt : `${post.title} ` }</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" width="100%">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="site-info" width="100%" align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
${ site.icon ? `
<tr>
<td class="site-icon"><a href="${site.url}"><img src="${site.url}${site.icon}" border="0"></a></td>
</tr>
` : ``}
<tr>
<td class="site-url"><div style="width: 100% !important;"><a href="${site.url}">${site.title}</a></div></td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="post-title"><a href="${post.url}" class="post-title-link">${post.title}</a></td>
</tr>
<tr>
<td align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="post-meta">
By ${post.authors}
${post.published_at}
<a href="${post.url}" class="view-online-link">View online </a>
</td>
</tr>
</table>
</td>
</tr>
${
post.feature_image ? `
<tr>
<td class="feature-image"><img src="${post.feature_image}"></td>
</tr>
` : ``
}
<tr>
<td class="post-content">
<!-- POST CONTENT START -->
${post.html}
<!-- POST CONTENT END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
<tr>
<td class="wrapper" align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="footer">${site.title} &copy; ${date.getFullYear()}  <a href="%recipient.unsubscribe_url%">Unsubscribe</a></td>
</tr>
</table>
</td>
</tr>
</table>
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>`;
};

View File

@ -1,15 +1,12 @@
const crypto = require('crypto');
const {URL} = require('url');
const settingsCache = require('../settings/cache');
const urlUtils = require('../../lib/url-utils');
const MembersApi = require('@tryghost/members-api');
const common = require('../../lib/common');
const ghostVersion = require('../../lib/ghost-version');
const mail = require('../mail');
const models = require('../../models');
const signinEmail = require('./emails/signin');
const signupEmail = require('./emails/signup');
const subscribeEmail = require('./emails/subscribe');
const config = require('./config');
async function createMember({email, name, note}, options = {}) {
const model = await models.Member.add({
@ -22,7 +19,7 @@ async function createMember({email, name, note}, options = {}) {
}
async function getMember(data, options = {}) {
if (!data.email && !data.id) {
if (!data.email && !data.id && !data.uuid) {
return Promise.resolve(null);
}
const model = await models.Member.findOne(data, options);
@ -75,11 +72,17 @@ async function getMetadata(module, member) {
};
}
async function updateMember({name, note}, options = {}) {
const model = await models.Member.edit({
async function updateMember({name, note, subscribed}, options = {}) {
const attrs = {
name: name || null,
note: note || null
}, options);
};
if (subscribed !== undefined) {
attrs.subscribed = subscribed;
}
const model = await models.Member.edit(attrs, options);
const member = model.toJSON(options);
return member;
@ -105,102 +108,17 @@ function listMembers(options) {
});
}
const getApiUrl = ({version, type}) => {
const {href} = new URL(
urlUtils.getApiPath({version, type}),
urlUtils.urlFor('admin', true)
);
return href;
};
const siteUrl = urlUtils.getSiteUrl();
const membersApiUrl = getApiUrl({version: 'v3', type: 'members'});
const ghostMailer = new mail.GhostMailer();
function getStripePaymentConfig() {
const subscriptionSettings = settingsCache.get('members_subscription_settings');
const stripePaymentProcessor = subscriptionSettings.paymentProcessors.find(
paymentProcessor => paymentProcessor.adapter === 'stripe'
);
if (!stripePaymentProcessor || !stripePaymentProcessor.config) {
return null;
}
if (!stripePaymentProcessor.config.public_token || !stripePaymentProcessor.config.secret_token) {
return null;
}
const webhookHandlerUrl = new URL('/members/webhooks/stripe', siteUrl);
const checkoutSuccessUrl = new URL(siteUrl);
checkoutSuccessUrl.searchParams.set('stripe', 'success');
const checkoutCancelUrl = new URL(siteUrl);
checkoutCancelUrl.searchParams.set('stripe', 'cancel');
return {
publicKey: stripePaymentProcessor.config.public_token,
secretKey: stripePaymentProcessor.config.secret_token,
checkoutSuccessUrl: checkoutSuccessUrl.href,
checkoutCancelUrl: checkoutCancelUrl.href,
webhookHandlerUrl: webhookHandlerUrl.href,
product: stripePaymentProcessor.config.product,
plans: stripePaymentProcessor.config.plans,
appInfo: {
name: 'Ghost',
partner_id: 'pp_partner_DKmRVtTs4j9pwZ',
version: ghostVersion.original,
url: 'https://ghost.org/'
}
};
}
function getAuthSecret() {
const hexSecret = settingsCache.get('members_email_auth_secret');
if (!hexSecret) {
common.logging.warn('Could not find members_email_auth_secret, using dynamically generated secret');
return crypto.randomBytes(64);
}
const secret = Buffer.from(hexSecret, 'hex');
if (secret.length < 64) {
common.logging.warn('members_email_auth_secret not large enough (64 bytes), using dynamically generated secret');
return crypto.randomBytes(64);
}
return secret;
}
function getAllowSelfSignup() {
const subscriptionSettings = settingsCache.get('members_subscription_settings');
return subscriptionSettings.allowSelfSignup;
}
// NOTE: the function is an exact duplicate of one in GhostMailer should be extracted
// into a common lib once it needs to be reused anywhere else again
function getDomain() {
const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
return domain && domain[1];
}
module.exports = createApiInstance;
function createApiInstance() {
const membersApiInstance = MembersApi({
tokenConfig: {
issuer: membersApiUrl,
publicKey: settingsCache.get('members_public_key'),
privateKey: settingsCache.get('members_private_key')
},
tokenConfig: config.getTokenConfig(),
auth: {
getSigninURL(token, type) {
const signinURL = new URL(siteUrl);
signinURL.searchParams.set('token', token);
signinURL.searchParams.set('action', type);
return signinURL.href;
},
allowSelfSignup: getAllowSelfSignup(),
secret: getAuthSecret()
getSigninURL: config.getSigninURL,
allowSelfSignup: config.getAllowSelfSignup(),
secret: config.getAuthSecret()
},
mail: {
transporter: {
@ -209,15 +127,10 @@ function createApiInstance() {
common.logging.warn(message.text);
}
let msg = Object.assign({
from: config.getEmailFromAddress(),
subject: 'Signin',
forceTextContent: true
}, message);
const subscriptionSettings = settingsCache.get('members_subscription_settings');
if (subscriptionSettings && subscriptionSettings.fromAddress) {
let from = `${subscriptionSettings.fromAddress}@${getDomain()}`;
msg = Object.assign({from: from}, msg);
}
return ghostMailer.send(msg);
}
@ -308,7 +221,7 @@ function createApiInstance() {
}
},
paymentConfig: {
stripe: getStripePaymentConfig()
stripe: config.getStripePaymentConfig()
},
setMetadata,
getMetadata,

View File

@ -0,0 +1,112 @@
const {URL} = require('url');
const settingsCache = require('../settings/cache');
const ghostVersion = require('../../lib/ghost-version');
const crypto = require('crypto');
const common = require('../../lib/common');
const urlUtils = require('../../lib/url-utils');
// NOTE: the function is an exact duplicate of one in GhostMailer should be extracted
// into a common lib once it needs to be reused anywhere else again
function getDomain() {
const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
return domain && domain[1];
}
function getEmailFromAddress() {
const subscriptionSettings = settingsCache.get('members_subscription_settings') || {};
return `${subscriptionSettings.fromAddress || 'noreply'}@${getDomain()}`;
}
const getApiUrl = ({version, type}) => {
const {href} = new URL(
urlUtils.getApiPath({version, type}),
urlUtils.urlFor('admin', true)
);
return href;
};
const siteUrl = urlUtils.getSiteUrl();
const membersApiUrl = getApiUrl({version: 'v3', type: 'members'});
function getStripePaymentConfig() {
const subscriptionSettings = settingsCache.get('members_subscription_settings');
const stripePaymentProcessor = subscriptionSettings.paymentProcessors.find(
paymentProcessor => paymentProcessor.adapter === 'stripe'
);
if (!stripePaymentProcessor || !stripePaymentProcessor.config) {
return null;
}
if (!stripePaymentProcessor.config.public_token || !stripePaymentProcessor.config.secret_token) {
return null;
}
const webhookHandlerUrl = new URL('/members/webhooks/stripe', siteUrl);
const checkoutSuccessUrl = new URL(siteUrl);
checkoutSuccessUrl.searchParams.set('stripe', 'success');
const checkoutCancelUrl = new URL(siteUrl);
checkoutCancelUrl.searchParams.set('stripe', 'cancel');
return {
publicKey: stripePaymentProcessor.config.public_token,
secretKey: stripePaymentProcessor.config.secret_token,
checkoutSuccessUrl: checkoutSuccessUrl.href,
checkoutCancelUrl: checkoutCancelUrl.href,
webhookHandlerUrl: webhookHandlerUrl.href,
product: stripePaymentProcessor.config.product,
plans: stripePaymentProcessor.config.plans,
appInfo: {
name: 'Ghost',
partner_id: 'pp_partner_DKmRVtTs4j9pwZ',
version: ghostVersion.original,
url: 'https://ghost.org/'
}
};
}
function getAuthSecret() {
const hexSecret = settingsCache.get('members_email_auth_secret');
if (!hexSecret) {
common.logging.warn('Could not find members_email_auth_secret, using dynamically generated secret');
return crypto.randomBytes(64);
}
const secret = Buffer.from(hexSecret, 'hex');
if (secret.length < 64) {
common.logging.warn('members_email_auth_secret not large enough (64 bytes), using dynamically generated secret');
return crypto.randomBytes(64);
}
return secret;
}
function getAllowSelfSignup() {
const subscriptionSettings = settingsCache.get('members_subscription_settings');
return subscriptionSettings.allowSelfSignup;
}
function getTokenConfig() {
return {
issuer: membersApiUrl,
publicKey: settingsCache.get('members_public_key'),
privateKey: settingsCache.get('members_private_key')
};
}
function getSigninURL(token, type) {
const signinURL = new URL(siteUrl);
signinURL.searchParams.set('token', token);
signinURL.searchParams.set('action', type);
return signinURL.href;
}
module.exports = {
getEmailFromAddress,
getStripePaymentConfig,
getAllowSelfSignup,
getAuthSecret,
getTokenConfig,
getSigninURL
};

View File

@ -0,0 +1,39 @@
// @ts-check
/** @typedef { boolean } AccessFlag */
const PERMIT_ACCESS = true;
const BLOCK_ACCESS = false;
/**
* @param {object} post - A post object to check access to
* @param {object} member - The member whos access should be checked
*
* @returns {AccessFlag}
*/
function checkPostAccess(post, member) {
if (post.visibility === 'public') {
return PERMIT_ACCESS;
}
if (!member) {
return BLOCK_ACCESS;
}
if (post.visibility === 'members') {
return PERMIT_ACCESS;
}
const memberHasPlan = member.stripe && member.stripe.subscriptions && member.stripe.subscriptions.length;
if (post.visibility === 'paid' && memberHasPlan) {
return PERMIT_ACCESS;
}
return BLOCK_ACCESS;
}
module.exports = {
checkPostAccess,
PERMIT_ACCESS,
BLOCK_ACCESS
};

View File

@ -28,6 +28,10 @@ const membersService = {
return !!settings && settings.isPaid && settings.paymentProcessors.length !== 0;
},
contentGating: require('./content-gating'),
config: require('./config'),
get api() {
if (!membersApi) {
membersApi = createMembersApiInstance();

View File

@ -255,6 +255,10 @@
"api_key": {
"apiKeyNotFound": "API Key not found"
},
"email": {
"emailNotFound": "Email not found.",
"retryNotAllowed": "Only failed emails can be retried"
},
"base": {
"index": {
"missingContext": "missing context"
@ -516,6 +520,11 @@
},
"loader": "Error trying to load YAML setting for {setting} from '{path}'.",
"ensureSettings": "Error trying to access settings files in {path}."
},
"mega": {
"requestFailed": {
"error" : "The email service was unable to send an email batch."
}
}
},
"errors": {

View File

@ -215,5 +215,13 @@ module.exports = function apiRoutes() {
// ## Actions
router.get('/actions', mw.authAdminApi, http(apiCanary.actions.browse));
// ## Email Preview
router.get('/email_preview/posts/:id', mw.authAdminApi, http(apiCanary.email_preview.read));
router.post('/email_preview/posts/:id', mw.authAdminApi, http(apiCanary.email_preview.sendTestEmail));
// ## Emails
router.get('/emails/:id', mw.authAdminApi, http(apiCanary.emails.read));
router.put('/emails/:id/retry', mw.authAdminApi, http(apiCanary.emails.retry));
return router;
};

View File

@ -55,7 +55,7 @@ describe('DB API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.db);
jsonResponse.db.should.have.length(1);
Object.keys(jsonResponse.db[0].data).length.should.eql(27);
Object.keys(jsonResponse.db[0].data).length.should.eql(28);
});
});

View File

@ -0,0 +1,72 @@
const should = require('should');
const supertest = require('supertest');
const ObjectId = require('bson-objectid');
const testUtils = require('../../utils');
const localUtils = require('./utils');
const config = require('../../../server/config');
const ghost = testUtils.startGhost;
describe('Email Preview API', function () {
let request;
before(function () {
return ghost()
.then(function (_ghostServer) {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'users:extra', 'posts');
});
});
describe('Read', function () {
it('can\'t retrieve for non existent post', function (done) {
request.get(localUtils.API.getApiQuery(`posts/${ObjectId.generate()}/`))
.set('Origin', config.get('url'))
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.errors);
testUtils.API.checkResponseValue(jsonResponse.errors[0], [
'message',
'context',
'type',
'details',
'property',
'help',
'code',
'id'
]);
done();
});
});
it('can read post email preview with fields', function () {
return request
.get(localUtils.API.getApiQuery(`email_preview/posts/${testUtils.DataGenerator.Content.posts[0].id}/`))
.set('Origin', config.get('url'))
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.email_previews);
localUtils.API.checkResponse(jsonResponse.email_previews[0], 'email_preview', null, null);
});
});
});
});

View File

@ -0,0 +1,47 @@
const should = require('should');
const supertest = require('supertest');
const testUtils = require('../../utils');
const config = require('../../../server/config');
const localUtils = require('./utils');
const ghost = testUtils.startGhost;
describe('Email API', function () {
let request;
before(function () {
return ghost()
.then(function () {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'posts', 'emails');
});
});
it('Can read an email', function () {
return request
.get(localUtils.API.getApiQuery(`emails/${testUtils.DataGenerator.Content.emails[0].id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.emails);
jsonResponse.emails.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.emails[0], 'email');
const stats = JSON.parse(jsonResponse.emails[0].stats);
should.exist(stats.delivered);
should.exist(stats.failed);
should.exist(stats.opened);
should.exist(stats.clicked);
should.exist(stats.unsubscribed);
should.exist(stats.complaints);
});
});
});

View File

@ -22,7 +22,7 @@ describe('Posts API', function () {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'users:extra', 'posts');
return localUtils.doAuth(request, 'users:extra', 'posts', 'emails');
})
.then(function (cookie) {
ownerCookie = cookie;
@ -95,7 +95,7 @@ describe('Posts API', function () {
});
});
it('Can includes single relation', function (done) {
it('Can include single relation', function (done) {
request.get(localUtils.API.getApiQuery('posts/?include=tags'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
@ -115,7 +115,7 @@ describe('Posts API', function () {
jsonResponse.posts[0],
'post',
null,
['authors', 'primary_author']
['authors', 'primary_author', 'email']
);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
@ -232,7 +232,7 @@ describe('Posts API', function () {
it('Can include relations for a single post', function (done) {
request
.get(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/?include=authors,tags'))
.get(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/?include=authors,tags,email'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
@ -254,6 +254,9 @@ describe('Posts API', function () {
jsonResponse.posts[0].tags[0].should.be.an.Object();
localUtils.API.checkResponse(jsonResponse.posts[0].tags[0], 'tag', ['url']);
jsonResponse.posts[0].email.should.be.an.Object();
localUtils.API.checkResponse(jsonResponse.posts[0].email, 'email');
done();
});
});

View File

@ -37,7 +37,7 @@ const expectedProperties = {
// always returns computed properties
.concat('url', 'primary_tag', 'primary_author', 'excerpt')
// returned by default
.concat('tags', 'authors')
.concat('tags', 'authors', 'email')
// returns meta fields from `posts_meta` schema
.concat(
..._(schema.posts_meta).keys().without('post_id', 'id')
@ -54,13 +54,18 @@ const expectedProperties = {
.without('type')
// deprecated
.without('author_id', 'author')
// pages are not sent as emails
.without('send_email_when_published')
// always returns computed properties
.concat('url', 'primary_tag', 'primary_author', 'excerpt')
// returned by default
.concat('tags', 'authors')
// returns meta fields from `posts_meta` schema
.concat(
..._(schema.posts_meta).keys().without('post_id', 'id')
..._(schema.posts_meta).keys()
.without('post_id', 'id')
// pages are not sent as emails
.without('email_subject')
)
,
@ -96,6 +101,10 @@ const expectedProperties = {
,
webhook: _(schema.webhooks)
.keys()
,
email: _(schema.emails)
.keys(),
email_preview: ['html', 'subject', 'plaintext']
};
_.each(expectedProperties, (value, key) => {

View File

@ -20,7 +20,7 @@ describe('Posts API', function () {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'users:extra', 'posts');
return localUtils.doAuth(request, 'users:extra', 'posts', 'emails');
})
.then(function (cookie) {
ownerCookie = cookie;
@ -60,7 +60,7 @@ describe('Posts API', function () {
});
it('combined fields, formats, include and non existing', function (done) {
request.get(localUtils.API.getApiQuery('posts/?formats=mobiledoc,html,plaintext&fields=id,title,primary_tag,doesnotexist&include=authors,tags'))
request.get(localUtils.API.getApiQuery('posts/?formats=mobiledoc,html,plaintext&fields=id,title,primary_tag,doesnotexist&include=authors,tags,email'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
@ -81,7 +81,7 @@ describe('Posts API', function () {
'post',
null,
null,
['mobiledoc', 'plaintext', 'id', 'title', 'html', 'authors', 'tags', 'primary_tag']
['mobiledoc', 'plaintext', 'id', 'title', 'html', 'authors', 'tags', 'primary_tag', 'email']
);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');

View File

@ -70,6 +70,8 @@ const expectedProperties = {
,
webhook: _(schema.webhooks)
.keys()
,
email_preview: ['html', 'subject', 'plaintext']
};
_.each(expectedProperties, (value, key) => {

View File

@ -246,7 +246,7 @@ describe('Database Migration (special functions)', function () {
result.roles.at(7).get('name').should.eql('Scheduler Integration');
// Permissions
result.permissions.length.should.eql(62);
result.permissions.length.should.eql(65);
result.permissions.toJSON().should.be.CompletePermissions();
});
});

View File

@ -12,7 +12,7 @@ describe('Unit: canary/utils/serializers/output/pages', function () {
return Object.assign(data, {toJSON: sinon.stub().returns(data)});
};
sinon.stub(mapper, 'mapPost').returns({});
sinon.stub(mapper, 'mapPage').returns({});
});
afterEach(function () {
@ -46,7 +46,7 @@ describe('Unit: canary/utils/serializers/output/pages', function () {
serializers.output.pages.all(ctrlResponse, apiConfig, frame);
mapper.mapPost.callCount.should.equal(2);
mapper.mapPost.getCall(0).args.should.eql([ctrlResponse.data[0], frame]);
mapper.mapPage.callCount.should.equal(2);
mapper.mapPage.getCall(0).args.should.eql([ctrlResponse.data[0], frame]);
});
});

View File

@ -38,7 +38,7 @@ describe('Unit: canary/utils/serializers/output/posts', function () {
meta: {}
};
serializers.output.pages.all(ctrlResponse, apiConfig, frame);
serializers.output.posts.all(ctrlResponse, apiConfig, frame);
mapper.mapPost.callCount.should.equal(2);
mapper.mapPost.getCall(0).args.should.eql([ctrlResponse.data[0], frame]);

View File

@ -12,7 +12,7 @@ describe('Unit: v3/utils/serializers/output/pages', function () {
return Object.assign(data, {toJSON: sinon.stub().returns(data)});
};
sinon.stub(mapper, 'mapPost').returns({});
sinon.stub(mapper, 'mapPage').returns({});
});
afterEach(function () {
@ -34,11 +34,11 @@ describe('Unit: v3/utils/serializers/output/pages', function () {
data: [
pageModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id1',
page: true
type: 'page'
})),
pageModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id2',
page: true
type: 'page'
}))
],
meta: {}
@ -46,7 +46,7 @@ describe('Unit: v3/utils/serializers/output/pages', function () {
serializers.output.pages.all(ctrlResponse, apiConfig, frame);
mapper.mapPost.callCount.should.equal(2);
mapper.mapPost.getCall(0).args.should.eql([ctrlResponse.data[0], frame]);
mapper.mapPage.callCount.should.equal(2);
mapper.mapPage.getCall(0).args.should.eql([ctrlResponse.data[0], frame]);
});
});

View File

@ -38,7 +38,7 @@ describe('Unit: v3/utils/serializers/output/posts', function () {
meta: {}
};
serializers.output.pages.all(ctrlResponse, apiConfig, frame);
serializers.output.posts.all(ctrlResponse, apiConfig, frame);
mapper.mapPost.callCount.should.equal(2);
mapper.mapPost.getCall(0).args.should.eql([ctrlResponse.data[0], frame]);

View File

@ -150,19 +150,19 @@ describe('Migration Fixture Utils', function () {
fixtureUtils.addFixturesForRelation(fixtures.relations[0]).then(function (result) {
should.exist(result);
result.should.be.an.Object();
result.should.have.property('expected', 56);
result.should.have.property('done', 56);
result.should.have.property('expected', 66);
result.should.have.property('done', 66);
// Permissions & Roles
permsAllStub.calledOnce.should.be.true();
rolesAllStub.calledOnce.should.be.true();
dataMethodStub.filter.callCount.should.eql(56);
dataMethodStub.filter.callCount.should.eql(66);
dataMethodStub.find.callCount.should.eql(7);
baseUtilAttachStub.callCount.should.eql(56);
baseUtilAttachStub.callCount.should.eql(66);
fromItem.related.callCount.should.eql(56);
fromItem.findWhere.callCount.should.eql(56);
toItem[0].get.callCount.should.eql(112);
fromItem.related.callCount.should.eql(66);
fromItem.findWhere.callCount.should.eql(66);
toItem[0].get.callCount.should.eql(132);
done();
}).catch(done);

View File

@ -19,8 +19,8 @@ var should = require('should'),
*/
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '09ab28b8a18dc7f7f4fb5dbd5ccc10ac';
const currentFixturesHash = '59714063fa30b2f3fbfc929551b3c1e5';
const currentSchemaHash = '773f8f6cd4267f50aec6af8c8b1edbd2';
const currentFixturesHash = '1a0f96fa1d8b976d663eb06719be031c';
// If this test is failing, then it is likely a change has been made that requires a DB version bump,
// and the values above will need updating as confirmation

View File

@ -113,7 +113,7 @@ describe('Unit: models/settings', function () {
return models.Settings.populateDefaults()
.then(() => {
eventSpy.callCount.should.equal(82);
eventSpy.callCount.should.equal(84);
const eventsEmitted = eventSpy.args.map(args => args[0]);
const checkEventEmitted = event => should.ok(eventsEmitted.includes(event), `${event} event should be emitted`);
@ -135,7 +135,7 @@ describe('Unit: models/settings', function () {
return models.Settings.populateDefaults()
.then(() => {
eventSpy.callCount.should.equal(80);
eventSpy.callCount.should.equal(82);
eventSpy.args[13][0].should.equal('settings.logo.added');
});

View File

@ -418,6 +418,31 @@ DataGenerator.Content = {
type: 'admin',
integration_id: undefined // "internal"
}
],
emails: [
{
id: ObjectId.generate(),
uuid: '6b6afda6-4b5e-4893-bff6-f16859e8349a',
status: 'submitted',
email_count: 2,
subject: 'You got mailed!',
html: '<p>Look! I\'m an email</p>',
plaintext: 'Waba-daba-dab-da',
submitted_at: moment().toDate()
},
{
id: ObjectId.generate(),
uuid: '365daa11-4bf0-4614-ad43-6346387ffa00',
status: 'failed',
error: 'Everything went south',
stats: '',
email_count: 3,
subject: 'You got mailed! Again!',
html: '<p>What\'s that? Another email!</p>',
plaintext: 'yes this is an email',
submitted_at: moment().toDate()
}
]
};
@ -425,6 +450,8 @@ DataGenerator.Content = {
DataGenerator.Content.subscribers[0].post_id = DataGenerator.Content.posts[0].id;
DataGenerator.Content.api_keys[0].integration_id = DataGenerator.Content.integrations[0].id;
DataGenerator.Content.api_keys[1].integration_id = DataGenerator.Content.integrations[0].id;
DataGenerator.Content.emails[0].post_id = DataGenerator.Content.posts[0].id;
DataGenerator.Content.emails[1].post_id = DataGenerator.Content.posts[1].id;
DataGenerator.forKnex = (function () {
function createBasic(overrides) {
@ -904,6 +931,11 @@ DataGenerator.forKnex = (function () {
createBasic(DataGenerator.Content.api_keys[2])
];
const emails = [
createBasic(DataGenerator.Content.emails[0]),
createBasic(DataGenerator.Content.emails[1])
];
return {
createPost: createPost,
createGenericPost: createGenericPost,
@ -940,7 +972,8 @@ DataGenerator.forKnex = (function () {
roles_users: roles_users,
webhooks: webhooks,
integrations: integrations,
api_keys: api_keys
api_keys: api_keys,
emails: emails
};
}());

View File

@ -464,6 +464,12 @@ fixtures = {
return Promise.map(DataGenerator.forKnex.api_keys, function (api_key) {
return models.ApiKey.add(api_key, module.exports.context.internal);
});
},
insertEmails: function insertEmails() {
return Promise.map(DataGenerator.forKnex.emails, function (email) {
return models.Email.add(email, module.exports.context.internal);
});
}
};
@ -613,6 +619,9 @@ toDoList = {
},
api_keys: function insertApiKeys() {
return fixtures.insertApiKeys();
},
emails: function insertEmails() {
return fixtures.insertEmails();
}
};

View File

@ -41,7 +41,7 @@
"dependencies": {
"@nexes/nql": "0.3.0",
"@tryghost/helpers": "1.1.19",
"@tryghost/members-api": "0.8.3",
"@tryghost/members-api": "0.9.0",
"@tryghost/members-ssr": "0.7.3",
"@tryghost/social-urls": "0.1.4",
"@tryghost/string": "^0.1.3",
@ -88,10 +88,12 @@
"js-yaml": "3.13.1",
"jsonpath": "1.0.2",
"jsonwebtoken": "8.5.1",
"juice": "5.2.0",
"keypair": "1.0.1",
"knex": "0.19.5",
"knex-migrator": "3.4.0",
"lodash": "4.17.15",
"mailgun-js": "0.22.0",
"markdown-it": "10.0.0",
"markdown-it-footnote": "3.0.2",
"markdown-it-lazy-headers": "0.1.3",

354
yarn.lock
View File

@ -227,22 +227,22 @@
dependencies:
"@tryghost/kg-clean-basic-html" "^0.1.4"
"@tryghost/magic-link@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-0.3.1.tgz#a06fafb6a6e526a8658cef64c194df5f57ce4436"
integrity sha512-jwTVp79ElFKhZm5T5Yn8fzXSWMwzCnMaW2FMEPKgLZnRKDLqHBMqcDkhJDoZ0ilgK7B4wNf1qDRxoMGa+boHoA==
"@tryghost/magic-link@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-0.3.2.tgz#dba8b1854d88d86b287be1e91be49d15a27e60b6"
integrity sha512-r5UTsHco7cQ17JvUdcqS1afs04D6ZxdUMSSiKcg2rRqx/POA/sFwsahm5Neek93goB03pPQoAsS7vQLPifsvdw==
dependencies:
bluebird "^3.5.5"
ghost-ignition "^3.1.0"
jsonwebtoken "^8.5.1"
lodash "^4.17.15"
"@tryghost/members-api@0.8.3":
version "0.8.3"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.8.3.tgz#83b829a7567827f5dc00dc2f93c6e412c405ef8e"
integrity sha512-RDIw12MxQn/MUFnJ6OOjxHgerxVHkCNTEtLtj8SaLrVJ+lSw88NVUvDCzKFsC80Pm0bLJjwj81bspH9uftud5A==
"@tryghost/members-api@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.9.0.tgz#8fc227b9b6f536093f67a68d47687830de2c1748"
integrity sha512-8nfaI2CAi+B3DToLrxUwSYVaBX/gX+mLnBJW3cIml/sBXokno4Sr08HX7OpTnTNXLroLHa/+QYivhtMRAOxjQQ==
dependencies:
"@tryghost/magic-link" "^0.3.1"
"@tryghost/magic-link" "^0.3.2"
bluebird "^3.5.4"
body-parser "^1.19.0"
cookies "^0.8.0"
@ -419,6 +419,20 @@ addressparser@~0.3.2:
resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-0.3.2.tgz#59873f35e8fcf6c7361c10239261d76e15348bb2"
integrity sha1-WYc/Nej89sc2HBAjkmHXbhU0i7I=
agent-base@4, agent-base@^4.2.0, agent-base@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
dependencies:
es6-promisify "^5.0.0"
agent-base@~4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"
integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==
dependencies:
es6-promisify "^5.0.0"
ajv-keywords@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.1.tgz#617997fc5f60576894c435f940d819e135b80762"
@ -703,6 +717,11 @@ assign-symbols@^1.0.0:
resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
ast-types@0.x.x:
version "0.13.2"
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.2.tgz#df39b677a911a83f3a049644fb74fdded23cea48"
integrity sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA==
astral-regex@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
@ -725,7 +744,7 @@ async@^2.0.0, async@^2.1.2, async@^2.6.0, async@^2.6.1, async@^2.6.3:
dependencies:
lodash "^4.17.14"
async@^3.0.1:
async@^3.0.1, async@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/async/-/async-3.1.0.tgz#42b3b12ae1b74927b5217d8c0016baaf62463772"
integrity sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ==
@ -1281,7 +1300,7 @@ cheerio-advanced-selectors@~2.0.1:
resolved "https://registry.yarnpkg.com/cheerio-advanced-selectors/-/cheerio-advanced-selectors-2.0.1.tgz#fb5ec70a4599e8cec1cf669c6d9b90a3fa969c48"
integrity sha512-5wHR8bpiD5pdUtaS81A6hnJezzoDzL1TLWfK6bxnLkIgEKPV26BlOdMCcvuj3fTE7JSalsTUeNU7AOD/u6bYhw==
cheerio@0.22.0:
cheerio@0.22.0, cheerio@^0.22.0:
version "0.22.0"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.22.0.tgz#a9baa860a3f9b595a6b81b1a86873121ed3a269e"
integrity sha1-qbqoYKP5tZWmuBsahocxIe06Jp4=
@ -1539,6 +1558,11 @@ commander@2.20.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==
commander@^2.15.1:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@^2.19.0, commander@^2.20.0, commander@^2.9.0:
version "2.20.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.1.tgz#3863ce3ca92d0831dcf2a102f5fb4b5926afd0f9"
@ -1937,6 +1961,11 @@ dashdash@^1.12.0:
dependencies:
assert-plus "^1.0.0"
data-uri-to-buffer@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz#77163ea9c20d8641b4707e8f18abdf9a78f34835"
integrity sha512-vKQ9DTQPN1FLYiiEEOQ6IBGFqvjCa5rSK3cWMy/Nespm5d/x3dGFT9UBZnkLxCwua/IXBi2TYnwTEpsOvhC4UQ==
data-urls@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe"
@ -1946,6 +1975,14 @@ data-urls@^1.1.0:
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
datauri@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/datauri/-/datauri-2.0.0.tgz#ff0ee23729935a6bcc81f301621bed3e692bf3c7"
integrity sha512-zS2HSf9pI5XPlNZgIqJg/wCJpecgU/HA6E/uv2EfaWnW1EiTGLfy/EexTIsC9c99yoCOTXlqeeWk4FkCSuO3/g==
dependencies:
image-size "^0.7.3"
mimer "^1.0.0"
dateformat@~1.0.12:
version "1.0.12"
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-1.0.12.tgz#9f124b67594c937ff706932e4a642cca8dbbfee9"
@ -1975,7 +2012,7 @@ debug@3.2.6, debug@^3.0.0, debug@^3.1.0, debug@^3.2.6:
dependencies:
ms "^2.1.1"
debug@4.1.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
debug@4, debug@4.1.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
@ -2075,6 +2112,15 @@ defined@^1.0.0:
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=
degenerator@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-1.0.4.tgz#fcf490a37ece266464d9cc431ab98c5819ced095"
integrity sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=
dependencies:
ast-types "0.x.x"
escodegen "1.x.x"
esprima "3.x.x"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
@ -2373,11 +2419,18 @@ es-to-primitive@^1.2.0:
is-date-object "^1.0.1"
is-symbol "^1.0.2"
es6-promise@^4.2.6:
es6-promise@^4.0.3, es6-promise@^4.2.6:
version "4.2.8"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
es6-promisify@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
dependencies:
es6-promise "^4.0.3"
escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
@ -2388,7 +2441,7 @@ escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
escodegen@^1.11.0, escodegen@^1.8.1:
escodegen@1.x.x, escodegen@^1.11.0, escodegen@^1.8.1:
version "1.12.0"
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.12.0.tgz#f763daf840af172bb3a2b6dd7219c0e17f7ff541"
integrity sha512-TuA+EhsanGcme5T3R0L80u4t8CpbXQjegRmf7+FPTJrtCTErXFeelblRgHQa1FofEzqYYJmJ/OqjTwREp9qgmg==
@ -2628,16 +2681,16 @@ esprima@1.2.2:
resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.2.2.tgz#76a0fd66fcfe154fd292667dc264019750b1657b"
integrity sha1-dqD9Zvz+FU/SkmZ9wmQBl1CxZXs=
esprima@3.x.x, esprima@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=
esprima@^2.6.0:
version "2.7.3"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581"
integrity sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=
esprima@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=
esprima@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
@ -2977,6 +3030,11 @@ file-sync-cmp@^0.1.0:
resolved "https://registry.yarnpkg.com/file-sync-cmp/-/file-sync-cmp-0.1.1.tgz#a5e7a8ffbfa493b43b923bbd4ca89a53b63b612b"
integrity sha1-peeo/7+kk7Q7kju9TKiaU7Y7YSs=
file-uri-to-path@1:
version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
fill-keys@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/fill-keys/-/fill-keys-1.0.2.tgz#9a8fa36f4e8ad634e3bf6b4f3c8882551452eb20"
@ -3145,7 +3203,7 @@ forever-agent@~0.6.1:
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
form-data@^2.3.1:
form-data@^2.3.1, form-data@^2.3.3:
version "2.5.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==
@ -3227,6 +3285,14 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
ftp@~0.3.10:
version "0.3.10"
resolved "https://registry.yarnpkg.com/ftp/-/ftp-0.3.10.tgz#9197d861ad8142f3e63d5a83bfe4c59f7330885d"
integrity sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=
dependencies:
readable-stream "1.1.x"
xregexp "2.0.0"
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@ -3311,6 +3377,18 @@ get-stream@^5.1.0:
dependencies:
pump "^3.0.0"
get-uri@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-2.0.4.tgz#d4937ab819e218d4cb5ae18e4f5962bef169cc6a"
integrity sha512-v7LT/s8kVjs+Tx0ykk1I+H/rbpzkHvuIq87LmeXptcf5sNWm9uQiwjNAt94SJPA1zOlCntmnOlJvVWKmzsxG8Q==
dependencies:
data-uri-to-buffer "1"
debug "2"
extend "~3.0.2"
file-uri-to-path "1"
ftp "~0.3.10"
readable-stream "2"
get-value@^2.0.3, get-value@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@ -3984,15 +4062,7 @@ http-errors@1.7.2:
statuses ">= 1.5.0 < 2"
toidentifier "1.0.0"
http-errors@~1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.4.0.tgz#6c0242dea6b3df7afda153c71089b31c6e82aabf"
integrity sha1-bAJC3qaz33r9oVPHEImzHG6Cqr8=
dependencies:
inherits "2.0.1"
statuses ">= 1.2.1 < 2"
http-errors@~1.7.2:
http-errors@1.7.3, http-errors@~1.7.2:
version "1.7.3"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
@ -4003,11 +4073,27 @@ http-errors@~1.7.2:
statuses ">= 1.5.0 < 2"
toidentifier "1.0.0"
http-errors@~1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.4.0.tgz#6c0242dea6b3df7afda153c71089b31c6e82aabf"
integrity sha1-bAJC3qaz33r9oVPHEImzHG6Cqr8=
dependencies:
inherits "2.0.1"
statuses ">= 1.2.1 < 2"
"http-parser-js@>=0.4.0 <0.4.11":
version "0.4.10"
resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.10.tgz#92c9c1374c35085f75db359ec56cc257cbb93fa4"
integrity sha1-ksnBN0w1CF912zWexWzCV8u5P6Q=
http-proxy-agent@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405"
integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==
dependencies:
agent-base "4"
debug "3.1.0"
http-signature@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
@ -4017,6 +4103,14 @@ http-signature@~1.2.0:
jsprim "^1.2.2"
sshpk "^1.7.0"
https-proxy-agent@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
dependencies:
agent-base "^4.3.0"
debug "^3.1.0"
iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -4069,7 +4163,7 @@ image-size@0.8.3:
dependencies:
queue "6.0.1"
image-size@^0.7.4:
image-size@^0.7.3, image-size@^0.7.4:
version "0.7.5"
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.7.5.tgz#269f357cf5797cb44683dfa99790e54c705ead04"
integrity sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==
@ -4104,11 +4198,16 @@ indexes-of@^1.0.1:
resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc=
inflection@^1.5.1:
inflection@^1.5.1, inflection@~1.12.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.12.0.tgz#a200935656d6f5f6bc4dc7502e1aecb703228416"
integrity sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=
inflection@~1.3.0:
version "1.3.8"
resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.3.8.tgz#cbd160da9f75b14c3cc63578d4f396784bf3014e"
integrity sha1-y9Fg2p91sUw8xjV41POWeEvzAU4=
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@ -4219,6 +4318,11 @@ ip-regex@^4.1.0:
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.1.0.tgz#5ad62f685a14edb421abebc2fff8db94df67b455"
integrity sha512-pKnZpbgCTfH/1NLIlOduP/V+WRXzC2MOz3Qo8xmxk8C5GudJLgK5QyLVXOSWy3ParAH7Eemurl3xjv/WXYFvMA==
ip@1.1.5, ip@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
ipaddr.js@1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
@ -4744,6 +4848,19 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
juice@5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/juice/-/juice-5.2.0.tgz#a40ea144bde2845fe2aade46a81f493f8ea677a0"
integrity sha512-0l6GZmT3efexyaaay3SchKT5kG311N59TEFP5lfvEy0nz9SNqjx311plJ3b4jze7arsmDsiHQLh/xnAuk0HFTQ==
dependencies:
cheerio "^0.22.0"
commander "^2.15.1"
cross-spawn "^6.0.5"
deep-extend "^0.6.0"
mensch "^0.3.3"
slick "^1.12.2"
web-resource-inliner "^4.3.1"
just-extend@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc"
@ -5134,7 +5251,7 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
lodash.unescape@^4.0.0:
lodash.unescape@^4.0.0, lodash.unescape@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.unescape/-/lodash.unescape-4.0.1.tgz#bf2249886ce514cda112fae9218cdc065211fc9c"
integrity sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw=
@ -5224,6 +5341,13 @@ lru-cache@^4.0.0, lru-cache@^4.0.1, lru-cache@^4.1.5:
pseudomap "^1.0.2"
yallist "^2.1.2"
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
dependencies:
yallist "^3.0.2"
mailcomposer@~0.2.10:
version "0.2.12"
resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-0.2.12.tgz#4d02a604616adcb45fb36d37513f4c1bd0b75681"
@ -5235,6 +5359,21 @@ mailcomposer@~0.2.10:
mime "~1.2.11"
mimelib "~0.2.15"
mailgun-js@0.22.0:
version "0.22.0"
resolved "https://registry.yarnpkg.com/mailgun-js/-/mailgun-js-0.22.0.tgz#128942b5e47a364a470791608852bf68c96b3a05"
integrity sha512-a2alg5nuTZA9Psa1pSEIEsbxr1Zrmqx4VkgGCQ30xVh0kIH7Bu57AYILo+0v8QLSdXtCyLaS+KVmdCrQo0uWFA==
dependencies:
async "^2.6.1"
debug "^4.1.0"
form-data "^2.3.3"
inflection "~1.12.0"
is-stream "^1.1.0"
path-proxy "~1.0.0"
promisify-call "^2.0.2"
proxy-agent "^3.0.3"
tsscmp "^1.0.6"
make-dir@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@ -5369,6 +5508,11 @@ memoize-one@~5.1.1:
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
mensch@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/mensch/-/mensch-0.3.3.tgz#e200ff4dd823717f8e0563b32e3f5481fca262b2"
integrity sha1-4gD/TdgjcX+OBWOzLj9UgfyiYrI=
meow@^3.3.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
@ -5561,6 +5705,11 @@ mimelib@~0.2.15:
addressparser "~0.3.2"
encoding "~0.1.7"
mimer@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/mimer/-/mimer-1.0.0.tgz#32251bef4dc7a63184db3a1082ed9be3abe0f3db"
integrity sha512-4ZJvCzfcwsBgPbkKXUzGoVZMWjv8IDIygkGzVc7uUYhgnK0t2LmGxxjdgH1i+pn0/KQfB5F/VKUJlfyTSOFQjg==
mimic-fn@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
@ -5905,6 +6054,11 @@ netjet@1.3.0:
lru-cache "^4.0.0"
posthtml "^0.9.0"
netmask@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35"
integrity sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=
next-tick@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
@ -6349,6 +6503,31 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
pac-proxy-agent@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-3.0.1.tgz#115b1e58f92576cac2eba718593ca7b0e37de2ad"
integrity sha512-44DUg21G/liUZ48dJpUSjZnFfZro/0K5JTyFYLBcmh9+T6Ooi4/i4efwUiEy0+4oQusCBqWdhv16XohIj1GqnQ==
dependencies:
agent-base "^4.2.0"
debug "^4.1.1"
get-uri "^2.0.0"
http-proxy-agent "^2.1.0"
https-proxy-agent "^3.0.0"
pac-resolver "^3.0.0"
raw-body "^2.2.0"
socks-proxy-agent "^4.0.1"
pac-resolver@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-3.0.0.tgz#6aea30787db0a891704deb7800a722a7615a6f26"
integrity sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==
dependencies:
co "^4.6.0"
degenerator "^1.0.4"
ip "^1.1.5"
netmask "^1.0.6"
thunkify "^2.1.2"
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@ -6469,6 +6648,13 @@ path-parse@^1.0.6:
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
path-proxy@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/path-proxy/-/path-proxy-1.0.0.tgz#18e8a36859fc9d2f1a53b48dee138543c020de5e"
integrity sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4=
dependencies:
inflection "~1.3.0"
path-root-regex@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d"
@ -6960,6 +7146,13 @@ progress@^2.0.0:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
promisify-call@^2.0.2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/promisify-call/-/promisify-call-2.0.4.tgz#d48c2d45652ccccd52801ddecbd533a6d4bd5fba"
integrity sha1-1IwtRWUszM1SgB3ey9UzptS9X7o=
dependencies:
with-callback "^1.0.2"
propagate@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45"
@ -6978,6 +7171,25 @@ proxy-addr@~2.0.5:
forwarded "~0.1.2"
ipaddr.js "1.9.0"
proxy-agent@^3.0.3:
version "3.1.1"
resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-3.1.1.tgz#7e04e06bf36afa624a1540be247b47c970bd3014"
integrity sha512-WudaR0eTsDx33O3EJE16PjBRZWcX8GqCEeERw1W3hZJgH/F2a46g7jty6UGty6NeJ4CKQy8ds2CJPMiyeqaTvw==
dependencies:
agent-base "^4.2.0"
debug "4"
http-proxy-agent "^2.1.0"
https-proxy-agent "^3.0.0"
lru-cache "^5.1.1"
pac-proxy-agent "^3.0.1"
proxy-from-env "^1.0.0"
socks-proxy-agent "^4.0.1"
proxy-from-env@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee"
integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=
proxyquire@2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/proxyquire/-/proxyquire-2.1.3.tgz#2049a7eefa10a9a953346a18e54aab2b4268df39"
@ -7100,6 +7312,16 @@ raw-body@2.4.0:
iconv-lite "0.4.24"
unpipe "1.0.0"
raw-body@^2.2.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==
dependencies:
bytes "3.1.0"
http-errors "1.7.3"
iconv-lite "0.4.24"
unpipe "1.0.0"
raw-body@~1.1.0:
version "1.1.7"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425"
@ -7162,7 +7384,7 @@ readable-stream@1.1.x, readable-stream@~1.1.9:
isarray "0.0.1"
string_decoder "~0.10.x"
readable-stream@2.3.6, readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
readable-stream@2, readable-stream@2.3.6, readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
version "2.3.6"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==
@ -7373,7 +7595,7 @@ request-promise@^4.2.4:
stealthy-require "^1.1.1"
tough-cookie "^2.3.3"
"request@>=2.76.0 <3.0.0", request@^2.83.0, request@^2.87.0, request@^2.88.0:
"request@>=2.76.0 <3.0.0", request@^2.78.0, request@^2.83.0, request@^2.87.0, request@^2.88.0:
version "2.88.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
@ -7566,7 +7788,7 @@ safe-regex@^1.1.0:
dependencies:
ret "~0.1.10"
"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@^2.1.2, safer-buffer@~2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
@ -7834,6 +8056,16 @@ slice-ansi@^2.1.0:
astral-regex "^1.0.0"
is-fullwidth-code-point "^2.0.0"
slick@^1.12.2:
version "1.12.2"
resolved "https://registry.yarnpkg.com/slick/-/slick-1.12.2.tgz#bd048ddb74de7d1ca6915faa4a57570b3550c2d7"
integrity sha1-vQSN23TefRymkV+qSldXCzVQwtc=
smart-buffer@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.1.0.tgz#91605c25d91652f4661ea69ccf45f1b331ca21ba"
integrity sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw==
smartquotes@~2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/smartquotes/-/smartquotes-2.3.1.tgz#01ebb595d6c7a9e24d90e8cb95c17d0e1af49407"
@ -7876,6 +8108,22 @@ snapdragon@^0.8.1:
source-map-resolve "^0.5.0"
use "^3.1.0"
socks-proxy-agent@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz#3c8991f3145b2799e70e11bd5fbc8b1963116386"
integrity sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==
dependencies:
agent-base "~4.2.1"
socks "~2.3.2"
socks@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/socks/-/socks-2.3.3.tgz#01129f0a5d534d2b897712ed8aceab7ee65d78e3"
integrity sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA==
dependencies:
ip "1.1.5"
smart-buffer "^4.1.0"
sort-keys@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
@ -8400,6 +8648,11 @@ through@^2.3.6:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
thunkify@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d"
integrity sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0=
tildify@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/tildify/-/tildify-1.2.0.tgz#dcec03f55dca9b7aa3e5b04f21817eb56e63588a"
@ -8549,7 +8802,7 @@ tslib@^1.9.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
tsscmp@1.0.6:
tsscmp@1.0.6, tsscmp@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
@ -8830,6 +9083,11 @@ v8flags@^3.1.2, v8flags@^3.1.3, v8flags@~3.1.1:
dependencies:
homedir-polyfill "^1.0.1"
valid-data-url@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/valid-data-url/-/valid-data-url-2.0.0.tgz#2220fa9f8d4e761ebd3f3bb02770f1212b810537"
integrity sha512-dyCZnv3aCey7yfTgIqdZanKl7xWAEEKCbgmR7SKqyK6QT/Z07ROactrgD1eA37C69ODRj7rNOjzKWVPh0EUjBA==
validate-npm-package-license@^3.0.1:
version "3.0.4"
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
@ -8917,6 +9175,21 @@ walkdir@^0.0.11:
resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.0.11.tgz#a16d025eb931bd03b52f308caed0f40fcebe9532"
integrity sha1-oW0CXrkxvQO1LzCMrtD0D86+lTI=
web-resource-inliner@^4.3.1:
version "4.3.3"
resolved "https://registry.yarnpkg.com/web-resource-inliner/-/web-resource-inliner-4.3.3.tgz#a5446b02bc11beb4cb5e764e928d9c1e4ef47f41"
integrity sha512-Qk19pohqZs3SoFUW4ZlOHlM8hsOnXhTpCrQ16b1qtJtKzJgO7NZLGP+/lcb2g3hWDQD39/LE/JYOn1Sjy7tn1A==
dependencies:
async "^3.1.0"
chalk "^2.4.2"
datauri "^2.0.0"
htmlparser2 "^3.9.2"
lodash.unescape "^4.0.1"
request "^2.78.0"
safer-buffer "^2.1.2"
valid-data-url "^2.0.0"
xtend "^4.0.2"
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
@ -8999,6 +9272,11 @@ window-size@^0.1.4:
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.4.tgz#f8e1aa1ee5a53ec5bf151ffa09742a6ad7697876"
integrity sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY=
with-callback@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/with-callback/-/with-callback-1.0.2.tgz#a09629b9a920028d721404fb435bdcff5c91bc21"
integrity sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE=
wordwrap@~0.0.2:
version "0.0.3"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
@ -9097,7 +9375,7 @@ xss@~1.0.6:
commander "^2.9.0"
cssfilter "0.0.10"
xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
@ -9117,7 +9395,7 @@ yallist@^2.1.2:
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
yallist@^3.0.0, yallist@^3.0.3:
yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==