Added @tryghost/mentions-email-report package

This package contains the business logic for the sending of mention
report emails, it could eventually be included in the webmentions
package I think, but has been kept separate for now in favour of
smaller packages.
This commit is contained in:
Fabien "egg" O'Carroll 2023-03-12 23:05:28 +07:00 committed by Rishabh Garg
parent b8b0d1538e
commit c56b819748
7 changed files with 341 additions and 0 deletions

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View File

@ -0,0 +1,21 @@
# Mentions Email Report
## Usage
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View File

@ -0,0 +1 @@
module.exports = require('./lib/MentionEmailReportJob');

View File

@ -0,0 +1,117 @@
module.exports = class MentionEmailReportJob {
/** @type {IMentionReportGenerator} */
#mentionReportGenerator;
/** @type {IMentionReportRecipientRepository} */
#mentionReportRecipientRepository;
/** @type {IMentionReportEmailView} */
#mentionReportEmailView;
/** @type {IMentionReportHistoryService} */
#mentionReportHistoryService;
/** @type {IEmailService} */
#emailService;
/**
* @param {object} deps
* @param {IMentionReportGenerator} deps.mentionReportGenerator
* @param {IMentionReportRecipientRepository} deps.mentionReportRecipientRepository
* @param {IMentionReportEmailView} deps.mentionReportEmailView
* @param {IMentionReportHistoryService} deps.mentionReportHistoryService
* @param {IEmailService} deps.emailService
*/
constructor(deps) {
this.#mentionReportGenerator = deps.mentionReportGenerator;
this.#mentionReportRecipientRepository = deps.mentionReportRecipientRepository;
this.#mentionReportEmailView = deps.mentionReportEmailView;
this.#mentionReportHistoryService = deps.mentionReportHistoryService;
this.#emailService = deps.emailService;
}
/**
* Checks for new mentions since the last report and sends an email to the recipients.
*
* @returns {Promise<number>} - A promise that resolves with the number of mentions found.
*/
async sendLatestReport() {
const lastReport = await this.#mentionReportHistoryService.getLatestReportDate();
const now = new Date();
if (now.valueOf() - lastReport.valueOf() < 24 * 60 * 60 * 1000) {
return 0;
}
const report = await this.#mentionReportGenerator.getMentionReport(lastReport, now);
report.mentions = report.mentions.map((mention) => {
return {
targetUrl: mention.target,
sourceUrl: mention.source,
sourceTitle: mention.sourceTitle,
sourceExcerpt: mention.sourceExcerpt,
sourceSiteTitle: mention.sourceSiteTitle,
sourceFavicon: mention.sourceFavicon,
sourceAuthor: mention.sourceAuthor,
sourceFeaturedImage: mention.sourceFeaturedImage
};
});
if (!report?.mentions?.length) {
return 0;
}
const recipients = await this.#mentionReportRecipientRepository.getMentionReportRecipients();
for (const recipient of recipients) {
const subject = await this.#mentionReportEmailView.renderSubject(report, recipient);
const html = await this.#mentionReportEmailView.renderHTML(report, recipient);
const text = await this.#mentionReportEmailView.renderText(report, recipient);
await this.#emailService.send(recipient.email, subject, html, text);
}
await this.#mentionReportHistoryService.setLatestReportDate(now);
return report.mentions.length;
}
};
/**
* @typedef {object} MentionReportRecipient
* @prop {string} email
* @prop {string} slug
*/
/**
* @typedef {object} IMentionReportRecipientRepository
* @prop {() => Promise<MentionReportRecipient[]>} getMentionReportRecipients
*/
/**
* @typedef {import('@tryghost/webmentions/lib/MentionsAPI').MentionReport} MentionReport
*/
/**
* @typedef {object} IMentionReportGenerator
* @prop {(startDate: Date, endDate: Date) => Promise<MentionReport>} getMentionReport
*/
/**
* @typedef {object} IMentionReportEmailView
* @prop {(report: MentionReport, recipient: MentionReportRecipient) => Promise<string>} renderHTML
* @prop {(report: MentionReport, recipient: MentionReportRecipient) => Promise<string>} renderText
* @prop {(report: MentionReport, recipient: MentionReportRecipient) => Promise<string>} renderSubject
*/
/**
* @typedef {object} IEmailService
* @prop {(to: string, subject: string, html: string, text: string) => Promise<void>} send
*/
/**
* @typedef {object} IMentionReportHistoryService
* @prop {() => Promise<Date>} getLatestReportDate
* @prop {(date: Date) => Promise<void>} setLatestReportDate
*/

