From dd74f423768e0acea856527f94c38b67f9fb7f64 Mon Sep 17 00:00:00 2001 From: Ronald Langeveld Date: Wed, 25 Jan 2023 21:10:29 +0800 Subject: [PATCH] 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. --- .../mentions/BookshelfMentionRepository.js | 8 ++ .../core/server/services/mentions/service.js | 7 +- .../core/core/server/services/staff/index.js | 5 +- .../e2e-api/webmentions/webmentions.test.js | 94 +++++++++++++++++++ .../email-templates/new-mention-received.hbs | 54 +++++++++++ .../new-mention-received.txt.js | 11 +++ ghost/staff-service/lib/emails.js | 27 ++++++ ghost/staff-service/lib/staff-service.js | 17 +++- .../staff-service/test/staff-service.test.js | 29 +++++- ghost/webmentions/lib/Mention.js | 13 ++- ghost/webmentions/lib/MentionCreatedEvent.js | 22 +++++ ghost/webmentions/lib/MentionsAPI.js | 1 - ghost/webmentions/lib/webmentions.js | 1 + 13 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 ghost/staff-service/lib/email-templates/new-mention-received.hbs create mode 100644 ghost/staff-service/lib/email-templates/new-mention-received.txt.js create mode 100644 ghost/webmentions/lib/MentionCreatedEvent.js diff --git a/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js b/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js index 511f38028c..204519d853 100644 --- a/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js +++ b/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js @@ -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); + } } }; diff --git a/ghost/core/core/server/services/mentions/service.js b/ghost/core/core/server/services/mentions/service.js index f72f3cc16b..f9a6b28193 100644 --- a/ghost/core/core/server/services/mentions/service.js +++ b/ghost/core/core/server/services/mentions/service.js @@ -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, diff --git a/ghost/core/core/server/services/staff/index.js b/ghost/core/core/server/services/staff/index.js index a845eecb31..8ad4632d63 100644 --- a/ghost/core/core/server/services/staff/index.js +++ b/ghost/core/core/server/services/staff/index.js @@ -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(); diff --git a/ghost/core/test/e2e-api/webmentions/webmentions.test.js b/ghost/core/test/e2e-api/webmentions/webmentions.test.js index 7601959014..5b4eabe7bd 100644 --- a/ghost/core/test/e2e-api/webmentions/webmentions.test.js +++ b/ghost/core/test/e2e-api/webmentions/webmentions.test.js @@ -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 = ` + Test Page + `; + 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 = ` + Test Page + `; + 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 = ` + Test Page + `; + 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); + }); }); diff --git a/ghost/staff-service/lib/email-templates/new-mention-received.hbs b/ghost/staff-service/lib/email-templates/new-mention-received.hbs new file mode 100644 index 0000000000..a0c37527ba --- /dev/null +++ b/ghost/staff-service/lib/email-templates/new-mention-received.hbs @@ -0,0 +1,54 @@ + + + + + + + + + + + + + +
+ + + + + +
+ +
+ + + + +
+ + +

You have been mentioned by {{sourceUrl}}!

+ +

Team Ghost
+ https://ghost.org

+ + +
+
+
+ + + + + +
+ +
+ +
+ + + + diff --git a/ghost/staff-service/lib/email-templates/new-mention-received.txt.js b/ghost/staff-service/lib/email-templates/new-mention-received.txt.js new file mode 100644 index 0000000000..042bb0666d --- /dev/null +++ b/ghost/staff-service/lib/email-templates/new-mention-received.txt.js @@ -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}. + `; +}; diff --git a/ghost/staff-service/lib/emails.js b/ghost/staff-service/lib/emails.js index 8ac37d692b..ef3b57a31e 100644 --- a/ghost/staff-service/lib/emails.js +++ b/ghost/staff-service/lib/emails.js @@ -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 */ diff --git a/ghost/staff-service/lib/staff-service.js b/ghost/staff-service/lib/staff-service.js index a93c921eaf..b713d29834 100644 --- a/ghost/staff-service/lib/staff-service.js +++ b/ghost/staff-service/lib/staff-service.js @@ -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`); + } + }); } } diff --git a/ghost/staff-service/test/staff-service.test.js b/ghost/staff-service/test/staff-service.test.js index a7d0516720..9d194abac4 100644 --- a/ghost/staff-service/test/staff-service.test.js +++ b/ghost/staff-service/test/staff-service.test.js @@ -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 () { diff --git a/ghost/webmentions/lib/Mention.js b/ghost/webmentions/lib/Mention.js index 9596500f5a..e0d7c21ca4 100644 --- a/ghost/webmentions/lib/Mention.js +++ b/ghost/webmentions/lib/Mention.js @@ -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; } }; diff --git a/ghost/webmentions/lib/MentionCreatedEvent.js b/ghost/webmentions/lib/MentionCreatedEvent.js new file mode 100644 index 0000000000..1b6483a8d1 --- /dev/null +++ b/ghost/webmentions/lib/MentionCreatedEvent.js @@ -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); + } +}; diff --git a/ghost/webmentions/lib/MentionsAPI.js b/ghost/webmentions/lib/MentionsAPI.js index 98cda37cb7..cedbff8062 100644 --- a/ghost/webmentions/lib/MentionsAPI.js +++ b/ghost/webmentions/lib/MentionsAPI.js @@ -161,7 +161,6 @@ module.exports = class MentionsAPI { sourceFeaturedImage: metadata.image }); } - await this.#repository.save(mention); return mention; diff --git a/ghost/webmentions/lib/webmentions.js b/ghost/webmentions/lib/webmentions.js index f8d0787140..c68ca28130 100644 --- a/ghost/webmentions/lib/webmentions.js +++ b/ghost/webmentions/lib/webmentions.js @@ -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');