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:
parent
effd5af615
commit
281d52610f
@ -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(),
|
||||
|
@ -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
|
||||
|
26
ghost/core/core/server/services/staff/index.js
Normal file
26
ghost/core/core/server/services/staff/index.js
Normal 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();
|
@ -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",
|
||||
|
6
ghost/staff-service/.eslintrc.js
Normal file
6
ghost/staff-service/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
21
ghost/staff-service/README.md
Normal file
21
ghost/staff-service/README.md
Normal 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
|
||||
|
1
ghost/staff-service/index.js
Normal file
1
ghost/staff-service/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./lib/staff-service');
|
198
ghost/staff-service/lib/email-templates/new-free-signup.hbs
Normal file
198
ghost/staff-service/lib/email-templates/new-free-signup.hbs
Normal 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;"> </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}} • {{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;">Don’t 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;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
@ -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}.
|
||||
`;
|
||||
};
|
@ -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;"> </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;">Don’t 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;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
@ -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}.
|
||||
`;
|
||||
};
|
214
ghost/staff-service/lib/email-templates/new-paid-started.hbs
Normal file
214
ghost/staff-service/lib/email-templates/new-paid-started.hbs
Normal 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;"> </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;">Don’t 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;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
@ -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}.
|
||||
`;
|
||||
};
|
291
ghost/staff-service/lib/emails.js
Normal file
291
ghost/staff-service/lib/emails.js
Normal 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;
|
46
ghost/staff-service/lib/staff-service.js
Normal file
46
ghost/staff-service/lib/staff-service.js
Normal 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;
|
30
ghost/staff-service/package.json
Normal file
30
ghost/staff-service/package.json
Normal 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"
|
||||
}
|
||||
}
|
6
ghost/staff-service/test/.eslintrc.js
Normal file
6
ghost/staff-service/test/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
10
ghost/staff-service/test/hello.test.js
Normal file
10
ghost/staff-service/test/hello.test.js
Normal 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');
|
||||
});
|
||||
});
|
361
ghost/staff-service/test/staff-service.test.js
Normal file
361
ghost/staff-service/test/staff-service.test.js
Normal 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 • 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
11
ghost/staff-service/test/utils/assertions.js
Normal file
11
ghost/staff-service/test/utils/assertions.js
Normal 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;
|
||||
// });
|
11
ghost/staff-service/test/utils/index.js
Normal file
11
ghost/staff-service/test/utils/index.js
Normal 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');
|
10
ghost/staff-service/test/utils/overrides.js
Normal file
10
ghost/staff-service/test/utils/overrides.js
Normal 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');
|
Loading…
Reference in New Issue
Block a user