View File

@ -0,0 +1,26 @@
{
"name": "@tryghost/mentions-email-report",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/mentions-email-report",
"author": "Ghost Foundation",
"private": true,
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --100 --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"test": "yarn test:unit",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
},
"files": [
"index.js",
"lib"
],
"devDependencies": {
"c8": "7.13.0",
"mocha": "10.2.0",
"sinon": "15.0.1"
},
"dependencies": {}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -0,0 +1,164 @@
const sinon = require('sinon');
const MentionEmailReportJob = require('../');
class MockMentionReportRecipientRepository {
#recipients = [{
email: 'fake@email.address',
slug: 'user-slug'
}];
constructor(recipients) {
if (recipients) {
this.#recipients = recipients;
}
}
async getMentionReportRecipients() {
return this.#recipients;
}
}
class MockMentionReportEmailView {
async renderSubject() {
return 'Mention Report';
}
async renderHTML() {
return '<h1>Mention Report</h1>';
}
async renderText() {
return 'Mention Report';
}
}
class MockEmailService {
async send() {
return;
}
}
class MockMentionReportHistoryService {
#date = null;
constructor(date) {
if (!date) {
throw new Error('Missing date');
}
this.#date = date;
}
async getLatestReportDate() {
return this.#date;
}
async setLatestReportDate(date) {
this.#date = date;
}
}
class MockMentionReportGenerator {
#mentions = null;
constructor(mentions) {
if (!mentions) {
throw new Error('Missing mentions');
}
this.#mentions = mentions;
}
async getMentionReport(startDate, endDate) {
return {
startDate,
endDate,
mentions: this.#mentions
};
}
}
describe('MentionEmailReportJob', function () {
describe('sendLatestReport', function () {
it('Does not send an email if the report has no mentions', async function () {
const emailService = new MockEmailService();
const mock = sinon.mock(emailService);
mock.expects('send').never();
const job = new MentionEmailReportJob({
mentionReportGenerator: new MockMentionReportGenerator([]),
mentionReportRecipientRepository: new MockMentionReportRecipientRepository(),
mentionReportEmailView: new MockMentionReportEmailView(),
mentionReportHistoryService: new MockMentionReportHistoryService(new Date(0)),
emailService: emailService
});
await job.sendLatestReport();
mock.verify();
});
it('Does not send an email if the last email was sent within 24 hours', async function () {
const emailService = new MockEmailService();
const mock = sinon.mock(emailService);
mock.expects('send').never();
const job = new MentionEmailReportJob({
mentionReportGenerator: new MockMentionReportGenerator([{
target: new URL('https://target.com'),
source: new URL('https://source.com'),
sourceTitle: 'Source Title',
sourceExcerpt: 'Source Excerpt',
sourceSiteTitle: 'Source Site Title',
sourceFavicon: new URL('https://source.com/favicon.ico'),
sourceAuthor: 'Source Author',
sourceFeaturedImage: new URL('https://source.com/featured-image.jpg')
}]),
mentionReportRecipientRepository: new MockMentionReportRecipientRepository(),
mentionReportEmailView: new MockMentionReportEmailView(),
mentionReportHistoryService: new MockMentionReportHistoryService(new Date()),
emailService: emailService
});
await job.sendLatestReport();
mock.verify();
});
it('Sends an email if the last email was sent more than 24 hours ago', async function () {
const emailService = new MockEmailService();
const mock = sinon.mock(emailService);
mock.expects('send').once().alwaysCalledWith(
'fake@email.address',
'Mention Report',
'<h1>Mention Report</h1>',
'Mention Report'
);
const job = new MentionEmailReportJob({
mentionReportGenerator: new MockMentionReportGenerator([{
target: new URL('https://target.com'),
source: new URL('https://source.com'),
sourceTitle: 'Source Title',
sourceExcerpt: 'Source Excerpt',
sourceSiteTitle: 'Source Site Title',
sourceFavicon: new URL('https://source.com/favicon.ico'),
sourceAuthor: 'Source Author',
sourceFeaturedImage: new URL('https://source.com/featured-image.jpg')
}]),
mentionReportRecipientRepository: new MockMentionReportRecipientRepository(),
mentionReportEmailView: new MockMentionReportEmailView(),
mentionReportHistoryService: new MockMentionReportHistoryService(new Date(0)),
emailService
});
await job.sendLatestReport();
mock.verify();
});
});
});