Milestone emails templates and sending implementation (#16318)

refs
https://www.notion.so/ghost/Marketing-Milestone-email-campaigns-1d2c9dee3cfa4029863edb16092ad5c4?pvs=4

- Added email template for milestones with using a configuration file
for different member milestone values, as we're sending different
content for each one
- Implement sending the email to users who have
`milestone-notifications` enabled, currently still behind a flag

Co-authored-by: Peter Zimon <peter.zimon@gmail.com>
This commit is contained in:
Aileen Booker 2023-03-21 15:39:40 +02:00 committed by GitHub
parent be96c9d5d0
commit 8d290c4560
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 610 additions and 58 deletions

View File

@ -153,6 +153,19 @@
</div>
</GhUploader>
</div>
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Milestones</h4>
<p class="gh-expandable-description">
Occasional summaries of your audience & revenue growth
</p>
</div>
<div class="for-switch">
<GhFeatureFlag @flag="milestoneEmails" />
</div>
</div>
</div>
</div>
</div>
@ -226,19 +239,6 @@
</div>
</div>
</div>
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Milestone emails</h4>
<p class="gh-expandable-description">
Send emails for reaching specific milestones.
</p>
</div>
<div class="for-switch">
<GhFeatureFlag @flag="milestoneEmails" />
</div>
</div>
</div>
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>

View File

@ -10,7 +10,8 @@ const getStripeLiveEnabled = () => {
const stripeConnect = settingsCache.get('stripe_connect_publishable_key');
const stripeKey = settingsCache.get('stripe_publishable_key');
const stripeLiveRegex = /pk_live_/;
// Allow Stripe test key when in development mode
const stripeLiveRegex = process.env.NODE_ENV === 'development' ? /pk_test_/ : /pk_live_/;
if (stripeConnect && stripeConnect.match(stripeLiveRegex)) {
return true;
@ -84,6 +85,11 @@ module.exports = {
* @returns {Promise<object>}
*/
async scheduleRun(customTimeout) {
if (process.env.NODE_ENV === 'development') {
// Run the job within 5sec after boot when in local development mode
customTimeout = 5000;
}
const timeOut = customTimeout || JOB_TIMEOUT;
const today = new Date();

View File

@ -1,5 +1,4 @@
const sinon = require('sinon');
const assert = require('assert');
const staffService = require('../../../../../core/server/services/staff');
const DomainEvents = require('@tryghost/domain-events');
@ -10,8 +9,6 @@ const {SubscriptionCancelledEvent, MemberCreatedEvent, SubscriptionActivatedEven
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
describe('Staff Service:', function () {
let userModelStub;
before(function () {
models.init();
});
@ -19,7 +16,10 @@ describe('Staff Service:', function () {
beforeEach(function () {
mockManager.mockMail();
mockManager.mockSlack();
userModelStub = sinon.stub(models.User, 'getEmailAlertUsers').resolves([{
mockManager.mockSetting('title', 'The Weekly Roundup');
mockManager.mockLabsEnabled('milestoneEmails');
sinon.stub(models.User, 'getEmailAlertUsers').resolves([{
email: 'owner@ghost.org',
slug: 'ghost'
}]);
@ -232,22 +232,13 @@ describe('Staff Service:', function () {
});
describe('milestone created event:', function () {
beforeEach(function () {
mockManager.mockLabsEnabled('milestoneEmails');
});
afterEach(async function () {
sinon.restore();
mockManager.restore();
});
it('logs when milestone event is handled', async function () {
it('sends email for achieved milestone', async function () {
await staffService.init();
DomainEvents.dispatch(MilestoneCreatedEvent.create({
milestone: {
type: 'arr',
currency: 'usd',
value: 100,
value: 1000,
createdAt: new Date(),
emailSentAt: new Date()
},
@ -258,8 +249,50 @@ describe('Staff Service:', function () {
// Wait for the dispatched events (because this happens async)
await DomainEvents.allSettled();
const [userCalls] = userModelStub.args[0];
assert.equal(userCalls, ['milestone-received']);
mockManager.assert.sentEmailCount(1);
mockManager.assert.sentEmail({
to: 'owner@ghost.org',
subject: /The Weekly Roundup hit \$1,000 ARR/
});
});
it('does not send email when no email created at provided or a reason is set', async function () {
DomainEvents.dispatch(MilestoneCreatedEvent.create({
milestone: {
type: 'arr',
currency: 'usd',
value: 1000,
createdAt: new Date(),
emailSentAt: null
},
meta: {
currentValue: 105
}
}));
// Wait for the dispatched events (because this happens async)
await DomainEvents.allSettled();
DomainEvents.dispatch(MilestoneCreatedEvent.create({
milestone: {
type: 'arr',
currency: 'usd',
value: 1000,
createdAt: new Date(),
emailSentAt: new Date(),
meta: {
currentValue: 105,
reason: 'import'
}
}
}));
// Wait for the dispatched events (because this happens async)
await DomainEvents.allSettled();
mockManager.assert.sentEmailCount(0);
});
});
});

View File

@ -0,0 +1,142 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>{{subject}}</title>
{{> styles}}
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
</head>
<body style="background-color: #ffffff; 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.5em; 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%;">
<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: 660px; padding: 10px; width: 660px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 620px; padding: 30px 20px;">
<!-- START CENTERED CONTAINER -->
{{#> preview}}
{{#*inline "content"}}
{{{heading}}}
{{/inline}}
{{/preview}}
<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" align="center" valign="top" 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;">
<!-- START LOGO -->
<table class="row" style="border-collapse:collapse;border-spacing:0;display:table;padding:0;position:relative;text-align:left;vertical-align:top;width:100%">
<tbody>
<tr style="padding:0;text-align:left;vertical-align:top">
<th style="margin:0 auto;padding-bottom:48px;padding-left:0;padding-right:0;padding-top:40px;text-align:left;width:580px">
<center data-parsed="" style="min-width:580px; width:100%">
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;border-spacing:0;display:table;padding:0;position:relative;text-align:left;vertical-align:top;width:60px; height: 60px;">
<tr style="padding: 0; margin: 0;">
<td background="https://static.ghost.org/v5.0.0/images/orb-email-head-static.png" width="60" height="60" style="width: 60px; height: 60px; padding: 0; margin: 0;"><img class="blog-logo" src="https://static.ghost.org/v5.0.0/images/orb-email-head.gif" alt="Blog Logo" width="60" height="60" style="-ms-interpolation-mode:bicubic;Margin:0 auto;border-radius:50%;clear:both;display:block;float:none;height:60px!important;margin:0 auto;max-width:100%;outline:0;text-align:center;text-decoration:none;width:60px!important" align="center"></td>
</tr>
</table>
</center>
</th>
</tr>
</tbody>
</table>
<!-- END LOGO -->
<!-- START FEATURE IMAGE -->
<table class="row" style="border-collapse:collapse;border-spacing:0;display:table;padding:0;position:relative;text-align:left;vertical-align:top;width:100%">
<tbody>
<tr style="padding:0;text-align:left;vertical-align:top">
<th style="margin:0 auto; padding:0; padding-bottom: 48px; padding-left:0; padding-right:0; width:580px">
<center data-parsed="" style="min-width:580px;width:100%; padding: 0;">
<img class="feature-post-image" src="{{image.url}}" width="580"{{#if image.height}} height="{{image.height}}"{{/if}} align="center" style="-ms-interpolation-mode:bicubic;Margin:0 auto;border:0 none;clear:both;display:block;float:none;height:auto!important;margin:0 auto;max-width:100%;outline:0;text-align:center;text-decoration:none;width:100%!important;z-index:99;padding:0;"></center>
</th>
</tr>
</tbody>
</table>
<!-- END FEATURE IMAGE -->
<!-- END HEADER -->
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; max-width: 580px;">
<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;">
<!-- BEGIN EMAIL HEADING -->
<!--[if mso]>
<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: 34px; color: #242628!important; font-weight: 800; line-height: 36px; margin: 0; margin-bottom: 25px; letter-spacing: -0.019em;"><font>{{{heading}}}</font></p>
<![endif]-->
<!--[if !mso !vml]-->
<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: 34px !important; color: #242628!important; font-weight: 800; line-height: 36px; margin: 0; margin-bottom: 25px; letter-spacing: -0.019em;"><font>{{{heading}}}</font></p>
<!--[endif]-->
<!-- END EMAIL HEADING -->
<!-- BEGIN EMAIL CONTENT -->
{{#each content as |contentPart|}}
<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 !important; color: #3A464C; font-weight: normal; margin: 0; line-height: 23px; margin-bottom: 16px;">{{{contentPart}}}</p>
{{/each}}
<!-- END EMAIL CONTENT -->
<!-- CTA -->
<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-top: 32px; padding-bottom: 12px;">
<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: #15171A; border-radius: 5px; text-align: center;"> <a href="{{adminUrl}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #15171A; border: solid 1px #15171A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px !important; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #15171A;">{{ctaText}}</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p style="word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 25px; margin-top:0; color: #3A464C;"></p>
</td>
</tr>
<!-- START FOOTER -->
<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; padding-top: 80px;">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0; margin-bottom: 2px;">This message was sent from <a class="small" href="{{siteUrl}}" style="text-decoration: underline; color: #738A94; font-size: 11px;">{{siteDomain}}</a> to <a class="small" href="mailto:{{toEmail}}" style="text-decoration: underline; color: #738A94; font-size: 11px;">{{toEmail}}</a></p>
</td>
</tr>
<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; padding-top: 2px">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0; margin-bottom: 2px;">Dont want to receive these emails? Manage your preferences <a class="small" href="{{staffUrl}}" style="text-decoration: underline; color: #738A94; font-size: 11px;">here</a>.</p>
</td>
</tr>
<!-- END FOOTER -->
</table>
<!-- END MAIN CONTENT AREA -->
</td>
</tr>
</table>
<!-- END CENTERED 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,13 @@
module.exports = function (data) {
// Be careful when you indent the email, because whitespaces are visible in emails!
return `
Congratulations!
${data.subject}
---
Sent to ${data.toEmail} from ${data.siteDomain}.
If you would no longer like to receive these notifications you can adjust your settings at ${data.staffUrl}.
`;
};

View File

@ -187,13 +187,57 @@ class StaffServiceEmails {
* @returns {Promise<void>}
*/
async notifyMilestoneReceived({milestone}) {
if (!milestone?.emailSentAt || milestone?.meta?.reason) {
// Do not send an email when no email was set to be sent or a reason
// not to send provided
return;
}
const formattedValue = this.getFormattedAmount({currency: milestone?.currency, amount: milestone.value, maximumFractionDigits: 0});
const milestoneEmailConfig = require('./milestone-email-config')(this.settingsCache.get('title'), formattedValue);
const emailData = milestoneEmailConfig?.[milestone.type]?.[milestone.value];
if (!emailData || Object.keys(emailData).length === 0) {
// Do not attempt to send an email with invalid or missing data
this.logging.warn('No Milestone email sent. Invalid or missing data.');
return;
}
const emailPromises = [];
const users = await this.models.User.getEmailAlertUsers('milestone-received');
// TODO: send email with correct templates
for (const user of users) {
const to = user.email;
this.logging.info(`Will send email to ${to} for ${milestone.type} / ${milestone.value} milestone.`);
const templateData = {
siteTitle: this.settingsCache.get('title'),
siteUrl: this.urlUtils.getSiteUrl(),
siteDomain: this.siteDomain,
fromEmail: this.fromEmailAddress,
...emailData,
partial: `milestones/${milestone.value}`,
toEmail: to,
adminUrl: this.urlUtils.urlFor('admin', true),
staffUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}`)
};
const {html, text} = await this.renderEmailTemplate('new-milestone-received', templateData);
emailPromises.push(await this.sendMail({
to,
subject: emailData.subject,
html,
text
}));
}
const results = await Promise.allSettled(emailPromises);
for (const result of results) {
if (result.status === 'rejected') {
this.logging.warn(result?.reason);
}
}
}
@ -228,15 +272,18 @@ class StaffServiceEmails {
}
/** @private */
getFormattedAmount({amount = 0, currency}) {
getFormattedAmount({amount = 0, currency, maximumFractionDigits = 2}) {
if (!currency) {
return amount > 0 ? Intl.NumberFormat().format(amount) : '';
return amount > 0 ? Intl.NumberFormat('en', {maximumFractionDigits}).format(amount) : '';
}
return Intl.NumberFormat('en', {
style: 'currency',
currency,
currencyDisplay: 'symbol'
currencyDisplay: 'symbol',
maximumFractionDigits,
// see https://github.com/andyearnshaw/Intl.js/issues/123
minimumFractionDigits: maximumFractionDigits
}).format(amount);
}

View File

@ -0,0 +1,207 @@
/**
*
* @param {string} siteTitle
* @param {string} formattedValue
*
* @returns {Object.<string, object>}
*/
const milestoneEmailConfig = (siteTitle, formattedValue) => {
const arrContent = {
subject: `${siteTitle} hit ${formattedValue} ARR`,
heading: `Congrats! You reached ${formattedValue} ARR`,
content: [
`<strong>${siteTitle}</strong> is now generating <strong>${formattedValue}</strong> in annual recurring revenue. Congratulations &mdash; this is a significant milestone.`,
'Subscription revenue is predictable and sustainable, meaning you can keep focusing on delivering great content while watching your business grow. Keep up the great work. See you at the next milestone!'
],
ctaText: 'Login to your dashboard'
};
return {
// For ARR we use the same content and only the image changes
// Should we start to support different currencies, we'll need
// to update the structure for ARR content to reflect that.
arr: {
100: {
...arrContent,
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-100.png',
height: 348
}
},
1000: {
...arrContent,
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-1000.png',
height: 348
}
},
10000: {
...arrContent,
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-10k.png',
height: 348
}
},
50000: {
...arrContent,
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-50k.png',
height: 348
}
},
100000: {
...arrContent,
heading: `Congrats! You reached $100k ARR`,
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-100k.png',
height: 348
}
},
250000: {
...arrContent,
heading: `Congrats! You reached $250k ARR`,
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-250k.png',
height: 348
}
},
500000: {
...arrContent,
heading: `Congrats! You reached $500k ARR`,
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-500k.png',
height: 348
}
},
1000000: {
...arrContent,
heading: `Congrats! You reached $1m ARR`,
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-1m.png',
height: 348
}
}
},
members: {
100: {
subject: `${siteTitle} has ${formattedValue} members 🤗`,
heading: `Milestone achieved: ${formattedValue} signups`,
content: [
'All the hard work in getting your publication up and running paid off, and your work has since gone on to inspire more than <strong>100 people</strong> to sign up. This is the first major milestone in growing an online audience, and youve made it here!',
'So whats next?',
'If you keep up the great work youll be well on your way to growing an even bigger audience. In the meantime, heres some actionable advice about <strong><a href="https://ghost.org/resources/first-1000-email-subscribers/">how to reach the next major milestones</a></strong>.',
'You got this!'
],
ctaText: 'Login to your dashboard',
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-100.png'
}
},
1000: {
subject: `${siteTitle} now has ${formattedValue} members`,
heading: `You have ${formattedValue} true fans`,
content: [
`Congrats, <strong>${siteTitle}</strong> has officially reached <strong>${formattedValue} member signups</strong>.`,
'This is such an impressive milestone and according to Kevin Kellys true fan <a href="https://kk.org/thetechnium/1000-true-fans/">theory</a>, it means you now have a direct relationship with enough people to run a truly independent creator business online.',
`Imagine ${formattedValue} people all in one room at the same time. That's a lot of people. It's also how many people are happy that you show up to create your work. Very cool. Keep up the great work!`
],
ctaText: 'See your member stats',
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-1000.png'
}
},
10000: {
subject: `${siteTitle} now has 10k members`,
heading: 'Huge success: 10k members',
content: [
`There are now <strong>10k people</strong> who enjoy <strong>${siteTitle}</strong> so much they decided to sign up as members.`,
'Building an audience of any size as an independent creator requires dedication, and reaching this incredible milestone is an impressive feat worth celebrating. Theres no stopping you now, keep up the great work!'
],
ctaText: 'Go to your dashboard',
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-10k.png'
}
},
25000: {
subject: `${siteTitle} now has 25k members`,
heading: `Celebrating ${formattedValue} signups`,
content: [
'Congrats, <strong>25k people</strong> have chosen to support and follow your work. Thats an audience big enough to sell out Madison Square Garden. What an incredible milestone!',
'It takes a lot of work and dedication to build an audience as an independent creator, so heres to recognizing what youve achieved.',
'Keep up the great work!'
],
ctaText: 'View your dashboard',
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-25k.png'
}
},
50000: {
subject: `${siteTitle} now has 50k members`,
heading: `${formattedValue} people love your work`,
content: [
`It's time to pop the champagne because <strong>${siteTitle}</strong> has officially reached <strong>50k members</strong>. At this rate of growth you can almost fill a Superbowl stadium 🏈`,
'Building an audience of this size is an incredible achievement, so hats off to you. Keep up the amazing work.',
'See you at the next milestone!'
],
ctaText: 'Go to your Dashboard',
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-50k.png'
}
},
100000: {
subject: `${siteTitle} just hit 100k members!`,
heading: `You just reached ${formattedValue} members`,
content: [
'Congratulations &mdash; your work has attracted an audience of <strong>100k people</strong> from around the world. Fun fact: Your audience is now big enough to fill any of the largest stadiums in the United States.',
'Whatever youre doing, its working. The sky is the limit from here. Keep up the great work (but first, go and celebrate this impressive milestone, you earned it).'
],
ctaText: 'Go to your dashboard',
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-100k.png'
}
},
250000: {
subject: `${siteTitle} now has 250k members`,
heading: 'Celebrating 250k member signups',
content: [
`One-quarter of a million people enjoy and support <strong>${siteTitle}</strong>. Thats the same number of people who make up the crowds at the SXSW festival.`,
'Youre officially in the top 5% of creators using Ghost 🚀',
'Reaching this milestone is no easy feat, so make sure you take some time to recognize how far youve come.',
'Keep up the amazing work!'
],
ctaText: 'Go to your dashboard',
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-250k.png'
}
},
500000: {
subject: `${siteTitle} has ${formattedValue} members`,
heading: `Half a million members!`,
content: [
`Congrats, <strong>${siteTitle}</strong> has officially attracted an audience of more than <strong>${formattedValue} people</strong>, and counting.`,
'Youre officially in the top 3% of creators using Ghost. ',
'It takes a huge amount of hard work and dedication to build an audience of this size. It is a testament to how much value your work is providing to thousands of people all over the world. Keep up the great work, and make sure to take the time to celebrate this incredible milestone.'
],
ctaText: 'Login to your dashboard',
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-500k.png'
}
},
1000000: {
subject: `${siteTitle} has 1 million members`,
heading: `You did it. 1 million members 🏆`,
content: [
`Start writing your acceptance speech! The <strong>${siteTitle}</strong> audience is now officially big enough to headline an event at the Copacabana, with more than <strong>1 million members</strong>. That puts you in the top 1% of creators using Ghost.`,
'In all seriousness, this is an <em>incredible</em> achievement and something to be very proud of. You deserve all the credit as a truly independent creator.',
'Keep it up, youre creating amazing value in the world!'
],
ctaText: 'Go to your dashboard',
image: {
url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-1m.png'
}
}
}
};
};
module.exports = milestoneEmailConfig;

View File

@ -111,7 +111,7 @@ describe('StaffService', function () {
describe('email notifications:', function () {
let mailStub;
let loggingInfoStub;
let loggingWarningStub;
let subscribeStub;
let getEmailAlertUsersStub;
let service;
@ -120,6 +120,14 @@ describe('StaffService', function () {
forUpdate: true
};
let stubs;
let labs = {
isSet: (flag) => {
if (flag === 'milestoneEmails') {
return true;
}
return false;
}
};
const settingsCache = {
get: (setting) => {
@ -151,7 +159,7 @@ describe('StaffService', function () {
};
beforeEach(function () {
loggingInfoStub = sinon.stub().resolves();
loggingWarningStub = sinon.stub().resolves();
mailStub = sinon.stub().resolves();
subscribeStub = sinon.stub().resolves();
getEmailAlertUsersStub = sinon.stub().resolves([{
@ -160,8 +168,7 @@ describe('StaffService', function () {
}]);
service = new StaffService({
logging: {
info: loggingInfoStub,
warn: () => {},
warn: loggingWarningStub,
error: () => {}
},
models: {
@ -177,7 +184,8 @@ describe('StaffService', function () {
},
settingsCache,
urlUtils,
settingsHelpers
settingsHelpers,
labs
});
stubs = {mailStub, getEmailAlertUsersStub};
});
@ -198,7 +206,6 @@ describe('StaffService', function () {
it('listens to events', async function () {
service = new StaffService({
logging: {
info: loggingInfoStub,
warn: () => {},
error: () => {}
},
@ -257,8 +264,6 @@ describe('StaffService', function () {
describe('handleEvent', function () {
beforeEach(function () {
loggingInfoStub = sinon.stub().resolves();
const models = {
User: {
getEmailAlertUsers: sinon.stub().resolves([{
@ -322,7 +327,6 @@ describe('StaffService', function () {
service = new StaffService({
logging: {
info: loggingInfoStub,
warn: () => {},
error: () => {}
},
@ -338,12 +342,6 @@ describe('StaffService', function () {
settingsHelpers,
labs: {
isSet: (flag) => {
if (flag === 'webmentions') {
return true;
}
if (flag === 'webmentionEmails') {
return true;
}
if (flag === 'milestoneEmails') {
return true;
}
@ -401,14 +399,15 @@ describe('StaffService', function () {
data: {
milestone: {
type: 'arr',
value: '100',
currency: 'usd'
value: '1000',
currency: 'usd',
emailSentAt: Date.now()
}
}
});
mailStub.called.should.be.false();
loggingInfoStub.calledOnce.should.be.true();
loggingInfoStub.calledWith('Will send email to owner@ghost.org for arr / 100 milestone.').should.be.true();
mailStub.calledWith(
sinon.match({subject: `Ghost Site hit $1,000 ARR`})
).should.be.true();
});
});
@ -798,7 +797,78 @@ describe('StaffService', function () {
});
describe('notifyMilestoneReceived', function () {
it('prepares to send email when user setting available', async function () {
it('send Members milestone email', async function () {
const milestone = {
type: 'members',
value: 25000,
emailSentAt: Date.now()
};
await service.emails.notifyMilestoneReceived({milestone});
getEmailAlertUsersStub.calledWith('milestone-received').should.be.true();
mailStub.calledOnce.should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Ghost Site now has 25k members'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Celebrating 25,000 signups'))
).should.be.true();
// Correct image and NO height for Members milestone
mailStub.calledWith(
sinon.match.has('html', sinon.match('src="https://static.ghost.org/v5.0.0/images/milestone-email-members-25k.png" width="580" align="center"'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Congrats, <strong>25k people</strong> have chosen to support and follow your work. Thats an audience big enough to sell out Madison Square Garden. What an incredible milestone!'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('View your dashboard'))
).should.be.true();
});
it('send ARR milestone email', async function () {
const milestone = {
type: 'arr',
value: 500000,
currency: 'usd',
emailSentAt: Date.now()
};
await service.emails.notifyMilestoneReceived({milestone});
getEmailAlertUsersStub.calledWith('milestone-received').should.be.true();
mailStub.calledOnce.should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Ghost Site hit $500,000 ARR'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Congrats! You reached $500k ARR'))
).should.be.true();
// Correct image and height for ARR milestone
mailStub.calledWith(
sinon.match.has('html', sinon.match('src="https://static.ghost.org/v5.0.0/images/milestone-email-usd-500k.png" width="580" height="348" align="center"'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('<strong>Ghost Site</strong> is now generating <strong>$500,000</strong> in annual recurring revenue. Congratulations &mdash; this is a significant milestone.'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Login to your dashboard'))
).should.be.true();
});
it('does not send email when no date provided', async function () {
const milestone = {
type: 'members',
value: 25000
@ -806,8 +876,42 @@ describe('StaffService', function () {
await service.emails.notifyMilestoneReceived({milestone});
getEmailAlertUsersStub.calledWith('milestone-received').should.be.false();
mailStub.called.should.be.false();
});
it('does not send email when a reason not to send email was provided', async function () {
const milestone = {
type: 'members',
value: 25000,
emailSentAt: Date.now(),
meta: {
reason: 'no-email'
}
};
await service.emails.notifyMilestoneReceived({milestone});
getEmailAlertUsersStub.calledWith('milestone-received').should.be.false();
mailStub.called.should.be.false();
});
it('does not send email for a milestone without correct content', async function () {
const milestone = {
type: 'members',
value: 5000, // milestone not configured
emailSentAt: Date.now()
};
await service.emails.notifyMilestoneReceived({milestone});
getEmailAlertUsersStub.calledWith('milestone-received').should.be.false();
loggingWarningStub.calledOnce.should.be.true();
mailStub.called.should.be.false();
getEmailAlertUsersStub.calledWith('milestone-received').should.be.true();
});
});
});