0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Merge branch 'master' into v3

This commit is contained in:
Kevin Ansfield 2019-10-11 11:31:31 +01:00
commit 78e16ddd3f
34 changed files with 868 additions and 807 deletions

@ -1 +1 @@
Subproject commit 2e91cb09bf9ea348552eae7daede465f227cb4b3
Subproject commit 1705a622d386bb034aecebeaa4857463c3c3c3a4

View file

@ -60,30 +60,20 @@ const members = {
}
},
permissions: true,
query(frame) {
// NOTE: Promise.resolve() is here for a reason! Method has to return an instance
// of a Bluebird promise to allow reflection. If decided to be replaced
// with something else, e.g: async/await, CSV export function
// would need a deep rewrite (see failing tests if this line is removed)
return Promise.resolve()
.then(() => {
return membersService.api.members.create(frame.data.members[0], {
sendEmail: frame.options.send_email,
emailType: frame.options.email_type
});
})
.then((member) => {
if (member) {
return Promise.resolve(member);
}
})
.catch((error) => {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.members.memberAlreadyExists')}));
}
return Promise.reject(error);
async query(frame) {
try {
const member = await membersService.api.members.create(frame.data.members[0], {
sendEmail: frame.options.send_email,
emailType: frame.options.email_type
});
return member;
} catch (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
throw new common.errors.ValidationError({message: common.i18n.t('errors.api.members.memberAlreadyExists')});
}
throw error;
}
}
},
@ -163,23 +153,24 @@ const members = {
return fsLib.readCSV({
path: filePath,
columnsToExtract: [{name: 'email', lookup: /email/i}, {name: 'name', lookup: /name/i}]
columnsToExtract: [{name: 'email', lookup: /email/i}, {name: 'name', lookup: /name/i}, {name: 'note', lookup: /note/i}]
}).then((result) => {
return Promise.all(result.map((entry) => {
const api = require('./index');
return api.members.add.query({
return Promise.resolve(api.members.add.query({
data: {
members: [{
email: entry.email,
name: entry.name
name: entry.name,
note: entry.note
}]
},
options: {
context: frame.options.context,
options: {send_email: false}
}
}).reflect();
})).reflect();
})).each((inspection) => {
if (inspection.isFulfilled()) {
fulfilled = fulfilled + 1;

View file

@ -41,7 +41,7 @@ module.exports = {
exportCSV(models, apiConfig, frame) {
debug('exportCSV');
const fields = ['id', 'email', 'name', 'created_at', 'deleted_at'];
const fields = ['id', 'email', 'name', 'note', 'created_at', 'deleted_at'];
function formatCSV(data) {
let csv = `${fields.join(',')}\r\n`,

View file

@ -94,8 +94,6 @@ const post = (attrs, frame) => {
if (attrs.og_description === '') {
attrs.og_description = null;
}
delete attrs.visibility;
} else {
delete attrs.page;
}

View file

@ -11,7 +11,6 @@
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
@ -21,6 +20,11 @@
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"note": {
"type": "string",
"minLength": 0,
"maxLength": 2000
},
"id": {
"strip": true
},

View file

@ -67,10 +67,6 @@ module.exports = {
return shared.pipeline(require('./settings'), localUtils);
},
get members() {
return shared.pipeline(require('./members'), localUtils);
},
get images() {
return shared.pipeline(require('./images'), localUtils);
},

View file

@ -1,209 +0,0 @@
// NOTE: We must not cache references to membersService.api
// as it is a getter and may change during runtime.
const Promise = require('bluebird');
const membersService = require('../../services/members');
const common = require('../../lib/common');
const fsLib = require('../../lib/fs');
const members = {
docName: 'members',
browse: {
options: [
'limit',
'fields',
'filter',
'order',
'debug',
'page'
],
permissions: true,
validation: {},
query(frame) {
return membersService.api.members.list(frame.options);
}
},
read: {
headers: {},
data: [
'id',
'email'
],
validation: {},
permissions: true,
async query(frame) {
const member = await membersService.api.members.get(frame.data, frame.options);
if (!member) {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.members.memberNotFound')
});
}
return member;
}
},
add: {
statusCode: 201,
headers: {},
options: [
'send_email',
'email_type'
],
validation: {
data: {
email: {required: true}
},
options: {
email_type: {
values: ['signin', 'signup', 'subscribe']
}
}
},
permissions: true,
query(frame) {
// NOTE: Promise.resolve() is here for a reason! Method has to return an instance
// of a Bluebird promise to allow reflection. If decided to be replaced
// with something else, e.g: async/await, CSV export function
// would need a deep rewrite (see failing tests if this line is removed)
return Promise.resolve()
.then(() => {
return membersService.api.members.create(frame.data.members[0], {
sendEmail: frame.options.send_email,
emailType: frame.options.email_type
});
})
.then((member) => {
if (member) {
return Promise.resolve(member);
}
})
.catch((error) => {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.members.memberAlreadyExists')}));
}
return Promise.reject(error);
});
}
},
edit: {
statusCode: 200,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
const member = await membersService.api.members.update(frame.data.members[0], frame.options);
return member;
}
},
destroy: {
statusCode: 204,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
frame.options.require = true;
await membersService.api.members.destroy(frame.options);
return null;
}
},
exportCSV: {
headers: {
disposition: {
type: 'csv',
value() {
const datetime = (new Date()).toJSON().substring(0, 10);
return `members.${datetime}.csv`;
}
}
},
response: {
format: 'plain'
},
permissions: {
method: 'browse'
},
validation: {},
query(frame) {
return membersService.api.members.list(frame.options);
}
},
importCSV: {
statusCode: 201,
permissions: {
method: 'add'
},
async query(frame) {
let filePath = frame.file.path,
fulfilled = 0,
invalid = 0,
duplicates = 0;
return fsLib.readCSV({
path: filePath,
columnsToExtract: [{name: 'email', lookup: /email/i}, {name: 'name', lookup: /name/i}]
}).then((result) => {
return Promise.all(result.map((entry) => {
const api = require('./index');
return api.members.add.query({
data: {
members: [{
email: entry.email,
name: entry.name
}]
},
options: {
context: frame.options.context,
options: {send_email: false}
}
}).reflect();
})).each((inspection) => {
if (inspection.isFulfilled()) {
fulfilled = fulfilled + 1;
} else {
if (inspection.reason() instanceof common.errors.ValidationError) {
duplicates = duplicates + 1;
} else {
invalid = invalid + 1;
}
}
});
}).then(() => {
return {
meta: {
stats: {
imported: fulfilled,
duplicates: duplicates,
invalid: invalid
}
}
};
});
}
}
};
module.exports = members;

View file

@ -1,77 +0,0 @@
const common = require('../../../../../lib/common');
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:members');
module.exports = {
browse(data, apiConfig, frame) {
debug('browse');
frame.response = data;
},
add(data, apiConfig, frame) {
debug('add');
frame.response = {
members: [data]
};
},
edit(data, apiConfig, frame) {
debug('edit');
frame.response = {
members: [data]
};
},
read(data, apiConfig, frame) {
debug('read');
if (!data) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.members.memberNotFound')
}));
}
frame.response = {
members: [data]
};
},
exportCSV(models, apiConfig, frame) {
debug('exportCSV');
const fields = ['id', 'email', 'name', 'created_at', 'deleted_at'];
function formatCSV(data) {
let csv = `${fields.join(',')}\r\n`,
entry,
field,
j,
i;
for (j = 0; j < data.length; j = j + 1) {
entry = data[j];
for (i = 0; i < fields.length; i = i + 1) {
field = fields[i];
csv += entry[field] !== null ? entry[field] : '';
if (i !== fields.length - 1) {
csv += ',';
}
}
csv += '\r\n';
}
return csv;
}
frame.response = formatCSV(models.members);
},
importCSV(data, apiConfig, frame) {
debug('importCSV');
frame.response = data;
}
};

