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');