Added staff service to manage email alert notifications

refs TryGhost/Team#1826

- adds new service package that manages all the email alert notifications for free members and paid subscriptions
- includes email templates for free member signup and paid subscription start/cancel
- initializes staff service before members to allow managing email alert notifications
- passes staff service to members api for triggering alerts
This commit is contained in:
Rishabh 2022-08-25 12:55:36 +05:30 committed by Rishabh Garg
parent effd5af615
commit 281d52610f
22 changed files with 1502 additions and 0 deletions

View File

@ -279,6 +279,7 @@ async function initServices({config}) {
const apiVersionCompatibility = require('./server/services/api-version-compatibility');
const scheduling = require('./server/adapters/scheduling');
const comments = require('./server/services/comments');
const staffService = require('./server/services/staff');
const memberAttribution = require('./server/services/member-attribution');
const urlUtils = require('./shared/url-utils');
@ -293,6 +294,7 @@ async function initServices({config}) {
await Promise.all([
memberAttribution.init(),
staffService.init(),
members.init(),
permissions.init(),
xmlrpc.listen(),

View File

@ -13,6 +13,7 @@ const SingleUseTokenProvider = require('./SingleUseTokenProvider');
const urlUtils = require('../../../shared/url-utils');
const labsService = require('../../../shared/labs');
const offersService = require('../offers');
const staffService = require('../staff');
const newslettersService = require('../newsletters');
const memberAttributionService = require('../member-attribution');
@ -197,6 +198,7 @@ function createApiInstance(config) {
},
stripeAPIService: stripeService.api,
offersAPI: offersService.api,
staffService: staffService.api,
labsService: labsService,
newslettersService: newslettersService,
memberAttributionService: memberAttributionService.service

View File

@ -0,0 +1,26 @@
class StaffServiceWrapper {
init() {
const StaffService = require('@tryghost/staff-service');
const config = require('../../../shared/config');
const logging = require('@tryghost/logging');
const models = require('../../models');
const {GhostMailer} = require('../mail');
const mailer = new GhostMailer();
const settingsCache = require('../../../shared/settings-cache');
const urlService = require('../url');
const urlUtils = require('../../../shared/url-utils');
this.api = new StaffService({
config,
logging,
models,
mailer,
settingsCache,
urlService,
urlUtils
});
}
}
module.exports = new StaffServiceWrapper();

View File

@ -84,6 +84,7 @@
"@tryghost/magic-link": "0.0.0",
"@tryghost/mailgun-client": "0.0.0",
"@tryghost/member-attribution": "0.0.0",
"@tryghost/staff-service": "0.0.0",
"@tryghost/member-events": "0.0.0",
"@tryghost/members-api": "0.0.0",
"@tryghost/members-csv": "0.0.0",

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View File

@ -0,0 +1,21 @@
# Staff Service
## Usage
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View File

@ -0,0 +1 @@
module.exports = require('./lib/staff-service');

View File

@ -0,0 +1,198 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>🥳 Free member signup: {{memberData.name}}</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;
}
table[class=body] p[class=small],
table[class=body] a[class=small] {
font-size: 11px !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;
}
a {
color: #15212A;
}
blockquote {
margin-left: 0;
padding-left: 20px;
border-left: 3px solid #DDE1E5;
}
</style>
</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: 540px; padding: 10px; width: 540px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED 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;"></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;">
<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; color: #15212A; font-weight: bold; line-height: 25px; margin: 0; margin-bottom: 15px;">Congratulations!</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; line-height: 25px; margin-bottom: 32px;">You have a <span style="font-weight: bold; color: #15212A;">new free member</span>.</p>
<table width="100" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F9F9FA; border-radius: 7px;">
<tbody>
<tr>
<td align="left" style="padding: 16px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 8px;">
<div style="width: 44px; height: 44px; background-color: #15171A; border-radius: 999px; 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: #FFFFFF; text-align: center; vertical-align: center; font-weight: 500; line-height: 42px;">
{{memberData.initials}}
</div>
</td>
<td style="padding-right: 8px;">
<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; padding-right: 8px; padding: 0; margin: 0; color: #15171A; font-weight: 600;">{{memberData.name}}</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: 13px; padding-right: 8px; padding: 0; margin: 0; color: #95A1AD;">Created on {{memberData.createdAt}}{{#if memberData.location}} &#8226; {{memberData.location}} {{/if}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
<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: {{accentColor}}; border-radius: 5px; text-align: center;"> <a href="{{memberData.adminUrl}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; 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: {{accentColor}};">View member</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<hr/>
<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; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 5px;">You can also copy & paste this URL into your browser:</p>
<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;">{{memberData.adminUrl}}</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>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</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!
You have a new free member: "${data.memberData.name}"
---
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

@ -0,0 +1,216 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>⚠️ Cancellation: {{memberData.name}}</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;
}
table[class=body] p[class=small],
table[class=body] a[class=small] {
font-size: 11px !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;
}
a {
color: #15212A;
}
blockquote {
margin-left: 0;
padding-left: 20px;
border-left: 3px solid #DDE1E5;
}
</style>
</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: 540px; padding: 10px; width: 540px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED 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;">{{subscriptionData.cancellationReason}}</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;">
<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; color: #15212A; font-weight: bold; line-height: 25px; 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; line-height: 25px; margin-bottom: 32px;">A paid member has just <span style="font-weight: bold; color: #15212A;">cancelled their subscription</span>.</p>
<table width="100" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F9F9FA; border-radius: 7px;">
<tbody>
<tr>
<td align="left" style="padding: 16px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 8px;">
<div style="width: 44px; height: 44px; background-color: #15171A; border-radius: 999px; 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: #FFFFFF; text-align: center; vertical-align: center; font-weight: 500; line-height: 42px;">
{{memberData.initials}}
</div>
</td>
<td style="padding-right: 8px;">
<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; padding-right: 8px; padding: 0; margin: 0; color: #15171A; font-weight: 600;">{{memberData.name}}</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: 13px; padding-right: 8px; padding: 0; margin: 0; color: #95A1AD;">Canceled on {{subscriptionData.canceledAt}} </p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="vertical-align: top; padding-top: 0; padding-right: 16px; padding-bottom: 16px; padding-left: 16px;">
<hr style="border-bottom: 2px solid #F4F4F5; margin-top: 0; margin-bottom: 16px;" />
<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: 13px; padding-right: 8px; padding: 0; margin: 0; color: #95A1AD;">Tier</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; padding-right: 8px; padding: 0; margin: 0; color: #15171A; font-weight: 600;">{{tierData.name}}<span style="font-weight: normal; color:#3A464C;"> - {{tierData.details}}</span></p>
</td>
</tr>
<tr>
<td style="vertical-align: top; padding-top: 0; padding-right: 16px; padding-bottom: 16px; padding-left: 16px;">
<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: 13px; padding-right: 8px; padding: 0; margin: 0; color: #95A1AD;">Subscription will expire on</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; padding-right: 8px; padding: 0; margin: 0; color: #15171A; font-weight: 600;">{{subscriptionData.expiryAt}}</p>
</td>
</tr>
<tr>
<td style="vertical-align: top; padding-top: 0; padding-right: 16px; padding-bottom: 16px; padding-left: 16px;">
<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: 13px; padding-right: 8px; padding: 0; margin: 0; color: #95A1AD;">Cancellation reason</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; padding-right: 8px; padding: 0; margin: 0; color: #15171A; font-weight: 600;">{{subscriptionData.cancellationReason}}</p>
</td>
</tr>
</tbody>
</table>
<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: {{accentColor}}; border-radius: 5px; text-align: center;"> <a href="{{memberData.adminUrl}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; 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: {{accentColor}};">View member</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<hr/>
<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; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 5px;">You can also copy & paste this URL into your browser:</p>
<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;">{{memberData.adminUrl}}</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>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</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 `
Hey there,
A paid member has just cancelled their subscription: "${data.memberData.name}"
---
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

@ -0,0 +1,214 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>💸 Paid subscription started: {{memberData.name}}</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;
}
table[class=body] p[class=small],
table[class=body] a[class=small] {
font-size: 11px !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;
}
a {
color: #15212A;
}
blockquote {
margin-left: 0;
padding-left: 20px;
border-left: 3px solid #DDE1E5;
}
</style>
</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: 540px; padding: 10px; width: 540px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED 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;">{{tierData.name}} - {{tierData.details}}</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;">
<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; color: #15212A; font-weight: bold; line-height: 25px; margin: 0; margin-bottom: 15px;">Congratulations!</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; line-height: 25px; margin-bottom: 32px;">You have a <span style="font-weight: bold; color: #15212A;">new paid member</span>.</p>
<table width="100" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F9F9FA; border-radius: 7px;">
<tbody>
<tr>
<td align="left" style="padding: 16px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 8px;">
<div style="width: 44px; height: 44px; background-color: #15171A; border-radius: 999px; 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: #FFFFFF; text-align: center; vertical-align: center; font-weight: 500; line-height: 42px;">
{{memberData.initials}}
</div>
</td>
<td style="padding-right: 8px;">
<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; padding-right: 8px; padding: 0; margin: 0; color: #15171A; font-weight: 600;">{{memberData.name}}</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: 13px; padding-right: 8px; padding: 0; margin: 0; color: #95A1AD;">Subscription started on {{subscriptionData.startedOn}} </p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="vertical-align: top; padding-top: 0; padding-right: 16px; padding-bottom: 16px; padding-left: 16px;">
<hr style="border-bottom: 2px solid #F4F4F5; margin-top: 0; margin-bottom: 16px;" />
<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: 13px; padding-right: 8px; padding: 0; margin: 0; color: #95A1AD;">Tier</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; padding-right: 8px; padding: 0; margin: 0; color: #15171A; font-weight: 600;">{{tierData.name}}
{{#if tierData.details}} <span style="font-weight: normal; color:#3A464C;"> - {{tierData.details}}</span>{{/if}}
</p>
</td>
</tr>
{{#if offerData}}
<tr>
<td style="vertical-align: top; padding-top: 0; padding-right: 16px; padding-bottom: 16px; padding-left: 16px;">
<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: 13px; padding-right: 8px; padding: 0; margin: 0; color: #95A1AD;">Offer</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; padding-right: 8px; padding: 0; margin: 0; color: #15171A; font-weight: 600;">{{offerData.name}} <span style="font-weight: normal; color:#3A464C;"> - {{offerData.details}}</span></p>
</td>
</tr>
{{/if}}
</tbody>
</table>
<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: {{accentColor}}; border-radius: 5px; text-align: center;"> <a href="{{memberData.adminUrl}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; 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: {{accentColor}};">View member</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<hr/>
<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; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 5px;">You can also copy & paste this URL into your browser:</p>
<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;">{{memberData.adminUrl}}</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>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</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!
You have a new paid member: "${data.memberData.name}"
---
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

@ -0,0 +1,291 @@
const {promises: fs} = require('fs');
const path = require('path');
const _ = require('lodash');
const moment = require('moment');
class StaffServiceEmails {
constructor({logging, models, mailer, settingsCache, urlUtils}) {
this.logging = logging;
this.models = models;
this.mailer = mailer;
this.settingsCache = settingsCache;
this.urlUtils = urlUtils;
this.Handlebars = require('handlebars');
}
async notifyFreeMemberSignup(member, options) {
const users = await this.models.User.getEmailAlertUsers('free-signup', options);
for (const user of users) {
const to = user.email;
const memberData = this.getMemberData(member);
const subject = `🥳 Free member signup: ${memberData.name}`;
const templateData = {
memberData,
siteTitle: this.settingsCache.get('title'),
siteUrl: this.urlUtils.getSiteUrl(),
siteDomain: this.siteDomain,
accentColor: this.settingsCache.get('accent_color'),
fromEmail: this.fromEmailAddress,
toEmail: to,
staffUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}`)
};
const {html, text} = await this.renderEmailTemplate('new-free-signup', templateData);
await this.sendMail({
to,
subject,
html,
text
});
}
}
async notifyPaidSubscriptionStarted({member, subscription, offer, tier}, options = {}) {
const users = await this.models.User.getEmailAlertUsers('paid-started', options);
for (const user of users) {
const to = user.email;
const memberData = this.getMemberData(member);
const subject = `💸 Paid subscription started: ${memberData.name}`;
const amount = this.getAmount(subscription?.plan_amount);
const formattedAmount = this.getFormattedAmount({currency: subscription?.plan_currency, amount});
const interval = subscription?.plan_interval || '';
const tierData = {
name: tier?.name || '',
details: `${formattedAmount}/${interval}`
};
const subscriptionData = {
startedOn: this.getFormattedDate(subscription.start_date)
};
let offerData = this.getOfferData(offer);
const templateData = {
memberData,
tierData,
offerData,
subscriptionData,
siteTitle: this.settingsCache.get('title'),
siteUrl: this.urlUtils.getSiteUrl(),
siteDomain: this.siteDomain,
accentColor: this.settingsCache.get('accent_color'),
fromEmail: this.fromEmailAddress,
toEmail: to,
staffUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}`)
};
const {html, text} = await this.renderEmailTemplate('new-paid-started', templateData);
await this.sendMail({
to,
subject,
html,
text
});
}
}
async notifyPaidSubscriptionCanceled({member, cancellationReason, tier, subscription}, options = {}) {
const users = await this.models.User.getEmailAlertUsers('paid-canceled', options);
const subscriptionPriceData = _.get(subscription, 'items.data[0].price');
for (const user of users) {
const to = user.email;
const memberData = this.getMemberData(member);
const subject = `⚠️ Cancellation: ${memberData.name}`;
const amount = this.getAmount(subscriptionPriceData?.unit_amount);
const formattedAmount = this.getFormattedAmount({currency: subscriptionPriceData?.currency, amount});
const interval = subscriptionPriceData?.recurring?.interval;
const tierDetail = `${formattedAmount}/${interval}`;
const tierData = {
name: tier?.name || '',
details: tierDetail
};
const subscriptionData = {
expiryAt: this.getFormattedStripeDate(subscription.cancel_at),
canceledAt: this.getFormattedStripeDate(subscription.canceled_at),
cancellationReason: cancellationReason || ''
};
const templateData = {
memberData,
tierData,
subscriptionData,
siteTitle: this.settingsCache.get('title'),
siteUrl: this.urlUtils.getSiteUrl(),
siteDomain: this.siteDomain,
accentColor: this.settingsCache.get('accent_color'),
fromEmail: this.fromEmailAddress,
toEmail: to,
staffUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}`)
};
const {html, text} = await this.renderEmailTemplate('new-paid-cancellation', templateData);
await this.sendMail({
to,
subject,
html,
text
});
}
}
// Utils
/** @private */
getMemberData(member) {
let name = member?.name || 'Anonymous';
return {
name: member?.name || member?.email,
adminUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/members/${member.id}`),
initials: this.extractInitials(name),
location: member.geolocation?.country || null,
createdAt: moment(member.created_at).format('D MMM YYYY')
};
}
/** @private */
getFormattedAmount({amount = 0, currency}) {
if (!currency) {
return '';
}
return Intl.NumberFormat('en', {
style: 'currency',
currency,
currencyDisplay: 'symbol'
}).format(amount);
}
/** @private */
getAmount(amount) {
if (!amount) {
return 0;
}
return amount / 100;
}
/** @private */
getFormattedDate(date) {
if (!date) {
return '';
}
return moment(date).format('D MMM YYYY');
}
/** @private */
getFormattedStripeDate(stripeDate) {
if (!stripeDate) {
return '';
}
const date = new Date(stripeDate * 1000);
return this.getFormattedDate(date);
}
/** @private */
getOfferData(offer) {
if (offer) {
let offAmount = '';
let offDuration = '';
if (offer.duration === 'once') {
offDuration = ', first payment';
} else if (offer.duration === 'repeating') {
offDuration = `, first ${offer.duration_in_months} months`;
} else if (offer.duration === 'forever') {
offDuration = `, forever`;
} else if (offer.duration === 'trial') {
offDuration = '';
}
if (offer.type === 'percent') {
offAmount = `${offer.amount}% off`;
} else if (offer.type === 'fixed') {
offAmount = `${this.getFormattedAmount({currency: offer.currency, amount: offer.amount})} off`;
} else if (offer.type === 'trial') {
offAmount = `${offer.amount} days free`;
}
return {
name: offer.name,
details: `${offAmount}${offDuration}`
};
}
}
get siteDomain() {
const [, siteDomain] = this.urlUtils.getSiteUrl()
.match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
return siteDomain;
}
get membersAddress() {
// TODO: get from address of default newsletter?
return `noreply@${this.siteDomain}`;
}
get fromEmailAddress() {
return `ghost@${this.siteDomain}`;
}
// TODO: duplicated from services/members/config - exrtact to settings?
get supportAddress() {
const supportAddress = this.settingsCache.get('members_support_address') || 'noreply';
// Any fromAddress without domain uses site domain, like default setting `noreply`
if (supportAddress.indexOf('@') < 0) {
return `${supportAddress}@${this.siteDomain}`;
}
return supportAddress;
}
get notificationFromAddress() {
return this.supportAddress || this.membersAddress;
}
extractInitials(name = '') {
const names = name.split(' ');
const initials = names.length > 1 ? [names[0][0], names[names.length - 1][0]] : [names[0][0]];
return initials.join('').toUpperCase();
}
async sendMail(message) {
if (process.env.NODE_ENV !== 'production') {
this.logging.warn(message.text);
}
let msg = Object.assign({
from: this.fromEmailAddress,
forceTextContent: true
}, message);
return this.mailer.send(msg);
}
async renderEmailTemplate(templateName, data) {
const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `${templateName}.hbs`), 'utf8');
const htmlTemplate = this.Handlebars.compile(Buffer.from(htmlTemplateSource).toString());
const textTemplate = require(`./email-templates/${templateName}.txt.js`);
const html = htmlTemplate(data);
const text = textTemplate(data);
return {html, text};
}
}
module.exports = StaffServiceEmails;

View File

@ -0,0 +1,46 @@
class StaffService {
constructor({logging, models, mailer, settingsCache, urlUtils}) {
/** @private */
this.models = models;
this.logging = logging;
/** @private */
this.settingsCache = settingsCache;
const Emails = require('./emails');
/** @private */
this.emails = new Emails({
logging,
models,
mailer,
settingsCache,
urlUtils
});
}
async notifyFreeMemberSignup(member, options) {
try {
await this.emails.notifyFreeMemberSignup(member, options);
} catch (e) {
this.logging.error(`Failed to notify free member signup - ${member?.id}`);
}
}
async notifyPaidSubscriptionStart({member, offer, tier, subscription}, options) {
try {
await this.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription}, options);
} catch (e) {
this.logging.error(`Failed to notify paid member subscription start - ${member?.id}`);
}
}
async notifyPaidSubscriptionCancel({member, cancellationReason, tier, subscription}, options) {
try {
await this.emails.notifyPaidSubscriptionCanceled({member, cancellationReason, tier, subscription}, options);
} catch (e) {
this.logging.error(`Failed to notify paid member subscription cancel - ${member?.id}`);
}
}
}
module.exports = StaffService;

View File

@ -0,0 +1,30 @@
{
"name": "@tryghost/staff-service",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/staff-service",
"author": "Ghost Foundation",
"private": true,
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"test": "yarn test:unit",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
},
"files": [
"index.js",
"lib"
],
"devDependencies": {
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
},
"dependencies": {
"lodash": "4.17.21",
"moment": "2.29.1",
"handlebars": "4.7.7"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -0,0 +1,10 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
'hello'.should.eql('hello');
});
});