View file

@ -1,15 +0,0 @@
const jsonSchema = require('../utils/json-schema');
module.exports = {
add(apiConfig, frame) {
const schema = require('./schemas/members-add');
const definitions = require('./schemas/members');
return jsonSchema.validate(schema, definitions, frame.data);
},
edit(apiConfig, frame) {
const schema = require('./schemas/members-edit');
const definitions = require('./schemas/members');
return jsonSchema.validate(schema, definitions, frame.data);
}
};

View file

@ -1,22 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "members.add",
"title": "members.add",
"description": "Schema for members.add",
"type": "object",
"additionalProperties": false,
"properties": {
"members": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"allOf": [{"$ref": "members#/definitions/member"}],
"required": ["email"]
}
}
},
"required": ["members"]
}

View file

@ -1,21 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "members.edit",
"title": "members.edit",
"description": "Schema for members.edit",
"type": "object",
"additionalProperties": false,
"properties": {
"members": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"allOf": [{"$ref": "members#/definitions/member"}]
}
}
},
"required": ["members"]
}

View file

@ -1,42 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "members",
"title": "members",
"description": "Base members definitions",
"definitions": {
"member": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"email": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"id": {
"strip": true
},
"created_at": {
"strip": true
},
"created_by": {
"strip": true
},
"updated_at": {
"strip": true
},
"updated_by": {
"strip": true
}
}
}
}
}

View file

