Added emails for recommendations (#18361)

fixes https://github.com/TryGhost/Product/issues/3938
This commit is contained in:
Simon Backx 2023-09-26 17:29:17 +02:00 committed by GitHub
parent d24c7c5fa6
commit b51e12d90f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1349 additions and 54 deletions

View File

@ -28,7 +28,10 @@ const COMMAND_GHOST = {
command: 'nx run ghost:dev', command: 'nx run ghost:dev',
cwd: path.resolve(__dirname, '../../ghost/core'), cwd: path.resolve(__dirname, '../../ghost/core'),
prefixColor: 'blue', 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 = { const COMMAND_ADMIN = {

View File

@ -18,6 +18,11 @@ function isPrivateIp(addr) {
} }
async function errorIfHostnameResolvesToPrivateIp(options) { 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 // allow requests through to local Ghost instance
const siteUrl = new URL(config.get('url')); const siteUrl = new URL(config.get('url'));
const requestUrl = new URL(options.url.href); const requestUrl = new URL(options.url.href);
@ -37,6 +42,10 @@ async function errorIfHostnameResolvesToPrivateIp(options) {
} }
async function errorIfInvalidUrl(options) { async function errorIfInvalidUrl(options) {
if (config.get('env') === 'development') {
return Promise.resolve();
}
if (!options.url.hostname || !validator.isURL(options.url.hostname)) { if (!options.url.hostname || !validator.isURL(options.url.hostname)) {
throw new errors.InternalServerError({ throw new errors.InternalServerError({
message: 'URL invalid.', message: 'URL invalid.',

View File

@ -57,7 +57,8 @@ module.exports = class BookshelfMentionRepository {
sourceExcerpt: model.get('source_excerpt'), sourceExcerpt: model.get('source_excerpt'),
sourceFavicon: model.get('source_favicon'), sourceFavicon: model.get('source_favicon'),
sourceFeaturedImage: model.get('source_featured_image'), sourceFeaturedImage: model.get('source_featured_image'),
verified: model.get('verified') verified: model.get('verified'),
deleted: model.get('deleted')
}); });
} }

View File

@ -27,6 +27,8 @@ function getPostUrl(post) {
module.exports = { module.exports = {
/** @type {import('@tryghost/webmentions/lib/MentionsAPI')} */ /** @type {import('@tryghost/webmentions/lib/MentionsAPI')} */
api: null, api: null,
/** @type {import('./BookshelfMentionRepository')} */
repository: null,
controller: new MentionController(), controller: new MentionController(),
metadata: new WebmentionMetadata(), metadata: new WebmentionMetadata(),
/** @type {import('@tryghost/webmentions/lib/MentionSendingService')} */ /** @type {import('@tryghost/webmentions/lib/MentionSendingService')} */
@ -41,6 +43,8 @@ module.exports = {
MentionModel: models.Mention, MentionModel: models.Mention,
DomainEvents DomainEvents
}); });
this.repository = repository;
const webmentionMetadata = this.metadata; const webmentionMetadata = this.metadata;
const discoveryService = new MentionDiscoveryService({externalRequest}); const discoveryService = new MentionDiscoveryService({externalRequest});
const resourceService = new ResourceService({ const resourceService = new ResourceService({

View File

@ -14,7 +14,7 @@ module.exports = class RecommendationEnablerService {
* @returns {string} * @returns {string}
*/ */
getSetting() { getSetting() {
this.#settingsService.read('recommendations_enabled'); return this.#settingsService.read('recommendations_enabled');
} }
/** /**

View File

@ -1,3 +1,7 @@
const DomainEvents = require('@tryghost/domain-events');
const {MentionCreatedEvent} = require('@tryghost/webmentions');
const logging = require('@tryghost/logging');
class RecommendationServiceWrapper { class RecommendationServiceWrapper {
/** /**
* @type {import('@tryghost/recommendations').RecommendationRepository} * @type {import('@tryghost/recommendations').RecommendationRepository}
@ -24,6 +28,11 @@ class RecommendationServiceWrapper {
*/ */
service; service;
/**
* @type {import('@tryghost/recommendations').IncomingRecommendationService}
*/
incomingRecommendationService;
init() { init() {
if (this.repository) { if (this.repository) {
return; return;
@ -40,7 +49,9 @@ class RecommendationServiceWrapper {
RecommendationService, RecommendationService,
RecommendationController, RecommendationController,
WellknownService, WellknownService,
BookshelfClickEventRepository BookshelfClickEventRepository,
IncomingRecommendationService,
IncomingRecommendationEmailRenderer
} = require('@tryghost/recommendations'); } = require('@tryghost/recommendations');
const mentions = require('../mentions'); const mentions = require('../mentions');
@ -75,26 +86,75 @@ class RecommendationServiceWrapper {
wellknownService, wellknownService,
mentionSendingService: mentions.sendingService, mentionSendingService: mentions.sendingService,
clickEventRepository: this.clickEventRepository, clickEventRepository: this.clickEventRepository,
subscribeEventRepository: this.subscribeEventRepository, subscribeEventRepository: this.subscribeEventRepository
mentionsApi: mentions.api
}); });
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({ this.controller = new RecommendationController({
service: this.service service: this.service
}); });
// eslint-disable-next-line no-console this.service.init().catch(logging.error);
this.service.init().catch(console.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 // Add mapper to WebmentionMetadata
mentions.metadata.addMapper((url) => { mentions.metadata.addMapper((url) => {
const p = '/.well-known/recommendations.json'; if (isRecommendationUrl(url)) {
if (url.pathname.endsWith(p)) {
// Strip p // Strip p
const newUrl = new URL(url.toString()); 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; 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);
}
}
});
} }
} }

View File

@ -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;\\">&nbsp;</td>
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; 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;\\">Dont 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;\\">&nbsp;</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;\\">&nbsp;</td>
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; 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;\\">Dont 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;\\">&nbsp;</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;\\">&nbsp;</td>
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; 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;\\">Dont 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;\\">&nbsp;</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",
}
`;

View File

@ -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);
});
});

View File

@ -232,7 +232,12 @@ class OEmbedService {
let scraperResponse; let scraperResponse;
try { 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) { } catch (err) {
// Log to avoid being blind to errors happenning in metascraper // Log to avoid being blind to errors happenning in metascraper
logging.error(err); logging.error(err);

View File

@ -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
});
}
};

View 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);
}
}
}

View File

@ -27,10 +27,6 @@ type MentionSendingService = {
sendAll(options: {url: URL, links: URL[]}): Promise<void> sendAll(options: {url: URL, links: URL[]}): Promise<void>
} }
type MentionsAPI = {
refreshMentions(options: {filter: string, limit: number|'all'}): Promise<void>
}
type RecommendationEnablerService = { type RecommendationEnablerService = {
getSetting(): string, getSetting(): string,
setSetting(value: string): Promise<void> setSetting(value: string): Promise<void>
@ -48,7 +44,6 @@ export class RecommendationService {
wellknownService: WellknownService; wellknownService: WellknownService;
mentionSendingService: MentionSendingService; mentionSendingService: MentionSendingService;
recommendationEnablerService: RecommendationEnablerService; recommendationEnablerService: RecommendationEnablerService;
mentionsApi: MentionsAPI;
constructor(deps: { constructor(deps: {
repository: RecommendationRepository, repository: RecommendationRepository,
@ -56,8 +51,7 @@ export class RecommendationService {
subscribeEventRepository: BookshelfRepository<string, SubscribeEvent>, subscribeEventRepository: BookshelfRepository<string, SubscribeEvent>,
wellknownService: WellknownService, wellknownService: WellknownService,
mentionSendingService: MentionSendingService, mentionSendingService: MentionSendingService,
recommendationEnablerService: RecommendationEnablerService, recommendationEnablerService: RecommendationEnablerService
mentionsApi: MentionsAPI
}) { }) {
this.repository = deps.repository; this.repository = deps.repository;
this.wellknownService = deps.wellknownService; this.wellknownService = deps.wellknownService;
@ -65,31 +59,11 @@ export class RecommendationService {
this.recommendationEnablerService = deps.recommendationEnablerService; this.recommendationEnablerService = deps.recommendationEnablerService;
this.clickEventRepository = deps.clickEventRepository; this.clickEventRepository = deps.clickEventRepository;
this.subscribeEventRepository = deps.subscribeEventRepository; this.subscribeEventRepository = deps.subscribeEventRepository;
this.mentionsApi = deps.mentionsApi;
} }
async init() { async init() {
const recommendations = await this.#listRecommendations(); const recommendations = await this.#listRecommendations();
await this.updateWellknown(recommendations); 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[]) { async updateWellknown(recommendations: Recommendation[]) {

View File

@ -9,3 +9,5 @@ export * from './ClickEvent';
export * from './BookshelfClickEventRepository'; export * from './BookshelfClickEventRepository';
export * from './SubscribeEvent'; export * from './SubscribeEvent';
export * from './BookshelfSubscribeEventRepository'; export * from './BookshelfSubscribeEventRepository';
export * from './IncomingRecommendationService';
export * from './IncomingRecommendationEmailRenderer';

View File

@ -15,7 +15,6 @@ class StaffService {
const Emails = require('./StaffServiceEmails'); const Emails = require('./StaffServiceEmails');
/** @private */
this.emails = new Emails({ this.emails = new Emails({
logging, logging,
models, models,

View File

@ -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;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; 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;">Dont want to receive these emails? Manage your preferences <a class="small" href="{{staffUrl}}" style="text-decoration: underline; color: #738A94; font-size: 11px;">here</a>.</p>
</td>
</tr>
<!-- END FOOTER -->
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,13 @@
module.exports = function (data) {
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}.
`;
};

View File

@ -56,7 +56,7 @@ module.exports = class InMemoryMentionRepository {
*/ */
async getBySourceAndTarget(source, target) { async getBySourceAndTarget(source, target) {
return this.#store.find((item) => { 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;
}); });
} }

View File

@ -30,6 +30,14 @@ module.exports = class Mention {
this.#deleted = true; 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} html
* @param {string} contentType * @param {string} contentType
@ -44,9 +52,11 @@ module.exports = class Mention {
this.#verified = hasTargetUrl; this.#verified = hasTargetUrl;
if (wasVerified && !this.#verified) { 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.#deleted = true;
this.#verified = true; this.#verified = true;
} else {
this.#undelete();
} }
} catch (e) { } catch (e) {
this.#verified = false; this.#verified = false;
@ -62,9 +72,11 @@ module.exports = class Mention {
this.#verified = !!html.includes(JSON.stringify(this.target.href)); this.#verified = !!html.includes(JSON.stringify(this.target.href));
if (wasVerified && !this.#verified) { 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.#deleted = true;
this.#verified = true; this.#verified = true;
} else {
this.#undelete();
} }
} catch (e) { } catch (e) {
this.#verified = false; this.#verified = false;
@ -217,6 +229,7 @@ module.exports = class Mention {
this.#resourceId = data.resourceId; this.#resourceId = data.resourceId;
this.#resourceType = data.resourceType; this.#resourceType = data.resourceType;
this.#verified = data.verified; this.#verified = data.verified;
this.#deleted = data.deleted || false;
} }
/** /**
@ -302,7 +315,8 @@ module.exports = class Mention {
payload, payload,
resourceId, resourceId,
resourceType, resourceType,
verified verified,
deleted: isNew ? false : !!data.deleted
}); });
mention.setSourceMetadata(data); mention.setSourceMetadata(data);

View File

@ -1,5 +1,6 @@
/** /**
* @typedef {object} MentionCreatedEventData * @typedef {object} MentionCreatedEventData
* @property {import('./Mention')} mention
*/ */
module.exports = class MentionCreatedEvent { module.exports = class MentionCreatedEvent {

View File

@ -1,4 +1,5 @@
const cheerio = require('cheerio'); const cheerio = require('cheerio');
const logging = require('@tryghost/logging');
module.exports = class MentionDiscoveryService { module.exports = class MentionDiscoveryService {
#externalRequest; #externalRequest;
@ -26,6 +27,7 @@ module.exports = class MentionDiscoveryService {
}); });
return this.getEndpointFromResponse(response); return this.getEndpointFromResponse(response);
} catch (error) { } catch (error) {
logging.error(`Error fetching ${url.href} to discover webmention endpoint`, error);
return null; return null;
} }
} }

View File

@ -183,6 +183,7 @@ module.exports = class MentionsAPI {
async #updateWebmention(mention, webmention) { async #updateWebmention(mention, webmention) {
const isNew = !mention; const isNew = !mention;
const wasDeleted = mention?.deleted ?? false;
const targetExists = await this.#routingService.pageExists(webmention.target); const targetExists = await this.#routingService.pageExists(webmention.target);
if (!targetExists) { if (!targetExists) {
@ -235,23 +236,23 @@ module.exports = class MentionsAPI {
} }
if (metadata?.body) { if (metadata?.body) {
try { mention.verify(metadata.body, metadata.contentType);
mention.verify(metadata.body, metadata.contentType);
} catch (e) {
logging.error(e);
}
} }
} }
await this.#repository.save(mention); await this.#repository.save(mention);
if (isNew) { 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 { } else {
if (mention.deleted) { if (mention.deleted && !wasDeleted) {
logging.info('[Webmention] Deleted ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified); logging.info('[Webmention] Deleted ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified);
} else { } 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);
}
} }
} }

