Added mentions email notifications (#16170)

closes https://github.com/TryGhost/Team/issues/2429

- sends email notifications to staff users when their site receives a Webmention.
- currently behind a flag, that can be toggled in the labs settings.
This commit is contained in:
Ronald Langeveld 2023-01-25 21:10:29 +08:00 committed by GitHub
parent c630fae60e
commit dd74f42376
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 280 additions and 9 deletions

View File

@ -21,12 +21,17 @@ module.exports = class BookshelfMentionRepository {
/** @type {Object} */
#MentionModel;
/** @type {import('@tryghost/domain-events')} */
#DomainEvents;
/**
* @param {object} deps
* @param {object} deps.MentionModel Bookshelf Model
* @param {import('@tryghost/domain-events')} deps.DomainEvents
*/
constructor(deps) {
this.#MentionModel = deps.MentionModel;
this.#DomainEvents = deps.DomainEvents;
}
#modelToMention(model) {
@ -113,5 +118,8 @@ module.exports = class BookshelfMentionRepository {
id: data.id
});
}
for (const event of mention.events) {
this.#DomainEvents.dispatch(event);
}
}
};

View File

@ -8,7 +8,6 @@ const {
const BookshelfMentionRepository = require('./BookshelfMentionRepository');
const ResourceService = require('./ResourceService');
const RoutingService = require('./RoutingService');
const models = require('../../models');
const events = require('../../lib/common/events');
const externalRequest = require('../../../server/lib/request-external.js');
@ -16,17 +15,20 @@ const urlUtils = require('../../../shared/url-utils');
const outputSerializerUrlUtil = require('../../../server/api/endpoints/utils/serializers/output/utils/url');
const labs = require('../../../shared/labs');
const urlService = require('../url');
const DomainEvents = require('@tryghost/domain-events');
function getPostUrl(post) {
const jsonModel = {};
outputSerializerUrlUtil.forPost(post.id, jsonModel, {options: {}});
return jsonModel.url;
}
module.exports = {
controller: new MentionController(),
async init() {
const repository = new BookshelfMentionRepository({
MentionModel: models.Mention
MentionModel: models.Mention,
DomainEvents
});
const webmentionMetadata = new WebmentionMetadata();
const discoveryService = new MentionDiscoveryService({externalRequest});
@ -34,6 +36,7 @@ module.exports = {
urlUtils,
urlService
});
const routingService = new RoutingService({
siteUrl: new URL(urlUtils.getSiteUrl()),
resourceService,

View File

@ -1,4 +1,6 @@
const DomainEvents = require('@tryghost/domain-events');
const labs = require('../../../shared/labs');
class StaffServiceWrapper {
init() {
if (this.api) {
@ -23,7 +25,8 @@ class StaffServiceWrapper {
settingsHelpers,
settingsCache,
urlUtils,
DomainEvents
DomainEvents,
labs
});
this.api.subscribeEvents();

View File

@ -6,14 +6,108 @@ const nock = require('nock');
describe('Webmentions (receiving)', function () {
let agent;
let emailMockReceiver;
before(async function () {
agent = await agentProvider.getWebmentionsAPIAgent();
await fixtureManager.init('posts');
nock.disableNetConnect();
mockManager.mockLabsEnabled('webmentionEmail');
});
after(function () {
nock.cleanAll();
nock.enableNetConnect();
});
beforeEach(function () {
emailMockReceiver = mockManager.mockMail();
});
afterEach(function () {
mockManager.restore();
});
it('can receive a webmention', async function () {
const url = new URL('http://testpage.com/external-article/');
const html = `
<html><head><title>Test Page</title><meta name="description" content="Test description"><meta name="author" content="John Doe"></head><body></body></html>
`;
nock(url.href)
.get('/')
.reply(200, html, {'content-type': 'text/html'});
await agent.post('/receive')
.body({
source: 'http://testpage.com/external-article/',
target: urlUtils.getSiteUrl() + 'integrations/',
withExtension: true // test payload recorded
})
.expectStatus(202);
// todo: remove sleep in future
await sleep(2000);
const mention = await models.Mention.findOne({source: 'http://testpage.com/external-article/'});
assert(mention);
assert.equal(mention.get('target'), urlUtils.getSiteUrl() + 'integrations/');
assert.ok(mention.get('resource_id'));
assert.equal(mention.get('resource_type'), 'post');
assert.equal(mention.get('source_title'), 'Test Page');
assert.equal(mention.get('source_excerpt'), 'Test description');
assert.equal(mention.get('source_author'), 'John Doe');
assert.equal(mention.get('payload'), JSON.stringify({
withExtension: true
}));
});
it('can send an email notification for a new webmention', async function () {
const url = new URL('http://testpage.com/external-article-123-email-test/');
const html = `
<html><head><title>Test Page</title><meta name="description" content="Test description"><meta name="author" content="John Doe"></head><body></body></html>
`;
nock(url.href)
.get('/')
.reply(200, html, {'content-type': 'text/html'});
await agent.post('/receive/')
.body({
source: 'http://testpage.com/external-article-123-email-test/',
target: urlUtils.getSiteUrl() + 'integrations/',
withExtension: true // test payload recorded
})
.expectStatus(202);
await sleep(2000);
const users = await models.User.findAll();
users.forEach(async (user) => {
await mockManager.assert.sentEmail({
subject: 'You\'ve been mentioned!',
to: user.toJSON().email
});
});
emailMockReceiver.sentEmailCount(users.length);
});
it('does not send notification with flag disabled', async function () {
mockManager.mockLabsDisabled('webmentionEmail');
const url = new URL('http://testpage.com/external-article-123-email-test/');
const html = `
<html><head><title>Test Page</title><meta name="description" content="Test description"><meta name="author" content="John Doe"></head><body></body></html>
`;
nock(url.href)
.get('/')
.reply(200, html, {'content-type': 'text/html'});
await agent.post('/receive/')
.body({
source: 'http://testpage.com/external-article-123-email-test/',
target: urlUtils.getSiteUrl() + 'integrations/',
withExtension: true // test payload recorded
})
.expectStatus(202);
await sleep(2000);
emailMockReceiver.sentEmailCount(0);
});
});

View File

@ -0,0 +1,54 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN " "http://www.w3.org/TR/html4/loose.dtd">
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body bgcolor="#ffffff" topmargin="0" leftmargin="0" marginheight="0" marginwidth="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; background: #ffffff; color: #808284; font-family: sans-serif; font-size: 15px; line-height: 1.5; margin: 0; width: 100%;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#ffffff">
<tr>
<td bgcolor="#ffffff" width="100%">
<table class="main-wrapper" width="600" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="#ffffff">
<tr>
<td class="cell" width="100%">
<div class="wrapper" style="-moz-border-radius: 3px; -webkit-border-radius: 3px; border: #e5e3d8 1px solid; border-radius: 3px; margin: 2%; padding: 5% 8%;">
<table class="content" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="content-cell" width="100%">
<!-- START OF EMAIL CONTENT -->
<p style="color: #808284; font-family: sans-serif; font-size: 15px; font-weight: normal; line-height: 1.5em; margin: 0; padding: 0 0 1.5em 0;"><strong>You have been mentioned by {{sourceUrl}}!</strong></p>
<p style="color: #808284; font-family: sans-serif; font-size: 15px; font-weight: normal; line-height: 1.5em; margin: 0; padding: 0 0 1.5em 0;">Team Ghost<br>
<a href="https://ghost.org" style="color: #5ba4e5;">https://ghost.org</a></p>
<!-- END OF EMAIL CONTENT -->
</td>
</tr>
</table>
</div>
<div class="container" style="padding: 0 4%;">
<table class="footer" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="footer-cell" align="right" style="color: #888888; font-family: sans-serif; font-size: 11px; line-height: 1.3; padding: 0 0 20px 0;">
Sent by <a href="{{siteUrl}}" style="color: #5ba4e5;">{{siteUrl}}</a>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,11 @@
module.exports = function (data) {
// Be careful when you indent the email, because whitespaces are visible in emails!
return `
You have been mentioned by ${data.sourceUrl}.
---
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

@ -142,6 +142,33 @@ class StaffServiceEmails {
}
}
async notifyMentionReceived({mention}) {
const users = await this.models.User.findAll(); // sending to all staff users for now
for (const user of users) {
const to = user.toJSON().email;
const subject = `You've been mentioned!`;
const templateData = {
sourceUrl: mention.source,
siteTitle: this.settingsCache.get('title'),
siteUrl: this.urlUtils.getSiteUrl(),
siteDomain: this.siteDomain,
accentColor: this.settingsCache.get('accent_color'),
fromEmail: this.fromEmailAddress,
toEmail: to,
staffUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.toJSON().slug}`)
};
const {html, text} = await this.renderEmailTemplate('new-mention-received', templateData);
await this.sendMail({
to,
subject,
html,
text
});
}
}
// Utils
/** @private */

View File

@ -1,11 +1,12 @@
const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionCreatedEvent} = require('@tryghost/member-events');
const {MentionCreatedEvent} = require('@tryghost/webmentions');
// @NOTE: 'StaffService' is a vague name that does not describe what it's actually doing.
// Possibly, "StaffNotificationService" or "StaffEventNotificationService" would be a more accurate name
class StaffService {
constructor({logging, models, mailer, settingsCache, settingsHelpers, urlUtils, DomainEvents}) {
constructor({logging, models, mailer, settingsCache, settingsHelpers, urlUtils, DomainEvents, labs}) {
this.logging = logging;
this.labs = labs;
/** @private */
this.settingsCache = settingsCache;
this.models = models;
@ -76,6 +77,9 @@ class StaffService {
/** @private */
async handleEvent(type, event) {
if (type === MentionCreatedEvent && event.data.mention && this.labs.isSet('webmentionEmail')) {
await this.emails.notifyMentionReceived(event.data);
}
if (!['api', 'member'].includes(event.data.source)) {
return;
}
@ -133,6 +137,15 @@ class StaffService {
this.logging.error(`Failed to notify paid member subscription cancel - ${event?.data?.memberId}`);
}
});
// Trigger email when a new webmention is received
this.DomainEvents.subscribe(MentionCreatedEvent, async (event) => {
try {
await this.handleEvent(MentionCreatedEvent, event);
} catch (e) {
this.logging.error(`Failed to notify webmention`);
}
});
}
}

View File

@ -2,6 +2,7 @@
// const testUtils = require('./utils');
const sinon = require('sinon');
const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionCreatedEvent} = require('@tryghost/member-events');
const {MentionCreatedEvent} = require('@tryghost/webmentions');
require('./utils');
const StaffService = require('../lib/staff-service');
@ -181,10 +182,11 @@ describe('StaffService', function () {
describe('subscribeEvents', function () {
it('subscribes to events', async function () {
service.subscribeEvents();
subscribeStub.calledThrice.should.be.true();
subscribeStub.callCount.should.eql(4);
subscribeStub.calledWith(SubscriptionCreatedEvent).should.be.true();
subscribeStub.calledWith(SubscriptionCancelledEvent).should.be.true();
subscribeStub.calledWith(MemberCreatedEvent).should.be.true();
subscribeStub.calledWith(MentionCreatedEvent).should.be.true();
});
});
@ -195,6 +197,12 @@ describe('StaffService', function () {
getEmailAlertUsers: sinon.stub().resolves([{
email: 'owner@ghost.org',
slug: 'ghost'
}]),
findAll: sinon.stub().resolves([{
toJSON: sinon.stub().returns({
email: 'owner@ghost.org',
slug: 'ghost'
})
}])
},
Member: {
@ -259,7 +267,10 @@ describe('StaffService', function () {
},
settingsCache,
urlUtils,
settingsHelpers
settingsHelpers,
labs: {
isSet: () => 'webmentionEmail'
}
});
});
it('handles free member created event', async function () {
@ -305,6 +316,20 @@ describe('StaffService', function () {
sinon.match({subject: '⚠️ Cancellation: Jamie'})
).should.be.true();
});
it('handles new mention notification', async function () {
await service.handleEvent(MentionCreatedEvent, {
data: {
mention: {
source: 'https://exmaple.com/some-post',
target: 'https://exmaple.com/some-mentioned-post'
}
}
});
mailStub.calledWith(
sinon.match({subject: `You've been mentioned!`})
).should.be.true();
});
});
describe('notifyFreeMemberSignup', function () {

View File

@ -1,7 +1,11 @@
const ObjectID = require('bson-objectid').default;
const {ValidationError} = require('@tryghost/errors');
const MentionCreatedEvent = require('./MentionCreatedEvent');
module.exports = class Mention {
/** @type {Array} */
events = [];
/** @type {ObjectID} */
#id;
get id() {
@ -114,7 +118,9 @@ module.exports = class Mention {
static async create(data) {
/** @type ObjectID */
let id;
let isNew = false;
if (!data.id) {
isNew = true;
id = new ObjectID();
} else if (typeof data.id === 'string') {
id = ObjectID.createFromHexString(data.id);
@ -198,7 +204,7 @@ module.exports = class Mention {
sourceFeaturedImage = new URL(data.sourceFeaturedImage);
}
return new Mention({
const mention = new Mention({
id,
source,
target,
@ -212,6 +218,11 @@ module.exports = class Mention {
sourceFavicon,
sourceFeaturedImage
});
if (isNew) {
mention.events.push(MentionCreatedEvent.create({mention}));
}
return mention;
}
};

View File

@ -0,0 +1,22 @@
/**
* @typedef {object} MentionCreatedEventData
*/
module.exports = class MentionCreatedEvent {
/**
* @param {MentionCreatedEventData} data
* @param {Date} timestamp
*/
constructor(data, timestamp) {
this.data = data;
this.timestamp = timestamp;
}
/**
* @param {MentionCreatedEventData} data
* @param {Date} [timestamp]
*/
static create(data, timestamp) {
return new MentionCreatedEvent(data, timestamp ?? new Date);
}
};

View File

@ -161,7 +161,6 @@ module.exports = class MentionsAPI {
sourceFeaturedImage: metadata.image
});
}
await this.#repository.save(mention);
return mention;

View File

@ -3,3 +3,4 @@ module.exports.MentionsAPI = require('./MentionsAPI');
module.exports.MentionDiscoveryService = require('./MentionDiscoveryService');
module.exports.Mention = require('./Mention');
module.exports.MentionSendingService = require('./MentionSendingService');
module.exports.MentionCreatedEvent = require('./MentionCreatedEvent');