0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Added subscription status text in newsletters (#16442)

fixes https://github.com/TryGhost/Team/issues/2736

Shows the actual subscription status (expires on DD MMM YYYY) in every
email when show subscription details is enabled.
This commit is contained in:
Simon Backx 2023-03-22 11:52:41 +01:00 committed by GitHub
parent c28b4c61d2
commit bc0126c54e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 1121 additions and 29 deletions

View file

@ -6003,6 +6003,597 @@ member-uuid",
}
`;
exports[`Batch sending tests Newsletter settings Shows subscription details box 1 1`] = `
Object {
"html": "<!doctype html>
<html>
<head>
<meta name=\\"viewport\\" content=\\"width=device-width\\">
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]-->
<title>This is a test post title</title>
<style>
.post-title-link {
color: #15212A;
display: block;
text-align: center;
margin-top: 50px;
}
.post-title-link-left {
text-align: left;
}
.comment-link {
color: #738a94;
font-size: 13px;
letter-spacing: 0.1px;
}
.comment-link img {
width: 16px;
height: 16px;
margin-bottom: 1px;
vertical-align: middle;
}
.view-online-link {
word-wrap: none;
white-space: nowrap;
color: #738a94;
}
.kg-nft-link {
display: block;
text-decoration: none !important;
color: #15212A !important;
font-family: inherit !important;
font-size: 14px;
line-height: 1.3em;
padding-top: 4px;
padding-right: 20px;
padding-left: 20px;
padding-bottom: 4px;
}
.kg-twitter-link {
display: block;
text-decoration: none !important;
color: #15212A !important;
font-family: inherit !important;
font-size: 15px;
padding: 8px;
line-height: 1.3em;
}
@media only screen and (max-width: 620px) {
table.body {
width: 100%;
min-width: 100%;
}
table.body p,
table.body ul,
table.body ol,
table.body td,
table.body span {
font-size: 16px !important;
}
table.body pre {
white-space: pre-wrap !important;
word-break: break-word !important;
}
table.body .content {
padding: 0 !important;
}
table.body .container {
padding: 0 !important;
width: 100% !important;
}
table.body .main {
border-spacing: 10px 0 !important;
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table.body .btn table {
width: 100% !important;
}
table.body .btn a {
width: 100% !important;
}
table.body .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
table.body .site-icon img {
width: 40px !important;
height: 40px !important;
}
table.body .site-url a {
font-size: 14px !important;
padding-bottom: 15px !important;
}
table.body .post-meta,
table.body .post-meta-date {
white-space: normal !important;
font-size: 12px !important;
line-height: 1.5em;
}
table.body .post-meta,
table.body .view-online {
display: inline-block;
}
table.body .post-meta.author-date {
width: 100%;
}
table.body .view-online {
text-decoration: underline;
}
table.body .view-online-link,
table.body .comment-link,
table.body .footer,
table.body .footer a {
font-size: 12px !important;
}
table.body .post-title a {
font-size: 32px !important;
line-height: 1.15em !important;
}
table.feedback-buttons {
display: none !important;
}
table.feedback-buttons-mobile {
display: table !important;
width: 100% !important;
max-width: 390px;
}
table.body .feedback-button-mobile-text {
font-size: 13px !important;
}
table.body .subscription-box {
padding: 24px 20px !important;
}
table.body .subscription-box h3 {
font-size: 14px !important;
}
table.body .subscription-box p,
table.body .subscription-box p span {
font-size: 13px !important;
}
table.body .subscription-details,
table.body .manage-subscription {
display: inline-block;
width: 100%;
text-align: left !important;
font-size: 13px !important;
}
table.body .subscription-details {
padding-bottom: 12px;
}
table.body .kg-bookmark-card {
width: 90vw;
}
table.body .kg-bookmark-thumbnail {
display: none !important;
}
table.body .kg-bookmark-metadata span {
font-size: 13px !important;
}
table.body .kg-embed-card {
max-width: 90vw !important;
}
table.body h1 {
font-size: 32px !important;
line-height: 1.3em !important;
}
table.body h2 {
font-size: 26px !important;
line-height: 1.22em !important;
}
table.body h3 {
font-size: 21px !important;
line-height: 1.25em !important;
}
table.body h4 {
font-size: 19px !important;
line-height: 1.3em !important;
}
table.body h5 {
font-size: 16px !important;
line-height: 1.4em !important;
}
table.body h6 {
font-size: 16px !important;
line-height: 1.4em !important;
}
table.body blockquote {
font-size: 17px;
line-height: 1.6em;
margin-bottom: 0;
padding-left: 15px;
}
table.body blockquote.kg-blockquote-alt {
border-left: 0 none !important;
margin: 0 0 2.5em 0 !important;
padding: 0 50px 0 50px !important;
font-size: 1.2em;
}
table.body blockquote + * {
margin-top: 1.5em !important;
}
table.body hr {
margin: 2em 0 !important;
}
table.body figcaption,
table.body figcaption a {
font-size: 13px !important;
}
}
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body style=\\"background-color: #fff; font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; -webkit-font-smoothing: antialiased; font-size: 18px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #15212A;\\">
<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;\\">Hello world</span>
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"body\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #fff; width: 100%;\\" bgcolor=\\"#fff\\">
<!-- Outlook doesn't respect max-width so we need an extra centered table -->
<!--[if mso]>
<tr>
<td>
<center>
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"600\\">
<![endif]-->
<tr>
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\">&#xA0;</td>
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
<!-- START CENTERED WHITE CONTAINER -->
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; border-spacing: 20px 0; width: 100%;\\">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
<tr class=\\"header-image-row\\">
<td class=\\"header-image\\" width=\\"100%\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; padding-top: 24px;\\" valign=\\"top\\">
<img src=\\"http://127.0.0.1:2369/content/images/2022/05/test.jpg\\" style=\\"border: none; -ms-interpolation-mode: bicubic; max-width: 100%;\\">
</td>
</tr>
<tr>
<td class=\\"site-info-bordered\\" width=\\"100%\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; padding-top: 50px; border-bottom: 1px solid #e5eff5;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\" width=\\"100%\\">
<tr>
<td class=\\"site-url \\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; vertical-align: top; color: #15212A; font-size: 16px; letter-spacing: -0.1px; font-weight: 700; text-transform: uppercase; text-align: center;\\" valign=\\"top\\" align=\\"center\\"><div style=\\"width: 100% !important;\\"><a href=\\"http://127.0.0.1:2369/\\" class=\\"site-title\\" style=\\"text-decoration: none; color: #15212A; overflow-wrap: anywhere;\\" target=\\"_blank\\">Ghost</a></div></td>
</tr>
<tr>
<td class=\\"site-url site-url-bottom-padding\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; vertical-align: top; color: #15212A; font-size: 16px; letter-spacing: -0.1px; font-weight: 700; text-transform: uppercase; text-align: center; padding-bottom: 50px;\\" valign=\\"top\\" align=\\"center\\"><div style=\\"width: 100% !important;\\"><a href=\\"http://127.0.0.1:2369/\\" class=\\"site-subtitle\\" style=\\"text-decoration: none; color: #8695a4; font-size: 14px; font-weight: 400; text-transform: none; overflow-wrap: anywhere;\\" target=\\"_blank\\">Daily newsletter</a></div></td>
</tr>
</table>
</td>
</tr>
<tr>
<td class=\\"post-title post-title-serif\\" style=\\"vertical-align: top; color: #15212A; padding-bottom: 16px; font-size: 42px; line-height: 1.1em; font-weight: 700; text-align: center; font-family: Georgia, serif; letter-spacing: -0.01em;\\" valign=\\"top\\" align=\\"center\\">
<a href=\\"http://127.0.0.1:2369/this-is-a-test-post-title-8/\\" class=\\"post-title-link\\" style=\\"text-decoration: none; color: #15212A; display: block; text-align: center; margin-top: 50px; overflow-wrap: anywhere;\\" target=\\"_blank\\">This is a test post title</a>
</td>
</tr>
<tr>
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-bottom: 48px;\\">
<tr>
<td class=\\"post-meta post-meta-center author-date\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; vertical-align: top; color: #738a94; font-size: 13px; letter-spacing: 0.1px; display: inline-block; width: 100%; text-align: center;\\" width=\\"100%\\" valign=\\"top\\">
By Joe Bloggs &#x2022; <span class=\\"post-meta-date\\" style=\\"white-space: nowrap;\\">1 Jan 2023 </span>
</td>
<td class=\\"post-meta post-meta-center view-online\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; vertical-align: top; color: #738a94; font-size: 13px; letter-spacing: 0.1px; display: inline-block; text-decoration: underline; width: 100%; text-align: center;\\" width=\\"100%\\" valign=\\"top\\">
<a href=\\"http://127.0.0.1:2369/this-is-a-test-post-title-8/\\" class=\\"view-online-link\\" style=\\"text-decoration: none; word-wrap: none; white-space: nowrap; color: #738a94; overflow-wrap: anywhere;\\" target=\\"_blank\\">View in browser</a>
</td>
</tr>
</table>
</td>
</tr>
<tr class=\\"post-content-row\\">
<td class=\\"post-content no-border\\" style=\\"vertical-align: top; font-family: Georgia, serif; font-size: 18px; line-height: 1.5em; color: #15212A; padding-bottom: 20px; border-bottom: 0; max-width: 600px;\\" valign=\\"top\\">
<!-- POST CONTENT START -->
<p style=\\"margin: 0 0 1.5em 0; line-height: 1.6em;\\">Hello world</p>
<!-- POST CONTENT END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
<tr>
<td class=\\"subscription-box\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; padding: 24px; background: #F4F5F6; color: #15212A; border-radius: 3px;\\" valign=\\"top\\">
<h3 style=\\"margin-top: 0; font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; line-height: 1.11em; text-rendering: optimizeLegibility; font-size: 14px; font-weight: 700; margin: 0 0 16px;\\">Subscription details</h3>
<p style=\\"margin: 0 0 1.5em 0; font-size: 14px; font-weight: 400; line-height: 1.45em; text-decoration: none; margin-bottom: 16px; color: #15212A;\\">
<span>You are receiving this because you are a <strong style=\\"font-weight: 700;\\">free subscriber</strong> to Ghost.</span>
</p>
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
<tr>
<td class=\\"subscription-details\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\">
<p style=\\"margin: 0 0 1.5em 0; margin-bottom: 0; font-size: 14px; font-weight: 400; line-height: 1.45em; text-decoration: none; color: #15212A;\\">Name: Simon Tester</p>
<p style=\\"margin: 0 0 1.5em 0; margin-bottom: 0; font-size: 14px; font-weight: 400; line-height: 1.45em; text-decoration: none; color: #15212A;\\">Email: <a href=\\"mailto:replacements-test-2@example.com\\" style=\\"overflow-wrap: anywhere; text-decoration: none; color: #15212A;\\" target=\\"_blank\\">replacements-test-2@example.com</a></p>
<p style=\\"margin: 0 0 1.5em 0; margin-bottom: 0; font-size: 14px; font-weight: 400; line-height: 1.45em; text-decoration: none; color: #15212A;\\">Member since: 22 March 2023</p>
</td>
<td align=\\"right\\" valign=\\"bottom\\" class=\\"manage-subscription\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; white-space: nowrap; font-size: 13.5px; font-weight: 500; text-align: right; line-height: 1.45em; vertical-align: bottom; color: #FF1A75;\\">
<a href=\\"http://127.0.0.1:2369/#/portal/account\\" style=\\"color: #FF1A75; text-decoration: none; overflow-wrap: anywhere;\\" target=\\"_blank\\"> Manage subscription &#x2192;</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
<tr>
<td class=\\"footer\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; vertical-align: top; color: #738a94; margin-top: 20px; text-align: center; font-size: 13px; padding-bottom: 10px; padding-top: 10px; padding-left: 30px; padding-right: 30px; line-height: 1.5em;\\" valign=\\"top\\" align=\\"center\\">Ghost &#xA9; 2023 &#x2013; <a href=\\"unsubscribe_url\\" style=\\"overflow-wrap: anywhere; color: #738a94; text-decoration: underline;\\" target=\\"_blank\\">Unsubscribe</a></td>
</tr>
<tr>
<td class=\\"footer-powered\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; text-align: center; padding-top: 70px; padding-bottom: 40px;\\" valign=\\"top\\" align=\\"center\\"><a href=\\"https://ghost.org/\\" style=\\"color: #FF1A75; text-decoration: none; overflow-wrap: anywhere;\\" target=\\"_blank\\"><img src=\\"https://static.ghost.org/v4.0.0/images/powered.png\\" border=\\"0\\" width=\\"142\\" height=\\"30\\" class=\\"gh-powered\\" alt=\\"Powered by Ghost\\" style=\\"border: none; -ms-interpolation-mode: bicubic; max-width: 100%; width: 142px; height: 30px;\\"></a></td>
</tr>
</table>
</td>
</tr>
</table>
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\">&#xA0;</td>
</tr>
<!--[if mso]>
</table>
</center>
</td>
</tr>
<![endif]-->
</table>
</body>
</html>
",
}
`;
exports[`Batch sending tests Newsletter settings Shows subscription details box 2 1`] = `
Object {
"html": "
Hello world
 
Ghost [http://127.0.0.1:2369/]
Daily newsletter [http://127.0.0.1:2369/]
This is a test post title [http://127.0.0.1:2369/this-is-a-test-post-title-8/]
By Joe Bloggs • 1 Jan 2023
View in browser [http://127.0.0.1:2369/this-is-a-test-post-title-8/]
Hello world
Subscription details
You are receiving this because you are a free subscriber to Ghost.
Name: Simon Tester
Email: replacements-test-2@example.com
Member since: 22 March 2023
Manage subscription → [http://127.0.0.1:2369/#/portal/account]
Ghost © 2023 Unsubscribe [unsubscribe_url]
https://ghost.org/
 
",
}
`;
exports[`Batch sending tests Replacements Does replace with and without fallback in both plaintext and html for member with name 1 1`] = `
Object {
"html": "<!doctype html>

View file

@ -1034,5 +1034,24 @@ describe('Batch sending tests', function () {
// undo
await models.Newsletter.edit({show_comment_cta: true}, {id: defaultNewsletter.id});
});
it('Shows subscription details box', async function () {
mockSetting('email_track_clicks', false); // Disable link replacement for this test
const defaultNewsletter = await getDefaultNewsletter();
await models.Newsletter.edit({show_subscription_details: true}, {id: defaultNewsletter.id});
const {html} = await sendEmail({
title: 'This is a test post title',
mobiledoc: mobileDocExample
});
// Currently the link is not present in plaintext version (because no text)
assert.equal(html.match(/#\/portal\/account/g).length, 1, 'Subscription details box should contain a link to the account page');
await lastEmailMatchSnapshot();
// undo
await models.Newsletter.edit({show_subscription_details: false}, {id: defaultNewsletter.id});
});
});
});

View file

@ -497,14 +497,21 @@ class BatchSendingService {
* @returns {Promise<MemberLike[]>}
*/
async getBatchMembers(batchId) {
const models = await this.#models.EmailRecipient.findAll({filter: `batch_id:${batchId}`, withRelated: ['member']});
const models = await this.#models.EmailRecipient.findAll({filter: `batch_id:${batchId}`, withRelated: ['member', 'member.stripeSubscriptions', 'member.products']});
return models.map((model) => {
// Map subscriptions
const subscriptions = model.related('member')?.related('stripeSubscriptions')?.toJSON() ?? [];
const tiers = model.related('member')?.related('products')?.toJSON() ?? [];
return {
id: model.get('member_id'),
uuid: model.get('member_uuid'),
email: model.get('member_email'),
name: model.get('member_name'),
createdAt: model.related('member')?.get('created_at') ?? null
createdAt: model.related('member')?.get('created_at') ?? null,
status: model.related('member')?.get('status') ?? 'free',
subscriptions,
tiers
};
});
}

View file

@ -7,6 +7,31 @@ const {isUnsplashImage} = require('@tryghost/kg-default-cards/lib/utils');
const {textColorForBackgroundColor, darkenToContrastThreshold} = require('@tryghost/color-utils');
const {DateTime} = require('luxon');
const htmlToPlaintext = require('@tryghost/html-to-plaintext');
const tpl = require('@tryghost/tpl');
const messages = {
subscriptionStatus: {
free: 'You are currently subscribed to the free plan.',
expired: 'Your subscription has expired.',
canceled: 'Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.',
active: 'Your subscription will renew on {date}.',
trial: 'Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.',
complimentaryExpires: 'Your subscription will expire on {date}.',
complimentaryInfinite: ''
}
};
function formatDateLong(date, timezone) {
return DateTime.fromJSDate(date).setZone(timezone).setLocale('en-gb').toLocaleString({
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* @typedef {string|null} Segment
@ -20,7 +45,20 @@ const htmlToPlaintext = require('@tryghost/html-to-plaintext');
* @prop {string} uuid
* @prop {string} email
* @prop {string} name
* @prop {'free'|'paid'|'comped'} status
* @prop {Date|null} createdAt This can be null if the member has been deleted for older email recipient rows
* @prop {MemberLikeSubscription[]} subscriptions Required to get trial end / next renewal date / expire at date for paid member
* @prop {MemberLikeTier[]} tiers Required to get the expiry date in case of a comped member
*
* @typedef {object} MemberLikeSubscription
* @prop {string} status
* @prop {boolean} cancel_at_period_end
* @prop {Date|null} trial_end_at
* @prop {Date} current_period_end
*
* @typedef {object} MemberLikeTier
* @prop {string} product_id
* @prop {Date|null} expiry_at
*/
/**
@ -330,7 +368,7 @@ class EmailRenderer {
* Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
* In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
*
* @param {string} [uuid] post uuid
* @param {string} [uuid] member uuid
* @param {Object} [options]
* @param {string} [options.newsletterUuid] newsletter uuid
* @param {boolean} [options.comments] Unsubscribe from comment emails
@ -354,6 +392,81 @@ class EmailRenderer {
return unsubscribeUrl.href;
}
/**
* createManageAccountUrl
*
* @param {string} [uuid] member uuid
*/
createManageAccountUrl(uuid) {
const siteUrl = this.#urlUtils.urlFor('home', true);
const url = new URL(siteUrl);
url.hash = '#/portal/account';
return url.href;
}
/**
* @param {MemberLike} member
* @returns {string}
*/
getMemberStatusText(member) {
if (member.status === 'free') {
// Not really used, but as a backup
return tpl(messages.subscriptionStatus.free);
}
// Do we have an active subscription?
if (member.status === 'paid') {
let activeSubscription = member.subscriptions.find((subscription) => {
return subscription.status === 'active';
}) ?? member.subscriptions.find((subscription) => {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.status);
});
if (!activeSubscription && !member.tiers.length) {
// No subscription?
return tpl(messages.subscriptionStatus.expired);
}
if (!activeSubscription) {
if (!member.tiers[0]?.expiry_at) {
return tpl(messages.subscriptionStatus.complimentaryInfinite);
}
// Create one manually that is expiring
activeSubscription = {
cancel_at_period_end: true,
current_period_end: member.tiers[0].expiry_at,
status: 'active',
trial_end_at: null
};
}
const timezone = this.#settingsCache.get('timezone');
// Translate to a human readable string
if (activeSubscription.trial_end_at && activeSubscription.trial_end_at > new Date() && activeSubscription.status === 'trialing') {
const date = formatDateLong(activeSubscription.trial_end_at, timezone);
return tpl(messages.subscriptionStatus.trial, {date});
}
const date = formatDateLong(activeSubscription.current_period_end, timezone);
if (activeSubscription.cancel_at_period_end) {
return tpl(messages.subscriptionStatus.canceled, {date});
}
return tpl(messages.subscriptionStatus.active, {date});
}
const expires = member.tiers[0]?.expiry_at ?? null;
if (expires) {
const timezone = this.#settingsCache.get('timezone');
const date = formatDateLong(expires, timezone);
return tpl(messages.subscriptionStatus.complimentaryExpires, {date});
}
return tpl(messages.subscriptionStatus.complimentaryInfinite);
}
/**
* Note that we only look in HTML because plaintext and HTML are essentially the same content
* @returns {ReplacementDefinition[]}
@ -366,6 +479,12 @@ class EmailRenderer {
return this.createUnsubscribeUrl(member.uuid, {newsletterUuid});
}
},
{
id: 'manage_account_url',
getValue: (member) => {
return this.createManageAccountUrl(member.uuid);
}
},
{
id: 'uuid',
getValue: (member) => {
@ -394,11 +513,22 @@ class EmailRenderer {
id: 'created_at',
getValue: (member) => {
const timezone = this.#settingsCache.get('timezone');
return member.createdAt ? DateTime.fromJSDate(member.createdAt).setZone(timezone).setLocale('en-gb').toLocaleString({
year: 'numeric',
month: 'long',
day: 'numeric'
}) : '';
return member.createdAt ? formatDateLong(member.createdAt, timezone) : '';
}
},
{
id: 'status',
getValue: (member) => {
if (member.status === 'comped') {
return 'complimentary';
}
return member.status;
}
},
{
id: 'status_text',
getValue: (member) => {
return this.getMemberStatusText(member);
}
}
];
@ -407,10 +537,6 @@ class EmailRenderer {
const EMAIL_REPLACEMENT_REGEX = /%%\{(.*?)\}%%/g;
const REPLACEMENT_STRING_REGEX = /^(?<recipientProperty>\w+?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?$/;
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Stores the definitions that we are actually going to use
const replacements = [];

View file

@ -178,9 +178,10 @@ class EmailService {
}
/**
* @params {string} [segment]
* @return {import('./email-renderer').MemberLike}
*/
getDefaultExampleMember() {
getDefaultExampleMember(segment) {
/**
* @type {import('./email-renderer').MemberLike}
*/
@ -189,20 +190,31 @@ class EmailService {
uuid: 'example-uuid',
email: 'jamie@example.com',
name: 'Jamie Larson',
createdAt: new Date()
createdAt: new Date(),
status: segment === 'status:free' ? 'free' : 'paid',
subscriptions: segment === 'status:free' ? [] : [
{
cancel_at_period_end: false,
trial_end_at: null,
current_period_end: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
status: 'active'
}
],
tiers: []
};
}
/**
* @private
* @param {string} [email] (optional) Search for a member with this email address and use it as the example. If not found, defaults to the default but still uses the provided email address.
* @param {string} [segment] (optional) The segment to use for the example member
* @return {Promise<import('./email-renderer').MemberLike>}
*/
async getExampleMember(email) {
async getExampleMember(email, segment) {
/**
* @type {import('./email-renderer').MemberLike}
*/
const exampleMember = this.getDefaultExampleMember();
const exampleMember = this.getDefaultExampleMember(segment);
// fetch any matching members so that replacements use expected values
if (email) {
@ -213,6 +225,16 @@ class EmailService {
exampleMember.email = member.get('email');
exampleMember.name = member.get('name');
exampleMember.createdAt = member.get('created_at');
if (segment === 'status:-free' && member.get('status') !== 'free') {
// Make sure the example member matches the chosen segment (otherwise we'll send an email to free segment, but include a paid member details, which looks like a bug)
exampleMember.status = member.get('status');
const subscriptions = (await member.getLazyRelation('stripeSubscriptions')).toJSON();
exampleMember.subscriptions = subscriptions;
const tiers = (await member.getLazyRelation('products')).toJSON();
exampleMember.tiers = tiers;
}
} else {
exampleMember.name = ''; // Force empty name to simulate name fallbacks
exampleMember.email = email;
@ -246,7 +268,7 @@ class EmailService {
* @returns {Promise<{subject: string, html: string, plaintext: string}>} Email preview
*/
async previewEmail(post, newsletter, segment) {
const exampleMember = await this.getExampleMember();
const exampleMember = await this.getExampleMember(null, segment);
const subject = this.#emailRenderer.getSubject(post);
let {html, plaintext, replacements} = await this.#emailRenderer.renderBody(post, newsletter, segment, {clickTrackingEnabled: false});
@ -268,7 +290,7 @@ class EmailService {
async sendTestEmail(post, newsletter, segment, emails) {
const members = [];
for (const email of emails) {
members.push(await this.getExampleMember(email));
members.push(await this.getExampleMember(email, segment));
}
await this.#sendingService.send({

View file

@ -179,18 +179,18 @@
<td class="subscription-box">
<h3>Subscription details</h3>
<p style="margin-bottom: 16px;">
<span>You are receiving this because you are a <strong><span data-gh-segment="status:-free">paid</span><span data-gh-segment="status:free">free</span> subscriber</strong> to {{site.title}}.</span>
<span data-gh-segment="status:-free">Your subscription will renew on 20 March 2024.</span>
<span>You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {{site.title}}.</span>
<span data-gh-segment="status:-free">%%{status_text}%%</span>
</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="subscription-details">
<p>Name: %%{name}%%</p>
<p>Email: <a href="#">%%{email}%%</a></p>
<p>Email: <a href="mailto:%%{email}%%">%%{email}%%</a></p>
<p>Member since: %%{created_at}%%</p>
</td>
<td align="right" valign="bottom" class="manage-subscription">
<a href="#"> Manage subscription &rarr;</a>
<a href="%%{manage_account_url}%%"> Manage subscription &rarr;</a>
</td>
</tr>
</table>

View file

@ -591,7 +591,11 @@ describe('Batch Sending Service', function () {
member_name: 'Test User',
loaded: ['member'],
member: createModel({
created_at: new Date()
created_at: new Date(),
loaded: ['stripeSubscriptions', 'products'],
status: 'free',
stripeSubscriptions: [],
products: []
})
},
{
@ -601,7 +605,11 @@ describe('Batch Sending Service', function () {
member_name: 'Test User 2',
loaded: ['member'],
member: createModel({
created_at: new Date()
created_at: new Date(),
status: 'free',
loaded: ['stripeSubscriptions', 'products'],
stripeSubscriptions: [],
products: []
})
}
]

View file

@ -80,7 +80,7 @@ describe('Email renderer', function () {
beforeEach(function () {
emailRenderer = new EmailRenderer({
urlUtils: {
urlFor: () => 'http://example.com/subdirectory'
urlFor: () => 'http://example.com/subdirectory/'
},
labs: {
isSet: () => true
@ -101,7 +101,8 @@ describe('Email renderer', function () {
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0)
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'free'
};
});
@ -165,6 +166,43 @@ describe('Email renderer', function () {
assert.equal(replacements[0].getValue(member), 'test@example.com');
});
it('returns correct status', function () {
const html = 'Hello %%{status}%%,';
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
assert.equal(replacements.length, 1);
assert.equal(replacements[0].token.toString(), '/%%\\{status\\}%%/g');
assert.equal(replacements[0].id, 'status');
assert.equal(replacements[0].getValue(member), 'free');
});
it('returns mapped complimentary status', function () {
member.status = 'comped';
const html = 'Hello %%{status}%%,';
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
assert.equal(replacements.length, 1);
assert.equal(replacements[0].token.toString(), '/%%\\{status\\}%%/g');
assert.equal(replacements[0].id, 'status');
assert.equal(replacements[0].getValue(member), 'complimentary');
});
it('returns manage_account_url', function () {
const html = 'Hello %%{manage_account_url}%%,';
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
assert.equal(replacements.length, 1);
assert.equal(replacements[0].token.toString(), '/%%\\{manage_account_url\\}%%/g');
assert.equal(replacements[0].id, 'manage_account_url');
assert.equal(replacements[0].getValue(member), 'http://example.com/subdirectory/#/portal/account');
});
it('returns status_text', function () {
const html = 'Hello %%{status_text}%%,';
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
assert.equal(replacements.length, 1);
assert.equal(replacements[0].token.toString(), '/%%\\{status_text\\}%%/g');
assert.equal(replacements[0].id, 'status_text');
assert.equal(replacements[0].getValue(member), 'You are currently subscribed to the free plan.');
});
it('returns correct createdAt', function () {
const html = 'Hello %%{created_at}%%,';
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
@ -223,6 +261,213 @@ describe('Email renderer', function () {
});
});
describe('getMemberStatusText', function () {
let emailRenderer;
beforeEach(function () {
emailRenderer = new EmailRenderer({
urlUtils: {
urlFor: () => 'http://example.com/subdirectory/'
},
labs: {
isSet: () => true
},
settingsCache: {
get: (key) => {
if (key === 'timezone') {
return 'UTC';
}
}
}
});
});
it('Returns for free member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'free'
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, 'You are currently subscribed to the free plan.');
});
it('Returns for active paid member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'paid',
subscriptions: [
{
status: 'active',
current_period_end: new Date(2023, 2, 13, 12, 0),
cancel_at_period_end: false
}
]
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, 'Your subscription will renew on 13 March 2023.');
});
it('Returns for canceled paid member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'paid',
subscriptions: [
{
status: 'active',
current_period_end: new Date(2023, 2, 13, 12, 0),
cancel_at_period_end: true
}
]
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, 'Your subscription has been canceled and will expire on 13 March 2023. You can resume your subscription via your account settings.');
});
it('Returns for expired paid member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'paid',
subscriptions: [
{
status: 'canceled',
current_period_end: new Date(2023, 2, 13, 12, 0),
cancel_at_period_end: true
}
],
tiers: []
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, 'Your subscription has expired.');
});
it('Returns for trialing paid member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'paid',
subscriptions: [
{
status: 'trialing',
trial_end_at: new Date(2050, 2, 13, 12, 0),
current_period_end: new Date(2023, 2, 13, 12, 0),
cancel_at_period_end: false
}
],
tiers: []
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, 'Your free trial ends on 13 March 2050, at which time you will be charged the regular price. You can always cancel before then.');
});
it('Returns for infinite complimentary member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'comped',
subscriptions: [],
tiers: [
{
name: 'Silver',
expiry_at: null
}
]
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, '');
});
it('Returns for expiring complimentary member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'comped',
subscriptions: [],
tiers: [
{
name: 'Silver',
expiry_at: new Date(2050, 2, 13, 12, 0)
}
]
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, 'Your subscription will expire on 13 March 2050.');
});
it('Returns for a paid member without subscriptions', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'paid',
subscriptions: [],
tiers: [
{
name: 'Silver',
expiry_at: new Date(2050, 2, 13, 12, 0)
}
]
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, 'Your subscription has been canceled and will expire on 13 March 2050. You can resume your subscription via your account settings.');
});
it('Returns for an infinte paid member without subscriptions', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'paid',
subscriptions: [],
tiers: [
{
name: 'Silver',
expiry_at: null
}
]
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, '');
});
});
describe('getSubject', function () {
const emailRenderer = new EmailRenderer({
urlUtils: {

View file

@ -293,14 +293,78 @@ describe('Email Service', function () {
const member = createModel({
uuid: '123',
name: 'Example member',
email: 'example@example.com'
email: 'example@example.com',
status: 'free'
});
membersRepository.get.resolves(member);
const exampleMember = await service.getExampleMember('example@example.com');
const exampleMember = await service.getExampleMember('example@example.com', 'status:free');
assert.strictEqual(exampleMember.id, member.id);
assert.strictEqual(exampleMember.name, member.get('name'));
assert.strictEqual(exampleMember.email, member.get('email'));
assert.strictEqual(exampleMember.uuid, member.get('uuid'));
assert.strictEqual(exampleMember.status, 'free');
assert.deepEqual(exampleMember.subscriptions, []);
assert.deepEqual(exampleMember.tiers, []);
});
it('Returns a paid member', async function () {
const member = createModel({
uuid: '123',
name: 'Example member',
email: 'example@example.com',
status: 'paid',
stripeSubscriptions: [
createModel({
status: 'active',
current_period_end: new Date(2050, 0, 1),
cancel_at_period_end: false
})
],
products: [createModel({
name: 'Silver',
expiry_at: null
})]
});
membersRepository.get.resolves(member);
const exampleMember = await service.getExampleMember('example@example.com', 'status:-free');
assert.strictEqual(exampleMember.id, member.id);
assert.strictEqual(exampleMember.name, member.get('name'));
assert.strictEqual(exampleMember.email, member.get('email'));
assert.strictEqual(exampleMember.uuid, member.get('uuid'));
assert.strictEqual(exampleMember.status, 'paid');
assert.deepEqual(exampleMember.subscriptions, [
{
status: 'active',
current_period_end: new Date(2050, 0, 1),
cancel_at_period_end: false,
id: member.related('stripeSubscriptions')[0].id
}
]);
assert.deepEqual(exampleMember.tiers, [
{
name: 'Silver',
expiry_at: null,
id: member.related('products')[0].id
}
]);
});
it('Returns a forced free member', async function () {
const member = createModel({
uuid: '123',
name: 'Example member',
email: 'example@example.com',
status: 'paid'
});
membersRepository.get.resolves(member);
const exampleMember = await service.getExampleMember('example@example.com', 'status:free');
assert.strictEqual(exampleMember.id, member.id);
assert.strictEqual(exampleMember.name, member.get('name'));
assert.strictEqual(exampleMember.email, member.get('email'));
assert.strictEqual(exampleMember.uuid, member.get('uuid'));
assert.strictEqual(exampleMember.status, 'free');
assert.deepEqual(exampleMember.subscriptions, []);
assert.deepEqual(exampleMember.tiers, []);
});
it('Returns a member without name if member does not exist', async function () {

View file

@ -12,7 +12,10 @@ const createModel = (propertiesAndRelations) => {
}
if (Array.isArray(propertiesAndRelations[relation])) {
return Promise.resolve({
models: propertiesAndRelations[relation]
models: propertiesAndRelations[relation],
toJSON: () => {
return propertiesAndRelations[relation].map(m => m.toJSON());
}
});
}
return Promise.resolve(propertiesAndRelations[relation]);
@ -24,6 +27,13 @@ const createModel = (propertiesAndRelations) => {
if (!propertiesAndRelations.loaded.includes(relation)) {
throw new Error(`Model.related('${relation}') was used on a test model that didn't explicitly loaded that relation.`);
}
if (Array.isArray(propertiesAndRelations[relation])) {
const arr = [...propertiesAndRelations[relation]];
arr.toJSON = () => {
return arr.map(m => m.toJSON());
};
return arr;
}
return propertiesAndRelations[relation];
},
get: (property) => {