View File

@ -0,0 +1,361 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
const sinon = require('sinon');
require('./utils');
const StaffService = require('../lib/staff-service');
function testCommonMailData({mailStub, getEmailAlertUsersStub}) {
getEmailAlertUsersStub.calledWith(
sinon.match.string,
sinon.match({transacting: {}, forUpdate: true})
).should.be.true();
// has right from/to address
mailStub.calledWith(sinon.match({
from: 'ghost@ghost.example',
to: 'owner@ghost.org'
})).should.be.true();
// Email HTML contains important bits
// Has accent color
mailStub.calledWith(
sinon.match.has('html', sinon.match('#ffffff'))
).should.be.true();
// Has member url
mailStub.calledWith(
sinon.match.has('html', sinon.match('https://admin.ghost.example/#/members/abc'))
).should.be.true();
// Has site url
mailStub.calledWith(
sinon.match.has('html', sinon.match('https://ghost.example'))
).should.be.true();
// Has staff admin url
mailStub.calledWith(
sinon.match.has('html', sinon.match('https://admin.ghost.example/#/settings/staff/ghost'))
).should.be.true();
}
function testCommonPaidSubMailData({mailStub, getEmailAlertUsersStub}) {
testCommonMailData({mailStub, getEmailAlertUsersStub});
getEmailAlertUsersStub.calledWith('paid-started').should.be.true();
mailStub.calledWith(
sinon.match({subject: '💸 Paid subscription started: Ghost'})
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('💸 Paid subscription started: Ghost'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Test Tier'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('$50.00/month'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Subscription started on 1 Aug 2022'))
).should.be.true();
}
function testCommonPaidSubCancelMailData({mailStub, getEmailAlertUsersStub}) {
testCommonMailData({mailStub, getEmailAlertUsersStub});
getEmailAlertUsersStub.calledWith('paid-canceled').should.be.true();
mailStub.calledWith(
sinon.match({subject: '⚠️ Cancellation: Ghost'})
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('⚠️ Cancellation: Ghost'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Test Tier'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('$50.00/month'))
).should.be.true();
}
describe('StaffService', function () {
describe('Constructor', function () {
it('doesn\'t throw', function () {
new StaffService({});
});
});
describe('email notifications:', function () {
let mailStub;
let getEmailAlertUsersStub;
let service;
let options = {
transacting: {},
forUpdate: true
};
let stubs;
beforeEach(function () {
mailStub = sinon.stub().resolves();
getEmailAlertUsersStub = sinon.stub().resolves([{
email: 'owner@ghost.org',
slug: 'ghost'
}]);
service = new StaffService({
logging: {
warn: () => {}
},
models: {
User: {
getEmailAlertUsers: getEmailAlertUsersStub
}
},
mailer: {
send: mailStub
},
settingsCache: {
get: (setting) => {
if (setting === 'title') {
return 'Ghost Site';
} else if (setting === 'accent_color') {
return '#ffffff';
}
return '';
}
},
urlUtils: {
getSiteUrl: () => {
return 'https://ghost.example';
},
urlJoin: (adminUrl,hash,path) => {
return `${adminUrl}/${hash}${path}`;
},
urlFor: () => {
return 'https://admin.ghost.example';
}
}
});
stubs = {mailStub, getEmailAlertUsersStub};
});
afterEach(function () {
sinon.restore();
});
describe('notifyFreeMemberSignup', function () {
it('sends free member signup alert', async function () {
const member = {
name: 'Ghost',
email: 'ghost@example.com',
id: 'abc',
geolocation: {country: 'France'},
created_at: '2022-08-01T07:30:39.882Z'
};
await service.notifyFreeMemberSignup(member, options);
mailStub.calledOnce.should.be.true();
testCommonMailData(stubs);
getEmailAlertUsersStub.calledWith('free-signup').should.be.true();
mailStub.calledWith(
sinon.match({subject: '🥳 Free member signup: Ghost'})
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('🥳 Free member signup: Ghost'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Created on 1 Aug 2022 &#8226; France'))
).should.be.true();
});
});
describe('notifyPaidSubscriptionStart', function () {
let member;
let tier;
let offer;
let subscription;
before(function () {
member = {
name: 'Ghost',
email: 'ghost@example.com',
id: 'abc',
geolocation: {country: 'France'},
created_at: '2022-08-01T07:30:39.882Z'
};
offer = {
name: 'Half price',
duration: 'once',
type: 'percent',
amount: 50
};
tier = {
name: 'Test Tier'
};
subscription = {
plan_amount: 5000,
plan_currency: 'USD',
plan_interval: 'month',
start_date: '2022-08-01T07:30:39.882Z'
};
});
it('sends paid subscription start alert without offer', async function () {
await service.notifyPaidSubscriptionStart({member, offer: null, tier, subscription}, options);
mailStub.calledOnce.should.be.true();
testCommonPaidSubMailData(stubs);
mailStub.calledWith(
sinon.match.has('html', 'Offer')
).should.be.false();
});
it('sends paid subscription start alert with percent offer - first payment', async function () {
await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options);
mailStub.calledOnce.should.be.true();
testCommonPaidSubMailData(stubs);
mailStub.calledWith(
sinon.match.has('html', sinon.match('Half price'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('50% off'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('first payment'))
).should.be.true();
});
it('sends paid subscription start alert with fixed type offer - repeating duration', async function () {
offer = {
name: 'Save ten',
duration: 'repeating',
duration_in_months: 3,
type: 'fixed',
currency: 'USD',
amount: 10
};
await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options);
mailStub.calledOnce.should.be.true();
testCommonPaidSubMailData(stubs);
mailStub.calledWith(
sinon.match.has('html', sinon.match('Save ten'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('$10.00 off'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('first 3 months'))
).should.be.true();
});
it('sends paid subscription start alert with fixed type offer - forever duration', async function () {
offer = {
name: 'Save twenty',
duration: 'forever',
type: 'fixed',
currency: 'USD',
amount: 20
};
await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options);
mailStub.calledOnce.should.be.true();
testCommonPaidSubMailData(stubs);
mailStub.calledWith(
sinon.match.has('html', sinon.match('Save twenty'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('$20.00 off'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('forever'))
).should.be.true();
});
it('sends paid subscription start alert with free trial offer', async function () {
offer = {
name: 'Free week',
duration: 'trial',
type: 'trial',
amount: 7
};
await service.notifyPaidSubscriptionStart({member, offer, tier, subscription}, options);
mailStub.calledOnce.should.be.true();
testCommonPaidSubMailData(stubs);
mailStub.calledWith(
sinon.match.has('html', sinon.match('Free week'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('7 days free'))
).should.be.true();
});
});
describe('notifyPaidSubscriptionCancel', function () {
let member;
let tier;
let subscription;
before(function () {
member = {
name: 'Ghost',
email: 'ghost@example.com',
id: 'abc',
geolocation: {country: 'France'},
created_at: '2022-08-01T07:30:39.882Z'
};
tier = {
name: 'Test Tier'
};
subscription = {
items: {
data: [{
price: {
unit_amount: 5000,
currency: 'USD',
recurring: {interval: 'month'}
}
}]
},
cancel_at: 1690875039,
canceled_at: 1659684639
};
});
it('sends paid subscription cancel alert', async function () {
await service.notifyPaidSubscriptionCancel({member, tier, subscription}, options);
mailStub.calledOnce.should.be.true();
testCommonPaidSubCancelMailData(stubs);
mailStub.calledWith(
sinon.match.has('html', sinon.match('Subscription will expire on'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('Canceled on 5 Aug 2022'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', sinon.match('1 Aug 2023'))
).should.be.true();
mailStub.calledWith(
sinon.match.has('html', 'Offer')
).should.be.false();
});
});
});
});

View File

@ -0,0 +1,11 @@
/**
* Custom Should Assertions
*
* Add any custom assertions to this file.
*/
// Example Assertion
// should.Assertion.add('ExampleAssertion', function () {
// this.params = {operator: 'to be a valid Example Assertion'};
// this.obj.should.be.an.Object;
// });

View File

@ -0,0 +1,11 @@
/**
* Test Utilities
*
* Shared utils for writing tests
*/
// Require overrides - these add globals for tests
require('./overrides');
// Require assertions - adds custom should assertions
require('./assertions');

View File

@ -0,0 +1,10 @@
// This file is required before any test is run
// Taken from the should wiki, this is how to make should global
// Should is a global in our eslint test config
global.should = require('should').noConflict();
should.extend();
// Sinon is a simple case
// Sinon is a global in our eslint test config
global.sinon = require('sinon');