@ -0,0 +1,28 @@
const commands = require('../../../schema').commands;
module.exports = {
up: commands.createColumnMigration({
table: 'members',
column: 'note',
dbIsInCorrectState(hasColumn) {
return hasColumn === true;
},
operation: commands.addColumn,
operationVerb: 'Adding'
}),
down: commands.createColumnMigration({
table: 'members',
column: 'note',
dbIsInCorrectState(hasColumn) {
return hasColumn === false;
},
operation: commands.dropColumn,
operationVerb: 'Dropping'
}),
config: {
transaction: true
}
};

View file

@ -194,11 +194,14 @@
"members_session_secret": {
"defaultValue": null
},
"members_email_auth_secret": {
"defaultValue": null
},
"default_content_visibility": {
"defaultValue": "public"
},
"members_subscription_settings": {
"defaultValue": "{\"isPaid\":false,\"fromAddress\":\"noreply\",\"requirePaymentForSignup\":false,\"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\":\"\"}]}}]}"
"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\":\"\"}]}}]}"
}
}
}

View file

@ -323,6 +323,7 @@ module.exports = {
id: {type: 'string', maxlength: 24, nullable: false, primary: 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},
created_at: {type: 'dateTime', nullable: false},
created_by: {type: 'string', maxlength: 24, nullable: false},
updated_at: {type: 'dateTime', nullable: true},

View file

@ -0,0 +1,44 @@
// this is a function so that when it's aliased across multiple cards we do not
// end up modifying the object by reference
module.exports = function markdownCardDefinition() {
return {
name: 'markdown',
type: 'dom',
config: {
commentWrapper: true
},
render: function (opts) {
let converters = require('../converters');
let payload = opts.payload;
let version = opts.options && opts.options.version || 2;
// convert markdown to HTML ready for insertion into dom
let html = converters.markdownConverter.render(payload.markdown || '');
if (!html) {
return '';
}
/**
* @deprecated Ghost 1.0's markdown-only renderer wrapped cards. Remove in Ghost 3.0
*/
if (version === 1) {
html = `<div class="kg-card-markdown">${html}</div>`;
}
// use the SimpleDOM document to create a raw HTML section.
// avoids parsing/rendering of potentially broken or unsupported HTML
return opts.env.dom.createRawHTMLSection(html);
},
absoluteToRelative(urlUtils, payload, options) {
payload.markdown = payload.markdown && urlUtils.markdownAbsoluteToRelative(payload.markdown, options);
return payload;
},
relativeToAbsolute(urlUtils, payload, options) {
payload.markdown = payload.markdown && urlUtils.markdownRelativeToAbsolute(payload.markdown, options);
return payload;
}
};
};

View file

@ -1,8 +1,9 @@
// this card is just an alias of the `markdown` card which is necessary because
// our markdown-only editor was using the `card-markdown` card name
const markdownCard = require('./markdown');
const markdownCard = require('./_markdown');
const createCard = require('../create-card');
module.exports = createCard(
Object.assign({}, markdownCard, {name: 'card-markdown'})
);
const v1CompatMarkdownCard = markdownCard();
v1CompatMarkdownCard.name = 'card-markdown';
module.exports = createCard(v1CompatMarkdownCard);

View file

@ -1,33 +1,6 @@
const markdownCard = require('./_markdown');
const createCard = require('../create-card');
module.exports = createCard({
name: 'markdown',
type: 'dom',
config: {
commentWrapper: true
},
render: function (opts) {
let converters = require('../converters');
let payload = opts.payload;
// convert markdown to HTML ready for insertion into dom
let html = converters.markdownConverter.render(payload.markdown || '');
if (!html) {
return '';
}
// use the SimpleDOM document to create a raw HTML section.
// avoids parsing/rendering of potentially broken or unsupported HTML
return opts.env.dom.createRawHTMLSection(html);
},
absoluteToRelative(urlUtils, payload, options) {
payload.markdown = payload.markdown && urlUtils.markdownAbsoluteToRelative(payload.markdown, options);
return payload;
},
relativeToAbsolute(urlUtils, payload, options) {
payload.markdown = payload.markdown && urlUtils.markdownRelativeToAbsolute(payload.markdown, options);
return payload;
}
});
// uses the _markdown card definition function so that the card definition
// can be re-used in aliased cards
module.exports = createCard(markdownCard());

View file

@ -1,24 +1,7 @@
const ghostBookshelf = require('./base');
const Member = ghostBookshelf.Model.extend({
tableName: 'members',
relationships: ['stripe_customers'],
relationshipBelongsTo: {
stripe_customers: 'members_stripe_customers'
},
permittedAttributes(...args) {
return ghostBookshelf.Model.prototype.permittedAttributes.apply(this, args).concat(this.relationships);
},
stripe_customers() {
return this.hasMany('MemberStripeCustomer', 'member_id');
}
}, {
permittedOptions(...args) {
return ghostBookshelf.Model.permittedOptions.apply(this, args).concat(['withRelated']);
}
tableName: 'members'
});
const Members = ghostBookshelf.Collection.extend({

View file

@ -37,7 +37,8 @@ function parseDefaultSettings() {
members_session_secret: () => crypto.randomBytes(32).toString('hex'),
theme_session_secret: () => crypto.randomBytes(32).toString('hex'),
members_public_key: () => getMembersKey('public'),
members_private_key: () => getMembersKey('private')
members_private_key: () => getMembersKey('private'),
members_email_auth_secret: () => crypto.randomBytes(64).toString('hex')
};
_.each(defaultSettingsInCategories, function each(settings, categoryName) {

View file

@ -36,10 +36,12 @@ function getFromAddress(requestedFromAddress) {
}
function createMessage(message) {
const encoding = 'base64';
const generateTextFromHTML = !message.forceTextContent;
return Object.assign({}, message, {
from: getFromAddress(),
generateTextFromHTML: true,
encoding: 'base64'
from: getFromAddress(message.from),
generateTextFromHTML,
encoding
});
}

View file

@ -1,3 +1,4 @@
const crypto = require('crypto');
const {URL} = require('url');
const settingsCache = require('../settings/cache');
const urlUtils = require('../../lib/url-utils');
@ -6,26 +7,30 @@ 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');
function createMember({email, name}) {
return models.Member.add({
async function createMember({email, name, note}, options = {}) {
const model = await models.Member.add({
email,
name
}).then((member) => {
return member.toJSON();
name: name || null,
note: note || null
});
const member = model.toJSON(options);
return member;
}
function getMember(data, options = {}) {
async function getMember(data, options = {}) {
if (!data.email && !data.id) {
return Promise.resolve(null);
}
return models.Member.findOne(data, options).then((model) => {
if (!model) {
return null;
}
return model.toJSON(options);
});
const model = await models.Member.findOne(data, options);
if (!model) {
return null;
}
const member = model.toJSON(options);
return member;
}
async function setMetadata(module, metadata) {
@ -70,8 +75,14 @@ async function getMetadata(module, member) {
};
}
function updateMember({name}, options) {
return models.Member.edit({name}, options);
async function updateMember({name, note}, options = {}) {
const model = await models.Member.edit({
name: name || null,
note: note || null
}, options);
const member = model.toJSON(options);
return member;
}
function deleteMember(options) {
@ -146,9 +157,30 @@ function getStripePaymentConfig() {
};
}
function getRequirePaymentSetting() {
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.requirePaymentForSignup;
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;
@ -167,7 +199,8 @@ function createApiInstance() {
signinURL.searchParams.set('action', type);
return signinURL.href;
},
allowSelfSignup: !getRequirePaymentSetting()
allowSelfSignup: getAllowSelfSignup(),
secret: getAuthSecret()
},
mail: {
transporter: {
@ -175,29 +208,102 @@ function createApiInstance() {
if (process.env.NODE_ENV !== 'production') {
common.logging.warn(message.text);
}
return ghostMailer.send(Object.assign({subject: 'Signin'}, message));
let msg = Object.assign({
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);
}
},
getText(url, type) {
getSubject(type) {
const siteTitle = settingsCache.get('title');
switch (type) {
case 'subscribe':
return `Click here to confirm your subscription ${url}`;
return `📫 Confirm your subscription to ${siteTitle}`;
case 'signup':
return `Click here to confirm your email address and sign up ${url}`;
return `🙌 Complete your sign up to ${siteTitle}!`;
case 'signin':
default:
return `Click here to sign in ${url}`;
return `🔑 Secure sign in link for ${siteTitle}`;
}
},
getHTML(url, type) {
getText(url, type, email) {
const siteTitle = settingsCache.get('title');
switch (type) {
case 'subscribe':
return `<a href="${url}">Click here to confirm your subscription</a>`;
return `
Hey there,
You're one tap away from subscribing to ${siteTitle} please confirm your email address with this link:
${url}
For your security, the link will expire in 10 minutes time.
All the best!
The team at ${siteTitle}
---
Sent to ${email}
If you did not make this request, you can simply delete this message. You will not be subscribed.
`;
case 'signup':
return `<a href="${url}">Click here to confirm your email address and sign up</a>`;
return `
Hey there!
Thanks for signing up for ${siteTitle} use this link to complete the sign up process and be automatically signed in:
${url}
For your security, the link will expire in 10 minutes time.
See you soon!
The team at ${siteTitle}
---
Sent to ${email}
If you did not make this request, you can simply delete this message. You will not be signed up, and no account will be created for you.
`;
case 'signin':
default:
return `<a href="${url}">Click here to sign in</a>`;
return `
Hey there,
Welcome back! Use this link to securely sign in to your ${siteTitle} account:
${url}
For your security, the link will expire in 10 minutes time.
See you soon!
The team at ${siteTitle}
---
Sent to ${email}
If you did not make this request, you can safely ignore this email.
`;
}
},
getHTML(url, type, email) {
const siteTitle = settingsCache.get('title');
switch (type) {
case 'subscribe':
return subscribeEmail({url, email, siteTitle});
case 'signup':
return signupEmail({url, email, siteTitle});
case 'signin':
default:
return signinEmail({url, email, siteTitle});
}
}
},

View file

@ -0,0 +1,169 @@
module.exports = ({siteTitle, email, url}) => `
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>🔑 Secure sign in link for ${siteTitle}</title>
<style>
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 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;
}
}
/* -------------------------------------
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%;
}
.recipient-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;
}
}
hr {
border-width: 0;
height: 0;
margin-top: 34px;
margin-bottom: 34px;
border-bottom-width: 1px;
border-bottom-color: #EEF5F8;
}
</style>
</head>
<body class="" style="background-color: #F4F8FB; 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: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #F4F8FB;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 600px; padding: 10px; width: 600px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">Welcome back to ${siteTitle}!</span>
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 40px 50px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; font-weight: bold; margin: 0; Margin-bottom: 15px;">Hey there,</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">Welcome back! Use this link to securely sign in to your ${siteTitle} account:</p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr>
<td align="left" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-bottom: 30px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #15212A; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #15212A; border: solid 1px #15212A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #15212A;">Sign in to ${siteTitle}</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">For your security, the link will expire in 10 minutes time.</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">See you soon!<br/>The team at ${siteTitle}</p>
<hr/>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">You can also copy & paste this URL into your browser:</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 12px; color: #738A94;">${url}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block powered-by" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 10px; padding-top: 15px; font-size: 12px; color: #738A94; text-align: center;">
If you did not make this request, you can safely ignore this email.
</td>
</tr>
<tr>
<td class="content-block" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 10px; padding-top: 15px; font-size: 12px; color: #738A94; text-align: center;">
<span class="recipient-link" style="color: #738A94; font-size: 12px; text-align: center;">Sent to <a href="mailto:${email}" style="text-decoration: underline; color: #738A94; font-size: 12px; text-align: center;">${email}</a></span>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>
`;

View file

@ -0,0 +1,169 @@
module.exports = ({siteTitle, email, url}) => `
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>🙌 Complete your sign up to ${siteTitle}!</title>
<style>
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 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;
}
}
/* -------------------------------------
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%;
}
.recipient-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;
}
}
hr {
border-width: 0;
height: 0;
margin-top: 34px;
margin-bottom: 34px;
border-bottom-width: 1px;
border-bottom-color: #EEF5F8;
}
</style>
</head>
<body class="" style="background-color: #F4F8FB; 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: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #F4F8FB;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 600px; padding: 10px; width: 600px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">Thanks for signing up for ${siteTitle}!</span>
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 40px 50px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; font-weight: bold; margin: 0; Margin-bottom: 15px;">Hey there!</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">Thanks for signing up for ${siteTitle} use this link to complete the sign up process and be automatically signed in:</p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr>
<td align="left" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-bottom: 30px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #15212A; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #15212A; border: solid 1px #15212A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #15212A;">Activate my account</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">For your security, the link will expire in 10 minutes time.</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">See you soon!<br/>The team at ${siteTitle}</p>
<hr/>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">You can also copy & paste this URL into your browser:</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 12px; color: #738A94;">${url}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 10px; padding-top: 15px; font-size: 12px; color: #738A94; text-align: center;">
If you did not make this request, you can simply delete this message. <br/>You will not be signed up, and no account will be created for you.
</td>
</tr>
<tr>
<td class="content-block" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 10px; padding-top: 15px; font-size: 12px; color: #738A94; text-align: center;">
<span class="recipient-link" style="color: #738A94; font-size: 12px; text-align: center;">Sent to <a href="mailto:${email}" style="text-decoration: underline; color: #738A94; font-size: 12px; text-align: center;">${email}</a></span>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>
`;

View file

@ -0,0 +1,169 @@
module.exports = ({siteTitle, email, url}) => `
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>📫 Confirm your subscription to ${siteTitle}</title>
<style>
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 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;
}
}
/* -------------------------------------
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%;
}
.recipient-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;
}
}
hr {
border-width: 0;
height: 0;
margin-top: 34px;
margin-bottom: 34px;
border-bottom-width: 1px;
border-bottom-color: #EEF5F8;
}
</style>
</head>
<body class="" style="background-color: #F4F8FB; 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: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #F4F8FB;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 600px; padding: 10px; width: 600px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">You're one tap away from subscribing to ${siteTitle}!</span>
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 40px 50px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; font-weight: bold; margin: 0; Margin-bottom: 15px;">Hey there,</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">You're one tap away from subscribing to ${siteTitle} please confirm your email address with this link:</p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr>
<td align="left" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-bottom: 30px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #15212A; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #15212A; border: solid 1px #15212A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #15212A;">Yes, I want to subscribe</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">For your security, the link will expire in 10 minutes time.</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; Margin-bottom: 30px;">All the best!<br/>The team at ${siteTitle}</p>
<hr/>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 30px;">You can also copy & paste this URL into your browser:</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 12px; color: #738A94;">${url}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 10px; padding-top: 15px; font-size: 12px; color: #738A94; text-align: center;">
If you did not make this request, you can simply delete this message.<br/>You will not be subscribed.
</td>
</tr>
<tr>
<td class="content-block" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 10px; padding-top: 15px; font-size: 12px; color: #738A94; text-align: center;">
<span class="recipient-link" style="color: #738A94; font-size: 12px; text-align: center;">Sent to <a href="mailto:${email}" style="text-decoration: underline; color: #738A94; font-size: 12px; text-align: center;">${email}</a></span>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>
`;

View file

@ -83,23 +83,6 @@ module.exports = function apiRoutes() {
router.put('/tags/:id', mw.authAdminApi, http(apiv2.tags.edit));
router.del('/tags/:id', mw.authAdminApi, http(apiv2.tags.destroy));
// ## Members
router.get('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.browse));
router.post('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.add));
router.get('/members/csv', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.exportCSV));
router.post('/members/csv',
shared.middlewares.labs.members,
mw.authAdminApi,
upload.single('membersfile'),
shared.middlewares.validation.upload({type: 'members'}),
http(apiv2.members.importCSV)
);
router.get('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.read));
router.put('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.edit));
router.del('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.destroy));
// ## Roles
router.get('/roles/', mw.authAdminApi, http(apiv2.roles.browse));

View file

@ -225,7 +225,7 @@ describe('Members API', function () {
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
res.headers['content-disposition'].should.match(/Attachment;\sfilename="members/);
res.text.should.match(/id,email,name,created_at,deleted_at/);
res.text.should.match(/id,email,name,note,created_at,deleted_at/);
res.text.should.match(/member1@test.com/);
res.text.should.match(/Mr Egg/);
});

View file

@ -20,7 +20,7 @@ const expectedProperties = {
// and always returns computed properties: url, primary_tag, primary_author
.concat('url')
// canary API doesn't return unused fields
.without('locale', 'visibility')
.without('locale')
// These fields aren't useful as they always have known values
.without('status')
// @TODO: https://github.com/TryGhost/Ghost/issues/10335

View file

@ -1,255 +0,0 @@
const path = require('path');
const should = require('should');
const supertest = require('supertest');
const sinon = require('sinon');
const testUtils = require('../../../../utils');
const localUtils = require('./utils');
const config = require('../../../../../server/config');
const labs = require('../../../../../server/services/labs');
const ghost = testUtils.startGhost;
let request;
describe('Members API', function () {
before(function () {
sinon.stub(labs, 'isSet').withArgs('members').returns(true);
});
after(function () {
sinon.restore();
});
before(function () {
return ghost()
.then(function () {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'member');
});
});
it('Can browse', function () {
return request
.get(localUtils.API.getApiQuery('members/'))
.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.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
testUtils.API.isISO8601(jsonResponse.members[0].created_at).should.be.true();
jsonResponse.members[0].created_at.should.be.an.instanceof(String);
jsonResponse.meta.pagination.should.have.property('page', 1);
jsonResponse.meta.pagination.should.have.property('limit', 15);
jsonResponse.meta.pagination.should.have.property('pages', 1);
jsonResponse.meta.pagination.should.have.property('total', 1);
jsonResponse.meta.pagination.should.have.property('next', null);
jsonResponse.meta.pagination.should.have.property('prev', null);
});
});
it('Can read', function () {
return request
.get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[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.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe');
});
});
it('Can add', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
jsonResponse.members[0].name.should.equal(member.name);
jsonResponse.members[0].email.should.equal(member.email);
})
.then(() => {
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(422);
});
});
it('Should fail when passing incorrect email_type query parameter', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/?send_email=true&email_type=lel`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(422);
});
it('Can edit by id', function () {
const memberToChange = {
name: 'change me',
email: 'member2Change@test.com'
};
const memberChanged = {
name: 'changed',
email: 'cantChangeMe@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [memberToChange]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.put(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.send({members: [memberChanged]})
.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.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
jsonResponse.members[0].name.should.equal(memberChanged.name);
jsonResponse.members[0].email.should.not.equal(memberChanged.email);
jsonResponse.members[0].email.should.equal(memberToChange.email);
});
});
});
it('Can destroy', function () {
const member = {
name: 'test',
email: 'memberTestDestroy@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.delete(localUtils.API.getApiQuery(`members/${newMember.id}`))
.set('Origin', config.get('url'))
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(204)
.then(() => newMember);
})
.then((newMember) => {
return request
.get(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(404);
});
});
it('Can export CSV', function () {
return request
.get(localUtils.API.getApiQuery(`members/csv/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /text\/csv/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
res.headers['content-disposition'].should.match(/Attachment;\sfilename="members/);
res.text.should.match(/id,email,name,created_at,deleted_at/);
res.text.should.match(/member1@test.com/);
res.text.should.match(/Mr Egg/);
});
});
it('Can import CSV', function () {
return request
.post(localUtils.API.getApiQuery(`members/csv/`))
.attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/valid-members-import.csv'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.meta);
should.exist(jsonResponse.meta.stats);
jsonResponse.meta.stats.imported.should.equal(2);
jsonResponse.meta.stats.duplicates.should.equal(0);
jsonResponse.meta.stats.invalid.should.equal(0);
});
});
});

View file

@ -19,7 +19,7 @@ var should = require('should'),
*/
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = 'bf8ffd57c6d35998dc0b17ef9d34f4cc';
const currentSchemaHash = '09ab28b8a18dc7f7f4fb5dbd5ccc10ac';
const currentFixturesHash = '9e3a7f71cab98f3fb8504d1f234b503d';
// If this test is failing, then it is likely a change has been made that requires a DB version bump,

View file

@ -0,0 +1,83 @@
const should = require('should');
const card = require('../../../../../server/lib/mobiledoc/cards/card-markdown');
const SimpleDom = require('simple-dom');
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
describe('Markdown card (v1 compatibility card)', function () {
it('renders', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n- list\r\n- items'
}
};
serializer.serialize(card.render(opts)).should.eql('<!--kg-card-begin: markdown--><h1 id="heading">HEADING</h1>\n<ul>\n<li>list</li>\n<li>items</li>\n</ul>\n<!--kg-card-end: markdown-->');
});
it('Accepts invalid HTML in markdown', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n<h2>Heading 2>'
}
};
serializer.serialize(card.render(opts)).should.eql('<!--kg-card-begin: markdown--><h1 id="heading">HEADING</h1>\n<h2>Heading 2><!--kg-card-end: markdown-->');
});
it('Renders nothing when payload is undefined', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: undefined
}
};
serializer.serialize(card.render(opts)).should.eql('');
});
it('[deprecated] version 1', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n- list\r\n- items'
},
options: {
version: 1
}
};
serializer.serialize(card.render(opts)).should.eql('<!--kg-card-begin: markdown--><div class="kg-card-markdown"><h1 id="heading">HEADING</h1>\n<ul>\n<li>list</li>\n<li>items</li>\n</ul>\n</div><!--kg-card-end: markdown-->');
});
it('transforms urls absolute to relative', function () {
let payload = {
markdown: 'A link to [an internal post](http://127.0.0.1:2369/post)'
};
const transformed = card.absoluteToRelative(payload, {});
transformed.markdown
.should.equal('A link to [an internal post](/post)');
});
it('transforms urls relative to absolute', function () {
let payload = {
markdown: 'A link to [an internal post](/post)'
};
const transformed = card.relativeToAbsolute(payload, {});
transformed.markdown
.should.equal('A link to [an internal post](http://127.0.0.1:2369/post)');
});
});