View File

@ -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 () { describe('create', function () {
it('Will error with invalid inputs', async function () { it('Will error with invalid inputs', async function () {
const invalidInputs = [ const invalidInputs = [

View File

@ -29,7 +29,8 @@ const mockWebmentionMetadata = {
author: 'Dr Egg Man', author: 'Dr Egg Man',
image: new URL('https://unsplash.com/photos/QAND9huzD04'), image: new URL('https://unsplash.com/photos/QAND9huzD04'),
favicon: new URL('https://ghost.org/favicon.ico'), 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 repository = new InMemoryMentionRepository();
const api = new MentionsAPI({ const api = new MentionsAPI({
repository, repository,
@ -449,6 +450,7 @@ describe('MentionsAPI', function () {
fetch: sinon.stub() fetch: sinon.stub()
.onFirstCall().resolves(mockWebmentionMetadata.fetch()) .onFirstCall().resolves(mockWebmentionMetadata.fetch())
.onSecondCall().rejects() .onSecondCall().rejects()
.onThirdCall().resolves(mockWebmentionMetadata.fetch())
} }
}); });
@ -481,6 +483,88 @@ describe('MentionsAPI', function () {
assert.equal(page.data.length, 0); assert.equal(page.data.length, 0);
break checkMentionDeleted; 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 () { it('Will throw for new mentions if the source page is not found', async function () {