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:
parent
c630fae60e
commit
dd74f42376
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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}.
|
||||
`;
|
||||
};
|
@ -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 */
|
||||
|
@ -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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 () {
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
22
ghost/webmentions/lib/MentionCreatedEvent.js
Normal file
22
ghost/webmentions/lib/MentionCreatedEvent.js
Normal 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);
|
||||
}
|
||||
};
|
@ -161,7 +161,6 @@ module.exports = class MentionsAPI {
|
||||
sourceFeaturedImage: metadata.image
|
||||
});
|
||||
}
|
||||
|
||||
await this.#repository.save(mention);
|
||||
|
||||
return mention;
|
||||
|
@ -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');
|
||||
|
Loading…
Reference in New Issue
Block a user