mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-26 04:08:01 +03:00
Added emails for recommendations (#18361)
fixes https://github.com/TryGhost/Product/issues/3938
This commit is contained in:
parent
d24c7c5fa6
commit
b51e12d90f
5
.github/scripts/dev.js
vendored
5
.github/scripts/dev.js
vendored
@ -28,7 +28,10 @@ const COMMAND_GHOST = {
|
||||
command: 'nx run ghost:dev',
|
||||
cwd: path.resolve(__dirname, '../../ghost/core'),
|
||||
prefixColor: 'blue',
|
||||
env: {}
|
||||
env: {
|
||||
// In development mode, we allow self-signed certificates (for sending webmentions and oembeds)
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0',
|
||||
}
|
||||
};
|
||||
|
||||
const COMMAND_ADMIN = {
|
||||
|
@ -18,6 +18,11 @@ function isPrivateIp(addr) {
|
||||
}
|
||||
|
||||
async function errorIfHostnameResolvesToPrivateIp(options) {
|
||||
// Allow all requests if we are in development mode
|
||||
if (config.get('env') === 'development') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// allow requests through to local Ghost instance
|
||||
const siteUrl = new URL(config.get('url'));
|
||||
const requestUrl = new URL(options.url.href);
|
||||
@ -37,6 +42,10 @@ async function errorIfHostnameResolvesToPrivateIp(options) {
|
||||
}
|
||||
|
||||
async function errorIfInvalidUrl(options) {
|
||||
if (config.get('env') === 'development') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (!options.url.hostname || !validator.isURL(options.url.hostname)) {
|
||||
throw new errors.InternalServerError({
|
||||
message: 'URL invalid.',
|
||||
|
@ -57,7 +57,8 @@ module.exports = class BookshelfMentionRepository {
|
||||
sourceExcerpt: model.get('source_excerpt'),
|
||||
sourceFavicon: model.get('source_favicon'),
|
||||
sourceFeaturedImage: model.get('source_featured_image'),
|
||||
verified: model.get('verified')
|
||||
verified: model.get('verified'),
|
||||
deleted: model.get('deleted')
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,8 @@ function getPostUrl(post) {
|
||||
module.exports = {
|
||||
/** @type {import('@tryghost/webmentions/lib/MentionsAPI')} */
|
||||
api: null,
|
||||
/** @type {import('./BookshelfMentionRepository')} */
|
||||
repository: null,
|
||||
controller: new MentionController(),
|
||||
metadata: new WebmentionMetadata(),
|
||||
/** @type {import('@tryghost/webmentions/lib/MentionSendingService')} */
|
||||
@ -41,6 +43,8 @@ module.exports = {
|
||||
MentionModel: models.Mention,
|
||||
DomainEvents
|
||||
});
|
||||
this.repository = repository;
|
||||
|
||||
const webmentionMetadata = this.metadata;
|
||||
const discoveryService = new MentionDiscoveryService({externalRequest});
|
||||
const resourceService = new ResourceService({
|
||||
|
@ -14,7 +14,7 @@ module.exports = class RecommendationEnablerService {
|
||||
* @returns {string}
|
||||
*/
|
||||
getSetting() {
|
||||
this.#settingsService.read('recommendations_enabled');
|
||||
return this.#settingsService.read('recommendations_enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,3 +1,7 @@
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
const {MentionCreatedEvent} = require('@tryghost/webmentions');
|
||||
const logging = require('@tryghost/logging');
|
||||
|
||||
class RecommendationServiceWrapper {
|
||||
/**
|
||||
* @type {import('@tryghost/recommendations').RecommendationRepository}
|
||||
@ -24,6 +28,11 @@ class RecommendationServiceWrapper {
|
||||
*/
|
||||
service;
|
||||
|
||||
/**
|
||||
* @type {import('@tryghost/recommendations').IncomingRecommendationService}
|
||||
*/
|
||||
incomingRecommendationService;
|
||||
|
||||
init() {
|
||||
if (this.repository) {
|
||||
return;
|
||||
@ -40,7 +49,9 @@ class RecommendationServiceWrapper {
|
||||
RecommendationService,
|
||||
RecommendationController,
|
||||
WellknownService,
|
||||
BookshelfClickEventRepository
|
||||
BookshelfClickEventRepository,
|
||||
IncomingRecommendationService,
|
||||
IncomingRecommendationEmailRenderer
|
||||
} = require('@tryghost/recommendations');
|
||||
|
||||
const mentions = require('../mentions');
|
||||
@ -75,26 +86,75 @@ class RecommendationServiceWrapper {
|
||||
wellknownService,
|
||||
mentionSendingService: mentions.sendingService,
|
||||
clickEventRepository: this.clickEventRepository,
|
||||
subscribeEventRepository: this.subscribeEventRepository,
|
||||
mentionsApi: mentions.api
|
||||
subscribeEventRepository: this.subscribeEventRepository
|
||||
});
|
||||
|
||||
const mail = require('../mail');
|
||||
const mailer = new mail.GhostMailer();
|
||||
const emailService = {
|
||||
async send(to, subject, html, text) {
|
||||
return mailer.send({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.incomingRecommendationService = new IncomingRecommendationService({
|
||||
mentionsApi: mentions.api,
|
||||
recommendationService: this.service,
|
||||
emailService,
|
||||
async getEmailRecipients() {
|
||||
const users = await models.User.getEmailAlertUsers('recommendation-received');
|
||||
return users.map((model) => {
|
||||
return {
|
||||
email: model.email,
|
||||
slug: model.slug
|
||||
};
|
||||
});
|
||||
},
|
||||
emailRenderer: new IncomingRecommendationEmailRenderer({
|
||||
staffService: require('../staff')
|
||||
})
|
||||
});
|
||||
|
||||
this.controller = new RecommendationController({
|
||||
service: this.service
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
this.service.init().catch(console.error);
|
||||
this.service.init().catch(logging.error);
|
||||
this.incomingRecommendationService.init().catch(logging.error);
|
||||
|
||||
const PATH_SUFFIX = '/.well-known/recommendations.json';
|
||||
|
||||
function isRecommendationUrl(url) {
|
||||
return url.pathname.endsWith(PATH_SUFFIX);
|
||||
}
|
||||
|
||||
// Add mapper to WebmentionMetadata
|
||||
mentions.metadata.addMapper((url) => {
|
||||
const p = '/.well-known/recommendations.json';
|
||||
if (url.pathname.endsWith(p)) {
|
||||
if (isRecommendationUrl(url)) {
|
||||
// Strip p
|
||||
const newUrl = new URL(url.toString());
|
||||
newUrl.pathname = newUrl.pathname.slice(0, -p.length);
|
||||
newUrl.pathname = newUrl.pathname.slice(0, -PATH_SUFFIX.length);
|
||||
return newUrl;
|
||||
}
|
||||
});
|
||||
|
||||
const labs = require('../../../shared/labs');
|
||||
|
||||
// Listen for incoming webmentions
|
||||
DomainEvents.subscribe(MentionCreatedEvent, async (event) => {
|
||||
if (labs.isSet('recommendations')) {
|
||||
// Check if this is a recommendation
|
||||
if (event.data.mention.verified && isRecommendationUrl(event.data.mention.source)) {
|
||||
logging.info('[INCOMING RECOMMENDATION] Received recommendation from ' + event.data.mention.source);
|
||||
await this.incomingRecommendationService.sendRecommendationEmail(event.data.mention);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,646 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends a different email if we receive a recommendation back 1: [html 1] 1`] = `
|
||||
"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name=\\"viewport\\" content=\\"width=device-width\\">
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
|
||||
<title>💌 New recommenation</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;
|
||||
}
|
||||
.new-mention-thumbnail {
|
||||
display: none !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%;
|
||||
}
|
||||
/* Reset styles for Gmail (it wraps email address in link with custom styles) */
|
||||
.text-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; padding: 10px;\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;\\">
|
||||
|
||||
<!-- START CENTERED CONTAINER -->
|
||||
<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;\\">Good news!</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: 16px;\\">One of the sites you're recommending is now <strong>recommending you back</strong>:</p>
|
||||
|
||||
<figure style=\\"margin:0 0 1.5em;padding:0;width:100%;\\">
|
||||
<a style=\\"display:flex;min-height:148px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;background:#F9F9FA;border-radius:3px;border:1px solid #F9F9FA;color:#15171a;text-decoration:none\\" href=\\"https://www.otherghostsite.com/\\">
|
||||
<div style=\\"display:inline-block; width:100%; padding:20px\\">
|
||||
<div style=\\"color:#15212a;font-size:16px;line-height:1.3em;font-weight:600\\">Other Ghost Site</div>
|
||||
<div style=\\"display:-webkit-box;overflow-y:hidden;margin-top:12px;max-height:40px;color:#738a94;font-size:13px;line-height:1.5em;font-weight:400\\"></div>
|
||||
<div style=\\"display:flex;margin-top:14px;color:#15212a;font-size:13px;font-weight:400\\">
|
||||
|
||||
<span style=\\"font-size:13px;line-height:1.5em\\">Other Ghost Site</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
<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: #FF1A75; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/ghost/#/settings-x/recommendations\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #FF1A75; border: solid 1px #FF1A75; 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: #FF1A75;\\">View recommendations</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 class=\\"text-link\\" 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;\\">http://127.0.0.1:2369/ghost/#/settings-x/recommendations</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=\\"http://127.0.0.1:2369/\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">127.0.0.1</a> to <a class=\\"small\\" href=\\"mailto:jbloggs@example.com\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">jbloggs@example.com</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=\\"http://127.0.0.1:2369/ghost/#/settings-x/users/show/joe-bloggs\\" 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>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends a different email if we receive a recommendation back 2: [text 1] 1`] = `
|
||||
"
|
||||
You have been recommended by Other Ghost Site.
|
||||
|
||||
---
|
||||
|
||||
Sent to jbloggs@example.com from 127.0.0.1.
|
||||
If you would no longer like to receive these notifications you can adjust your settings at http://127.0.0.1:2369/ghost/#/settings-x/users/show/joe-bloggs.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends a different email if we receive a recommendation back 3: [metadata 1] 1`] = `
|
||||
Object {
|
||||
"subject": "Other Ghost Site recommended you",
|
||||
"to": "jbloggs@example.com",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends an email if we receive a recommendation 1: [html 1] 1`] = `
|
||||
"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name=\\"viewport\\" content=\\"width=device-width\\">
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
|
||||
<title>💌 New recommenation</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;
|
||||
}
|
||||
.new-mention-thumbnail {
|
||||
display: none !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%;
|
||||
}
|
||||
/* Reset styles for Gmail (it wraps email address in link with custom styles) */
|
||||
.text-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; padding: 10px;\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;\\">
|
||||
|
||||
<!-- START CENTERED CONTAINER -->
|
||||
<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;\\">Good news!</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: 16px;\\">A new site is <strong>recommending you</strong> to their audience:</p>
|
||||
|
||||
<figure style=\\"margin:0 0 1.5em;padding:0;width:100%;\\">
|
||||
<a style=\\"display:flex;min-height:148px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;background:#F9F9FA;border-radius:3px;border:1px solid #F9F9FA;color:#15171a;text-decoration:none\\" href=\\"https://www.otherghostsite.com/\\">
|
||||
<div style=\\"display:inline-block; width:100%; padding:20px\\">
|
||||
<div style=\\"color:#15212a;font-size:16px;line-height:1.3em;font-weight:600\\">Other Ghost Site</div>
|
||||
<div style=\\"display:-webkit-box;overflow-y:hidden;margin-top:12px;max-height:40px;color:#738a94;font-size:13px;line-height:1.5em;font-weight:400\\"></div>
|
||||
<div style=\\"display:flex;margin-top:14px;color:#15212a;font-size:13px;font-weight:400\\">
|
||||
|
||||
<span style=\\"font-size:13px;line-height:1.5em\\">Other Ghost Site</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
<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: #FF1A75; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/ghost/#/settings-x/recommendations\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #FF1A75; border: solid 1px #FF1A75; 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: #FF1A75;\\">Recommend back</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 class=\\"text-link\\" 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;\\">http://127.0.0.1:2369/ghost/#/settings-x/recommendations</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=\\"http://127.0.0.1:2369/\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">127.0.0.1</a> to <a class=\\"small\\" href=\\"mailto:jbloggs@example.com\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">jbloggs@example.com</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=\\"http://127.0.0.1:2369/ghost/#/settings-x/users/show/joe-bloggs\\" 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>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends an email if we receive a recommendation 2: [html 2] 1`] = `
|
||||
"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name=\\"viewport\\" content=\\"width=device-width\\">
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
|
||||
<title>💌 New recommenation</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;
|
||||
}
|
||||
.new-mention-thumbnail {
|
||||
display: none !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%;
|
||||
}
|
||||
/* Reset styles for Gmail (it wraps email address in link with custom styles) */
|
||||
.text-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; padding: 10px;\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;\\">
|
||||
|
||||
<!-- START CENTERED CONTAINER -->
|
||||
<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;\\">Good news!</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: 16px;\\">A new site is <strong>recommending you</strong> to their audience:</p>
|
||||
|
||||
<figure style=\\"margin:0 0 1.5em;padding:0;width:100%;\\">
|
||||
<a style=\\"display:flex;min-height:148px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;background:#F9F9FA;border-radius:3px;border:1px solid #F9F9FA;color:#15171a;text-decoration:none\\" href=\\"https://www.otherghostsite.com/\\">
|
||||
<div style=\\"display:inline-block; width:100%; padding:20px\\">
|
||||
<div style=\\"color:#15212a;font-size:16px;line-height:1.3em;font-weight:600\\">Other Ghost Site</div>
|
||||
<div style=\\"display:-webkit-box;overflow-y:hidden;margin-top:12px;max-height:40px;color:#738a94;font-size:13px;line-height:1.5em;font-weight:400\\"></div>
|
||||
<div style=\\"display:flex;margin-top:14px;color:#15212a;font-size:13px;font-weight:400\\">
|
||||
|
||||
<span style=\\"font-size:13px;line-height:1.5em\\">Other Ghost Site</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
<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: #FF1A75; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/ghost/#/settings-x/recommendations\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #FF1A75; border: solid 1px #FF1A75; 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: #FF1A75;\\">Recommend back</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 class=\\"text-link\\" 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;\\">http://127.0.0.1:2369/ghost/#/settings-x/recommendations</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=\\"http://127.0.0.1:2369/\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">127.0.0.1</a> to <a class=\\"small\\" href=\\"mailto:swellingsworth@example.com\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">swellingsworth@example.com</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=\\"http://127.0.0.1:2369/ghost/#/settings-x/users/show/smith-wellingsworth\\" 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>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends an email if we receive a recommendation 2: [text 1] 1`] = `
|
||||
"
|
||||
You have been recommended by Other Ghost Site.
|
||||
|
||||
---
|
||||
|
||||
Sent to jbloggs@example.com from 127.0.0.1.
|
||||
If you would no longer like to receive these notifications you can adjust your settings at http://127.0.0.1:2369/ghost/#/settings-x/users/show/joe-bloggs.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends an email if we receive a recommendation 3: [metadata 1] 1`] = `
|
||||
Object {
|
||||
"subject": "Other Ghost Site recommended you",
|
||||
"to": "jbloggs@example.com",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends an email if we receive a recommendation 3: [text 1] 1`] = `
|
||||
"
|
||||
You have been recommended by Other Ghost Site.
|
||||
|
||||
---
|
||||
|
||||
Sent to jbloggs@example.com from 127.0.0.1.
|
||||
If you would no longer like to receive these notifications you can adjust your settings at http://127.0.0.1:2369/ghost/#/settings-x/users/show/joe-bloggs.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends an email if we receive a recommendation 4: [metadata 1] 1`] = `
|
||||
Object {
|
||||
"subject": "Other Ghost Site recommended you",
|
||||
"to": "jbloggs@example.com",
|
||||
}
|
||||
`;
|
@ -0,0 +1,180 @@
|
||||
const {agentProvider, fixtureManager, mockManager, dbUtils} = require('../../utils/e2e-framework');
|
||||
const assert = require('assert/strict');
|
||||
const mentionsService = require('../../../core/server/services/mentions');
|
||||
const recommendationsService = require('../../../core/server/services/recommendations');
|
||||
|
||||
let agent;
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
const {Mention} = require('@tryghost/webmentions');
|
||||
const {Recommendation} = require('@tryghost/recommendations');
|
||||
|
||||
describe('Incoming Recommendation Emails', function () {
|
||||
let emailMockReceiver;
|
||||
|
||||
before(async function () {
|
||||
agent = await agentProvider.getAdminAPIAgent();
|
||||
await fixtureManager.init('users');
|
||||
await agent.loginAsAdmin();
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
emailMockReceiver = mockManager.mockMail();
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
mockManager.restore();
|
||||
});
|
||||
|
||||
it('Sends an email if we receive a recommendation', async function () {
|
||||
const webmention = await Mention.create({
|
||||
source: 'https://www.otherghostsite.com/.well-known/recommendations.json',
|
||||
target: 'https://www.mysite.com/',
|
||||
timestamp: new Date(),
|
||||
payload: null,
|
||||
resourceId: null,
|
||||
resourceType: null,
|
||||
sourceTitle: 'Other Ghost Site',
|
||||
sourceSiteTitle: 'Other Ghost Site',
|
||||
sourceAuthor: null,
|
||||
sourceExcerpt: null,
|
||||
sourceFavicon: null,
|
||||
sourceFeaturedImage: null
|
||||
});
|
||||
|
||||
// Mark it as verified
|
||||
webmention.verify('{"url": "https://www.mysite.com/"}', 'application/json');
|
||||
assert.ok(webmention.verified);
|
||||
|
||||
// Save to repository
|
||||
await mentionsService.repository.save(webmention);
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
emailMockReceiver
|
||||
.assertSentEmailCount(2)
|
||||
.matchHTMLSnapshot([{}], 0)
|
||||
.matchHTMLSnapshot([{}], 1)
|
||||
.matchPlaintextSnapshot([{}])
|
||||
.matchMetadataSnapshot();
|
||||
|
||||
const email = emailMockReceiver.getSentEmail(0);
|
||||
|
||||
// Check if the site title is visible in the email
|
||||
assert(email.html.includes('Other Ghost Site'));
|
||||
assert(email.html.includes('Recommend back'));
|
||||
assert(email.html.includes('https://www.otherghostsite.com'));
|
||||
});
|
||||
|
||||
it('Sends a different email if we receive a recommendation back', async function () {
|
||||
if (dbUtils.isSQLite()) {
|
||||
this.skip();
|
||||
}
|
||||
|
||||
// Create a recommendation to otherghostsite.com
|
||||
const recommendation = Recommendation.create({
|
||||
title: `Recommendation`,
|
||||
reason: `Reason`,
|
||||
url: new URL(`https://www.otherghostsite.com/`),
|
||||
favicon: null,
|
||||
featuredImage: null,
|
||||
excerpt: 'Test excerpt',
|
||||
oneClickSubscribe: true,
|
||||
createdAt: new Date(5000)
|
||||
});
|
||||
|
||||
await recommendationsService.repository.save(recommendation);
|
||||
|
||||
const webmention = await Mention.create({
|
||||
source: 'https://www.otherghostsite.com/.well-known/recommendations.json',
|
||||
target: 'https://www.mysite.com/',
|
||||
timestamp: new Date(),
|
||||
payload: null,
|
||||
resourceId: null,
|
||||
resourceType: null,
|
||||
sourceTitle: 'Other Ghost Site',
|
||||
sourceSiteTitle: 'Other Ghost Site',
|
||||
sourceAuthor: null,
|
||||
sourceExcerpt: null,
|
||||
sourceFavicon: null,
|
||||
sourceFeaturedImage: null
|
||||
});
|
||||
|
||||
// Mark it as verified
|
||||
webmention.verify('{"url": "https://www.mysite.com/"}', 'application/json');
|
||||
assert.ok(webmention.verified);
|
||||
|
||||
// Save to repository
|
||||
await mentionsService.repository.save(webmention);
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
emailMockReceiver
|
||||
.assertSentEmailCount(2)
|
||||
.matchHTMLSnapshot([{}])
|
||||
.matchPlaintextSnapshot([{}])
|
||||
.matchMetadataSnapshot();
|
||||
|
||||
const email = emailMockReceiver.getSentEmail(0);
|
||||
|
||||
// Check if the site title is visible in the email
|
||||
assert(email.html.includes('Other Ghost Site'));
|
||||
assert(email.html.includes('View recommendations'));
|
||||
assert(email.html.includes('https://www.otherghostsite.com'));
|
||||
});
|
||||
|
||||
it('Does not send an email if we receive a normal mention', async function () {
|
||||
const webmention = await Mention.create({
|
||||
source: 'https://www.otherghostsite.com/recommendations.json',
|
||||
target: 'https://www.mysite.com/',
|
||||
timestamp: new Date(),
|
||||
payload: null,
|
||||
resourceId: null,
|
||||
resourceType: null,
|
||||
sourceTitle: 'Other Ghost Site',
|
||||
sourceSiteTitle: 'Other Ghost Site',
|
||||
sourceAuthor: null,
|
||||
sourceExcerpt: null,
|
||||
sourceFavicon: null,
|
||||
sourceFeaturedImage: null
|
||||
});
|
||||
|
||||
// Mark it as verified
|
||||
webmention.verify('{"url": "https://www.mysite.com/"}', 'application/json');
|
||||
assert.ok(webmention.verified);
|
||||
|
||||
// Save to repository
|
||||
await mentionsService.repository.save(webmention);
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
mockManager.assert.sentEmailCount(0);
|
||||
});
|
||||
|
||||
it('Does not send an email for an unverified webmention', async function () {
|
||||
const webmention = await Mention.create({
|
||||
source: 'https://www.otherghostsite.com/.well-known/recommendations.json',
|
||||
target: 'https://www.mysite.com/',
|
||||
timestamp: new Date(),
|
||||
payload: null,
|
||||
resourceId: null,
|
||||
resourceType: null,
|
||||
sourceTitle: 'Other Ghost Site',
|
||||
sourceSiteTitle: 'Other Ghost Site',
|
||||
sourceAuthor: null,
|
||||
sourceExcerpt: null,
|
||||
sourceFavicon: null,
|
||||
sourceFeaturedImage: null
|
||||
});
|
||||
|
||||
// Mark it as verified
|
||||
webmention.verify('{"url": "https://www.myste.com/"}', 'application/json');
|
||||
assert.ok(!webmention.verified);
|
||||
|
||||
// Save to repository
|
||||
await mentionsService.repository.save(webmention);
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
mockManager.assert.sentEmailCount(0);
|
||||
});
|
||||
});
|
@ -232,7 +232,12 @@ class OEmbedService {
|
||||
let scraperResponse;
|
||||
|
||||
try {
|
||||
scraperResponse = await metascraper({html, url});
|
||||
scraperResponse = await metascraper({
|
||||
html,
|
||||
url,
|
||||
// In development, allow non-standard tlds
|
||||
validateUrl: this.config.get('env') !== 'development'
|
||||
});
|
||||
} catch (err) {
|
||||
// Log to avoid being blind to errors happenning in metascraper
|
||||
logging.error(err);
|
||||
|
@ -0,0 +1,36 @@
|
||||
import {IncomingRecommendation, EmailRecipient} from './IncomingRecommendationService';
|
||||
|
||||
type StaffService = {
|
||||
api: {
|
||||
emails: {
|
||||
renderHTML(template: string, data: unknown): Promise<string>,
|
||||
renderText(template: string, data: unknown): Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class IncomingRecommendationEmailRenderer {
|
||||
#staffService: StaffService;
|
||||
|
||||
constructor({staffService}: {staffService: StaffService}) {
|
||||
this.#staffService = staffService;
|
||||
}
|
||||
|
||||
async renderSubject(recommendation: IncomingRecommendation) {
|
||||
return `${recommendation.siteTitle} recommended you`;
|
||||
}
|
||||
|
||||
async renderHTML(recommendation: IncomingRecommendation, recipient: EmailRecipient) {
|
||||
return this.#staffService.api.emails.renderHTML('recommendation-received', {
|
||||
recommendation,
|
||||
recipient
|
||||
});
|
||||
}
|
||||
|
||||
async renderText(recommendation: IncomingRecommendation, recipient: EmailRecipient) {
|
||||
return this.#staffService.api.emails.renderText('recommendation-received', {
|
||||
recommendation,
|
||||
recipient
|
||||
});
|
||||
}
|
||||
};
|
135
ghost/recommendations/src/IncomingRecommendationService.ts
Normal file
135
ghost/recommendations/src/IncomingRecommendationService.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import {IncomingRecommendationEmailRenderer} from './IncomingRecommendationEmailRenderer';
|
||||
import {RecommendationService} from './RecommendationService';
|
||||
import logging from '@tryghost/logging';
|
||||
|
||||
export type IncomingRecommendation = {
|
||||
title: string;
|
||||
siteTitle: string|null;
|
||||
url: URL;
|
||||
excerpt: string|null;
|
||||
favicon: URL|null;
|
||||
featuredImage: URL|null;
|
||||
recommendingBack: boolean;
|
||||
}
|
||||
|
||||
export type Report = {
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
recommendations: IncomingRecommendation[]
|
||||
}
|
||||
|
||||
type Mention = {
|
||||
source: URL,
|
||||
sourceTitle: string,
|
||||
sourceSiteTitle: string|null,
|
||||
sourceAuthor: string|null,
|
||||
sourceExcerpt: string|null,
|
||||
sourceFavicon: URL|null,
|
||||
sourceFeaturedImage: URL|null
|
||||
}
|
||||
|
||||
type MentionsAPI = {
|
||||
refreshMentions(options: {filter: string, limit: number|'all'}): Promise<void>
|
||||
listMentions(options: {filter: string, limit: number|'all'}): Promise<{data: Mention[]}>
|
||||
}
|
||||
|
||||
export type EmailRecipient = {
|
||||
email: string
|
||||
}
|
||||
|
||||
type EmailService = {
|
||||
send(to: string, subject: string, html: string, text: string): Promise<void>
|
||||
}
|
||||
|
||||
export class IncomingRecommendationService {
|
||||
#mentionsApi: MentionsAPI;
|
||||
#recommendationService: RecommendationService;
|
||||
|
||||
#emailService: EmailService;
|
||||
#emailRenderer: IncomingRecommendationEmailRenderer;
|
||||
#getEmailRecipients: () => Promise<EmailRecipient[]>;
|
||||
|
||||
constructor(deps: {
|
||||
recommendationService: RecommendationService,
|
||||
mentionsApi: MentionsAPI,
|
||||
emailService: EmailService,
|
||||
emailRenderer: IncomingRecommendationEmailRenderer,
|
||||
getEmailRecipients: () => Promise<EmailRecipient[]>,
|
||||
}) {
|
||||
this.#recommendationService = deps.recommendationService;
|
||||
this.#mentionsApi = deps.mentionsApi;
|
||||
this.#emailService = deps.emailService;
|
||||
this.#emailRenderer = deps.emailRenderer;
|
||||
this.#getEmailRecipients = deps.getEmailRecipients;
|
||||
}
|
||||
|
||||
async init() {
|
||||
// When we boot, it is possible that we missed some webmentions from other sites recommending you
|
||||
// More importantly, we might have missed some deletes which we can detect.
|
||||
// So we do a slow revalidation of all incoming recommendations
|
||||
// This also prevents doing multiple external fetches when doing quick reboots of Ghost after each other (requires Ghost to be up for at least 15 seconds)
|
||||
if (!process.env.NODE_ENV?.startsWith('test')) {
|
||||
setTimeout(() => {
|
||||
logging.info('Updating incoming recommendations on boot');
|
||||
this.#updateIncomingRecommendations().catch((err) => {
|
||||
logging.error('Failed to update incoming recommendations on boot', err);
|
||||
});
|
||||
}, 15 * 1000 + Math.random() * 5 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
#getMentionFilter({verified = true} = {}) {
|
||||
const base = `source:~$'/.well-known/recommendations.json'`;
|
||||
if (verified) {
|
||||
return `${base}+verified:true`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async #updateIncomingRecommendations() {
|
||||
// Note: we also recheck recommendations that were not verified (verification could have failed)
|
||||
const filter = this.#getMentionFilter({verified: false});
|
||||
await this.#mentionsApi.refreshMentions({filter, limit: 100});
|
||||
}
|
||||
|
||||
async #mentionToIncomingRecommendation(mention: Mention): Promise<IncomingRecommendation|null> {
|
||||
try {
|
||||
const url = new URL(mention.source.toString().replace(/\/.well-known\/recommendations\.json$/, ''));
|
||||
|
||||
// Check if we are also recommending this URL
|
||||
const existing = await this.#recommendationService.countRecommendations({
|
||||
filter: `url:~^'${url}'`
|
||||
});
|
||||
const recommendingBack = existing > 0;
|
||||
|
||||
return {
|
||||
title: mention.sourceTitle,
|
||||
siteTitle: mention.sourceSiteTitle,
|
||||
url,
|
||||
excerpt: mention.sourceExcerpt,
|
||||
favicon: mention.sourceFavicon,
|
||||
featuredImage: mention.sourceFeaturedImage,
|
||||
recommendingBack
|
||||
};
|
||||
} catch (e) {
|
||||
logging.error('Failed to parse mention to incoming recommendation data type', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async sendRecommendationEmail(mention: Mention) {
|
||||
const recommendation = await this.#mentionToIncomingRecommendation(mention);
|
||||
if (!recommendation) {
|
||||
return;
|
||||
}
|
||||
const recipients = await this.#getEmailRecipients();
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const subject = await this.#emailRenderer.renderSubject(recommendation);
|
||||
const html = await this.#emailRenderer.renderHTML(recommendation, recipient);
|
||||
const text = await this.#emailRenderer.renderText(recommendation, recipient);
|
||||
|
||||
await this.#emailService.send(recipient.email, subject, html, text);
|
||||
}
|
||||
}
|
||||
}
|
@ -27,10 +27,6 @@ type MentionSendingService = {
|
||||
sendAll(options: {url: URL, links: URL[]}): Promise<void>
|
||||
}
|
||||
|
||||
type MentionsAPI = {
|
||||
refreshMentions(options: {filter: string, limit: number|'all'}): Promise<void>
|
||||
}
|
||||
|
||||
type RecommendationEnablerService = {
|
||||
getSetting(): string,
|
||||
setSetting(value: string): Promise<void>
|
||||
@ -48,7 +44,6 @@ export class RecommendationService {
|
||||
wellknownService: WellknownService;
|
||||
mentionSendingService: MentionSendingService;
|
||||
recommendationEnablerService: RecommendationEnablerService;
|
||||
mentionsApi: MentionsAPI;
|
||||
|
||||
constructor(deps: {
|
||||
repository: RecommendationRepository,
|
||||
@ -56,8 +51,7 @@ export class RecommendationService {
|
||||
subscribeEventRepository: BookshelfRepository<string, SubscribeEvent>,
|
||||
wellknownService: WellknownService,
|
||||
mentionSendingService: MentionSendingService,
|
||||
recommendationEnablerService: RecommendationEnablerService,
|
||||
mentionsApi: MentionsAPI
|
||||
recommendationEnablerService: RecommendationEnablerService
|
||||
}) {
|
||||
this.repository = deps.repository;
|
||||
this.wellknownService = deps.wellknownService;
|
||||
@ -65,31 +59,11 @@ export class RecommendationService {
|
||||
this.recommendationEnablerService = deps.recommendationEnablerService;
|
||||
this.clickEventRepository = deps.clickEventRepository;
|
||||
this.subscribeEventRepository = deps.subscribeEventRepository;
|
||||
this.mentionsApi = deps.mentionsApi;
|
||||
}
|
||||
|
||||
async init() {
|
||||
const recommendations = await this.#listRecommendations();
|
||||
await this.updateWellknown(recommendations);
|
||||
|
||||
// When we boot, it is possible that we missed some webmentions from other sites recommending you
|
||||
// More importantly, we might have missed some deletes which we can detect.
|
||||
// So we do a slow revalidation of all incoming recommendations
|
||||
// This also prevents doing multiple external fetches when doing quick reboots of Ghost after each other (requires Ghost to be up for at least 15 seconds)
|
||||
if (!process.env.NODE_ENV?.startsWith('test')) {
|
||||
setTimeout(() => {
|
||||
logging.info('Updating incoming recommendations on boot');
|
||||
this.#updateIncomingRecommendations().catch((err) => {
|
||||
logging.error('Failed to update incoming recommendations on boot', err);
|
||||
});
|
||||
}, 15 * 1000 + Math.random() * 5 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async #updateIncomingRecommendations() {
|
||||
// Note: we also recheck recommendations that were not verified (verification could have failed)
|
||||
const filter = `source:~$'/.well-known/recommendations.json'`;
|
||||
await this.mentionsApi.refreshMentions({filter, limit: 100});
|
||||
}
|
||||
|
||||
async updateWellknown(recommendations: Recommendation[]) {
|
||||
|
@ -9,3 +9,5 @@ export * from './ClickEvent';
|
||||
export * from './BookshelfClickEventRepository';
|
||||
export * from './SubscribeEvent';
|
||||
export * from './BookshelfSubscribeEventRepository';
|
||||
export * from './IncomingRecommendationService';
|
||||
export * from './IncomingRecommendationEmailRenderer';
|
||||
|
@ -15,7 +15,6 @@ class StaffService {
|
||||
|
||||
const Emails = require('./StaffServiceEmails');
|
||||
|
||||
/** @private */
|
||||
this.emails = new Emails({
|
||||
logging,
|
||||
models,
|
||||
|
@ -0,0 +1,105 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>💌 New recommenation</title>
|
||||
{{> styles}}
|
||||
</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; padding: 10px;">
|
||||
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
|
||||
|
||||
<!-- START CENTERED CONTAINER -->
|
||||
<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;">Good news!</p>
|
||||
|
||||
{{#if recommendation.recommendingBack}}
|
||||
<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: 16px;">One of the sites you're recommending is now <strong>recommending you back</strong>:</p>
|
||||
{{else}}
|
||||
<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: 16px;">A new site is <strong>recommending you</strong> to their audience:</p>
|
||||
{{/if}}
|
||||
|
||||
<figure style="margin:0 0 1.5em;padding:0;width:100%;">
|
||||
<a style="display:flex;min-height:148px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;background:#F9F9FA;border-radius:3px;border:1px solid #F9F9FA;color:#15171a;text-decoration:none" href="{{recommendation.url}}">
|
||||
<div style="display:inline-block; width:100%; padding:20px">
|
||||
<div style="color:#15212a;font-size:16px;line-height:1.3em;font-weight:600">{{recommendation.title}}</div>
|
||||
<div style="display:-webkit-box;overflow-y:hidden;margin-top:12px;max-height:40px;color:#738a94;font-size:13px;line-height:1.5em;font-weight:400">{{recommendation.excerpt}}</div>
|
||||
<div style="display:flex;margin-top:14px;color:#15212a;font-size:13px;font-weight:400">
|
||||
{{#if recommendation.favicon}}<img style="border:none;max-width:100%;margin-right:8px;width:22px;height:22px" src="{{recommendation.favicon}}" alt="">{{/if}}
|
||||
{{#if recommendation.siteTitle}}<span style="font-size:13px;line-height:1.5em">{{recommendation.siteTitle}}</span>{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{#if recommendation.featuredImage}}
|
||||
<div style="min-width: 140px; max-width: 180px; background-repeat: no-repeat; background-size: cover; background-position: center; border-radius: 0 2px 2px 0;background-image: url('{{recommendation.featuredImage}}')" class="new-mention-thumbnail">
|
||||
<img src="{{recommendation.featuredImage}}" style="border: none; -ms-interpolation-mode: bicubic; max-width: 100%; display: none;"/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
<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>
|
||||
{{#if recommendation.recommendingBack}}
|
||||
<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="{{siteUrl}}ghost/#/settings-x/recommendations" 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 recommendations</a></td>
|
||||
{{else}}
|
||||
<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="{{siteUrl}}ghost/#/settings-x/recommendations" 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}};">Recommend back</a></td>
|
||||
{{/if}}
|
||||
</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 class="text-link" 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;">{{siteUrl}}ghost/#/settings-x/recommendations</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) {
|
||||
const {recommendation} = data;
|
||||
|
||||
// Be careful when you indent the email, because whitespaces are visible in emails!
|
||||
return `
|
||||
You have been recommended by ${recommendation.siteTitle || recommendation.title || recommendation.url}.
|
||||
|
||||
---
|
||||
|
||||
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}.
|
||||
`;
|
||||
};
|
@ -56,7 +56,7 @@ module.exports = class InMemoryMentionRepository {
|
||||
*/
|
||||
async getBySourceAndTarget(source, target) {
|
||||
return this.#store.find((item) => {
|
||||
return item.source.href === source.href && item.target.href === target.href && !Mention.isDeleted(item);
|
||||
return item.source.href === source.href && item.target.href === target.href;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -30,6 +30,14 @@ module.exports = class Mention {
|
||||
this.#deleted = true;
|
||||
}
|
||||
|
||||
#undelete() {
|
||||
// When an earlier mention is deleted, but then it gets verified again, we need to undelete it
|
||||
if (this.#deleted) {
|
||||
this.#deleted = false;
|
||||
this.events.push(MentionCreatedEvent.create({mention: this}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} html
|
||||
* @param {string} contentType
|
||||
@ -44,9 +52,11 @@ module.exports = class Mention {
|
||||
this.#verified = hasTargetUrl;
|
||||
|
||||
if (wasVerified && !this.#verified) {
|
||||
// Delete the mention
|
||||
// Delete the mention, but keep it verified (it was just deleted, because it was verified earlier, so now it is removed from the site according to the spec)
|
||||
this.#deleted = true;
|
||||
this.#verified = true;
|
||||
} else {
|
||||
this.#undelete();
|
||||
}
|
||||
} catch (e) {
|
||||
this.#verified = false;
|
||||
@ -62,9 +72,11 @@ module.exports = class Mention {
|
||||
this.#verified = !!html.includes(JSON.stringify(this.target.href));
|
||||
|
||||
if (wasVerified && !this.#verified) {
|
||||
// Delete the mention
|
||||
// Delete the mention, but keep it verified (it was just deleted, because it was verified earlier, so now it is removed from the site according to the spec)
|
||||
this.#deleted = true;
|
||||
this.#verified = true;
|
||||
} else {
|
||||
this.#undelete();
|
||||
}
|
||||
} catch (e) {
|
||||
this.#verified = false;
|
||||
@ -217,6 +229,7 @@ module.exports = class Mention {
|
||||
this.#resourceId = data.resourceId;
|
||||
this.#resourceType = data.resourceType;
|
||||
this.#verified = data.verified;
|
||||
this.#deleted = data.deleted || false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -302,7 +315,8 @@ module.exports = class Mention {
|
||||
payload,
|
||||
resourceId,
|
||||
resourceType,
|
||||
verified
|
||||
verified,
|
||||
deleted: isNew ? false : !!data.deleted
|
||||
});
|
||||
|
||||
mention.setSourceMetadata(data);
|
||||
|
@ -1,5 +1,6 @@
|
||||
/**
|
||||
* @typedef {object} MentionCreatedEventData
|
||||
* @property {import('./Mention')} mention
|
||||
*/
|
||||
|
||||
module.exports = class MentionCreatedEvent {
|
||||
|
@ -1,4 +1,5 @@
|
||||
const cheerio = require('cheerio');
|
||||
const logging = require('@tryghost/logging');
|
||||
|
||||
module.exports = class MentionDiscoveryService {
|
||||
#externalRequest;
|
||||
@ -26,6 +27,7 @@ module.exports = class MentionDiscoveryService {
|
||||
});
|
||||
return this.getEndpointFromResponse(response);
|
||||
} catch (error) {
|
||||
logging.error(`Error fetching ${url.href} to discover webmention endpoint`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -183,6 +183,7 @@ module.exports = class MentionsAPI {
|
||||
|
||||
async #updateWebmention(mention, webmention) {
|
||||
const isNew = !mention;
|
||||
const wasDeleted = mention?.deleted ?? false;
|
||||
const targetExists = await this.#routingService.pageExists(webmention.target);
|
||||
|
||||
if (!targetExists) {
|
||||
@ -235,23 +236,23 @@ module.exports = class MentionsAPI {
|
||||
}
|
||||
|
||||
if (metadata?.body) {
|
||||
try {
|
||||
mention.verify(metadata.body, metadata.contentType);
|
||||
} catch (e) {
|
||||
logging.error(e);
|
||||
}
|
||||
mention.verify(metadata.body, metadata.contentType);
|
||||
}
|
||||
}
|
||||
|
||||
await this.#repository.save(mention);
|
||||
|
||||
if (isNew) {
|
||||
logging.info('[Webmention] Created ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified);
|
||||
logging.info('[Webmention] Created ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified + ', deleted: ' + mention.deleted);
|
||||
} else {
|
||||
if (mention.deleted) {
|
||||
if (mention.deleted && !wasDeleted) {
|
||||
logging.info('[Webmention] Deleted ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified);
|
||||
} else {
|
||||
logging.info('[Webmention] Updated ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified);
|
||||
if (!mention.deleted && wasDeleted) {
|
||||
logging.info('[Webmention] Restored ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified);
|
||||
} else {
|
||||
logging.info('[Webmention] Updated ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified + ', deleted: ' + mention.deleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -126,6 +126,27 @@ describe('Mention', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('undelete', function () {
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('can undelete a verified mention', async function () {
|
||||
const mention = await Mention.create({
|
||||
...validInput,
|
||||
id: new ObjectID(),
|
||||
deleted: true,
|
||||
verified: true
|
||||
});
|
||||
assert(mention.verified);
|
||||
assert(mention.deleted);
|
||||
|
||||
mention.verify('{"url": "https://target.com/"}', 'application/json');
|
||||
assert(mention.verified);
|
||||
assert(!mention.isDeleted());
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', function () {
|
||||
it('Will error with invalid inputs', async function () {
|
||||
const invalidInputs = [
|
||||
|
@ -29,7 +29,8 @@ const mockWebmentionMetadata = {
|
||||
author: 'Dr Egg Man',
|
||||
image: new URL('https://unsplash.com/photos/QAND9huzD04'),
|
||||
favicon: new URL('https://ghost.org/favicon.ico'),
|
||||
body: `<html><body><p>Some HTML and a <a href='http://target.com/'>mentioned url</a></p></body></html>`
|
||||
body: `<html><body><p>Some HTML and a <a href='https://target.com/'>mentioned url</a></p></body></html>`,
|
||||
contentType: 'text/html'
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -432,7 +433,7 @@ describe('MentionsAPI', function () {
|
||||
}
|
||||
});
|
||||
|
||||
it('Will delete an existing mention if the source page does not exist', async function () {
|
||||
it('Will delete and restore an existing mention if the source page does not exist', async function () {
|
||||
const repository = new InMemoryMentionRepository();
|
||||
const api = new MentionsAPI({
|
||||
repository,
|
||||
@ -449,6 +450,7 @@ describe('MentionsAPI', function () {
|
||||
fetch: sinon.stub()
|
||||
.onFirstCall().resolves(mockWebmentionMetadata.fetch())
|
||||
.onSecondCall().rejects()
|
||||
.onThirdCall().resolves(mockWebmentionMetadata.fetch())
|
||||
}
|
||||
});
|
||||
|
||||
@ -481,6 +483,88 @@ describe('MentionsAPI', function () {
|
||||
assert.equal(page.data.length, 0);
|
||||
break checkMentionDeleted;
|
||||
}
|
||||
|
||||
checkRestored: {
|
||||
const mention = await api.processWebmention({
|
||||
source: new URL('https://source.com'),
|
||||
target: new URL('https://target.com'),
|
||||
payload: {}
|
||||
});
|
||||
|
||||
const page = await api.listMentions({
|
||||
limit: 'all'
|
||||
});
|
||||
|
||||
assert.equal(page.data[0].id, mention.id);
|
||||
break checkRestored;
|
||||
}
|
||||
});
|
||||
|
||||
it('Will delete and restore an existing mention if the target url is not present on the source page', async function () {
|
||||
const repository = new InMemoryMentionRepository();
|
||||
const api = new MentionsAPI({
|
||||
repository,
|
||||
routingService: mockRoutingService,
|
||||
resourceService: {
|
||||
async getByURL() {
|
||||
return {
|
||||
type: 'post',
|
||||
id: new ObjectID
|
||||
};
|
||||
}
|
||||
},
|
||||
webmentionMetadata: {
|
||||
fetch: sinon.stub()
|
||||
.onFirstCall().resolves(mockWebmentionMetadata.fetch())
|
||||
.onSecondCall().resolves({...(await mockWebmentionMetadata.fetch()), body: 'test'})
|
||||
.onThirdCall().resolves(mockWebmentionMetadata.fetch())
|
||||
}
|
||||
});
|
||||
|
||||
checkFirstMention: {
|
||||
const mention = await api.processWebmention({
|
||||
source: new URL('https://source.com'),
|
||||
target: new URL('https://target.com'),
|
||||
payload: {}
|
||||
});
|
||||
|
||||
const page = await api.listMentions({
|
||||
limit: 'all'
|
||||
});
|
||||
|
||||
assert.equal(page.data[0].id, mention.id);
|
||||
break checkFirstMention;
|
||||
}
|
||||
|
||||
checkMentionDeleted: {
|
||||
await api.processWebmention({
|
||||
source: new URL('https://source.com'),
|
||||
target: new URL('https://target.com'),
|
||||
payload: {}
|
||||
});
|
||||
|
||||
const page = await api.listMentions({
|
||||
limit: 'all'
|
||||
});
|
||||
|
||||
assert.equal(page.data.length, 0);
|
||||
break checkMentionDeleted;
|
||||
}
|
||||
|
||||
checkRestored: {
|
||||
const mention = await api.processWebmention({
|
||||
source: new URL('https://source.com'),
|
||||
target: new URL('https://target.com'),
|
||||
payload: {}
|
||||
});
|
||||
|
||||
const page = await api.listMentions({
|
||||
limit: 'all'
|
||||
});
|
||||
|
||||
assert.equal(page.data[0].id, mention.id);
|
||||
break checkRestored;
|
||||
}
|
||||
});
|
||||
|
||||
it('Will throw for new mentions if the source page is not found', async function () {
|
||||
|
Loading…
Reference in New Issue
Block a user