From c56b819748757d9becc8be8a91f6fe05009a7cc9 Mon Sep 17 00:00:00 2001 From: "Fabien \"egg\" O'Carroll" Date: Sun, 12 Mar 2023 23:05:28 +0700 Subject: [PATCH] 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. --- ghost/mentions-email-report/.eslintrc.js | 6 + ghost/mentions-email-report/README.md | 21 +++ ghost/mentions-email-report/index.js | 1 + .../lib/MentionEmailReportJob.js | 117 +++++++++++++ ghost/mentions-email-report/package.json | 26 +++ ghost/mentions-email-report/test/.eslintrc.js | 6 + .../mentions-email-report/test/hello.test.js | 164 ++++++++++++++++++ 7 files changed, 341 insertions(+) create mode 100644 ghost/mentions-email-report/.eslintrc.js create mode 100644 ghost/mentions-email-report/README.md create mode 100644 ghost/mentions-email-report/index.js create mode 100644 ghost/mentions-email-report/lib/MentionEmailReportJob.js create mode 100644 ghost/mentions-email-report/package.json create mode 100644 ghost/mentions-email-report/test/.eslintrc.js create mode 100644 ghost/mentions-email-report/test/hello.test.js diff --git a/ghost/mentions-email-report/.eslintrc.js b/ghost/mentions-email-report/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/mentions-email-report/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/mentions-email-report/README.md b/ghost/mentions-email-report/README.md new file mode 100644 index 0000000000..7ea9ac2735 --- /dev/null +++ b/ghost/mentions-email-report/README.md @@ -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 + diff --git a/ghost/mentions-email-report/index.js b/ghost/mentions-email-report/index.js new file mode 100644 index 0000000000..d3614d6586 --- /dev/null +++ b/ghost/mentions-email-report/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/MentionEmailReportJob'); diff --git a/ghost/mentions-email-report/lib/MentionEmailReportJob.js b/ghost/mentions-email-report/lib/MentionEmailReportJob.js new file mode 100644 index 0000000000..23b480fddf --- /dev/null +++ b/ghost/mentions-email-report/lib/MentionEmailReportJob.js @@ -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} - 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} getMentionReportRecipients + */ + +/** + * @typedef {import('@tryghost/webmentions/lib/MentionsAPI').MentionReport} MentionReport + */ + +/** + * @typedef {object} IMentionReportGenerator + * @prop {(startDate: Date, endDate: Date) => Promise} getMentionReport + */ + +/** + * @typedef {object} IMentionReportEmailView + * @prop {(report: MentionReport, recipient: MentionReportRecipient) => Promise} renderHTML + * @prop {(report: MentionReport, recipient: MentionReportRecipient) => Promise} renderText + * @prop {(report: MentionReport, recipient: MentionReportRecipient) => Promise} renderSubject + */ + +/** + * @typedef {object} IEmailService + * @prop {(to: string, subject: string, html: string, text: string) => Promise} send + */ + +/** + * @typedef {object} IMentionReportHistoryService + * @prop {() => Promise} getLatestReportDate + * @prop {(date: Date) => Promise} setLatestReportDate + */ diff --git a/ghost/mentions-email-report/package.json b/ghost/mentions-email-report/package.json new file mode 100644 index 0000000000..6a2b8456a5 --- /dev/null +++ b/ghost/mentions-email-report/package.json @@ -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": {} +} diff --git a/ghost/mentions-email-report/test/.eslintrc.js b/ghost/mentions-email-report/test/.eslintrc.js new file mode 100644 index 0000000000..829b601eb0 --- /dev/null +++ b/ghost/mentions-email-report/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/mentions-email-report/test/hello.test.js b/ghost/mentions-email-report/test/hello.test.js new file mode 100644 index 0000000000..a33aa61d63 --- /dev/null +++ b/ghost/mentions-email-report/test/hello.test.js @@ -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 '

Mention Report

'; + } + + 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', + '

Mention Report

', + '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(); + }); + }); +});