View file

@ -113,17 +113,15 @@ describe('Unit: models/settings', function () {
return models.Settings.populateDefaults()
.then(() => {
eventSpy.callCount.should.equal(80);
eventSpy.callCount.should.equal(82);
const eventsEmitted = eventSpy.args.map(args => args[0]);
const checkEventEmitted = event => should.ok(eventsEmitted.includes(event), `${event} event should be emitted`);
eventSpy.args[1][0].should.equal('settings.db_hash.added');
eventSpy.args[1][1].attributes.type.should.equal('core');
checkEventEmitted('settings.db_hash.added');
checkEventEmitted('settings.description.added');
eventSpy.args[13][0].should.equal('settings.description.added');
eventSpy.args[13][1].attributes.type.should.equal('blog');
eventSpy.args[13][1].attributes.value.should.equal('The professional publishing platform');
eventSpy.args[77][0].should.equal('settings.default_content_visibility.added');
eventSpy.args[79][0].should.equal('settings.members_subscription_settings.added');
checkEventEmitted('settings.default_content_visibility.added');
checkEventEmitted('settings.members_subscription_settings.added');
});
});
@ -137,7 +135,7 @@ describe('Unit: models/settings', function () {
return models.Settings.populateDefaults()
.then(() => {
eventSpy.callCount.should.equal(78);
eventSpy.callCount.should.equal(80);
eventSpy.args[13][0].should.equal('settings.logo.added');
});

View file

@ -1,6 +1,6 @@
{
"name": "ghost",
"version": "2.34.0",
"version": "2.35.0",
"description": "The professional publishing platform",
"author": "Ghost Foundation",
"homepage": "https://ghost.org",
@ -41,11 +41,11 @@
"dependencies": {
"@nexes/nql": "0.3.0",
"@tryghost/helpers": "1.1.15",
"@tryghost/members-api": "0.8.0",
"@tryghost/members-api": "0.8.2",
"@tryghost/members-ssr": "0.7.0",
"@tryghost/social-urls": "0.1.2",
"@tryghost/string": "^0.1.3",
"@tryghost/url-utils": "0.6.5",
"@tryghost/url-utils": "0.6.6",
"ajv": "6.10.2",
"amperize": "0.6.0",
"analytics-node": "3.3.0",

View file

@ -227,22 +227,22 @@
dependencies:
"@tryghost/kg-clean-basic-html" "^0.1.3"
"@tryghost/magic-link@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-0.2.1.tgz#c729bf5d2fe7fa1330eccbba51ba3579834784fc"
integrity sha512-bqlZndOXwU3b9FXvMtHIep1EradDnsfQ+4vvINQ+QsCOWKH1EDbPhjYS9f2G0xNx8BVyG4e1eMxZ5lBhJ6lBCA==
"@tryghost/magic-link@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-0.3.0.tgz#52e4326efba5756a39d219990d8fcbcbd3ba813a"
integrity sha512-Ahd4KG8Hz/zndRjx0MQt6cY8Lq8TL3UQEXbNIhEO1zOzgyq7FKGpBoSfaVzYb6aSx1pAG/EP60VuBKetHBmUSw==
dependencies:
bluebird "^3.5.5"
ghost-ignition "^3.1.0"
jsonwebtoken "^8.5.1"
lodash "^4.17.15"
"@tryghost/members-api@0.8.0":
version "0.8.0"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.8.0.tgz#e1f0b67371b6b61f6cf4f64e5b62c92ba3777c2a"
integrity sha512-THPd9HUyqo1WdroWFH8X+KcNfyy586dVSOYRQWIjF+URoYHmlrf2AlxBrdHMq5R8mQVCKIO0t3pmZwRnafucVA==
"@tryghost/members-api@0.8.2":
version "0.8.2"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.8.2.tgz#2d72cb80909c571fb2e6c9de868d438b5319e54d"
integrity sha512-maqhvyNpvw0kWG20UpSG0637YXteO5cc/ts+KWiSk4oIDuaH1sycgNXQ98q4Z+MAKzMye0iGoom7NcXQOFh8/Q==
dependencies:
"@tryghost/magic-link" "^0.2.1"
"@tryghost/magic-link" "^0.3.0"
bluebird "^3.5.4"
body-parser "^1.19.0"
cookies "^0.7.3"
@ -297,10 +297,10 @@
dependencies:
unidecode "^0.1.8"
"@tryghost/url-utils@0.6.5":
version "0.6.5"
resolved "https://registry.yarnpkg.com/@tryghost/url-utils/-/url-utils-0.6.5.tgz#a393e67e60daf265fd5d3e4ff6423bd0573bcd10"
integrity sha512-Ah6gmFo79a4p/lpzGC/EMwKgw5jEiURJAtaj3RSAiOaDF8VIAdjg7G4zO6CY8ShnMCCo7xizIGUMETHePRBgVA==
"@tryghost/url-utils@0.6.6":
version "0.6.6"
resolved "https://registry.yarnpkg.com/@tryghost/url-utils/-/url-utils-0.6.6.tgz#45fe815419df325cc07571c39739b04296a1cc09"
integrity sha512-U3JHMK5c2OVpuXHoJAOMjomYOVoyCY3ZGMYJ8yyby7eq2mMAHIBbwWsg4IkKuYKQ+/hGxK7DLGS7A3xb/Rxxbg==
dependencies:
cheerio "0.22.0"
moment "2.24.0"