diff --git a/ghost/core/core/server/services/bulk-email/bulk-email-processor.js b/ghost/core/core/server/services/bulk-email/bulk-email-processor.js index 183c3c4711..d27a9fd352 100644 --- a/ghost/core/core/server/services/bulk-email/bulk-email-processor.js +++ b/ghost/core/core/server/services/bulk-email/bulk-email-processor.js @@ -5,17 +5,19 @@ const errors = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const logging = require('@tryghost/logging'); const models = require('../../models'); -const mailgunProvider = require('./mailgun'); +const MailgunClient = require('@tryghost/mailgun-client'); const sentry = require('../../../shared/sentry'); const labs = require('../../../shared/labs'); const debug = require('@tryghost/debug')('mega'); const postEmailSerializer = require('../mega/post-email-serializer'); +const configService = require('../../../shared/config'); +const settingsCache = require('../../../shared/settings-cache'); const messages = { error: 'The email service was unable to send an email batch.' }; -const BATCH_SIZE = mailgunProvider.BATCH_SIZE; +const mailgunClient = new MailgunClient({config: configService, settings: settingsCache}); /** * An object representing batch request result @@ -64,7 +66,7 @@ class FailedBatch extends BatchResultBase { */ module.exports = { - BATCH_SIZE, + BATCH_SIZE: MailgunClient.BATCH_SIZE, SuccessfulBatch, FailedBatch, @@ -211,11 +213,12 @@ module.exports = { * @param {Email-like} emailData - The email to send, must be a POJO so emailModel.toJSON() before calling if needed * @param {[EmailRecipient]} recipients - The recipients to send the email to with their associated data * @param {string?} memberSegment - The member segment of the recipients - * @returns {Object} - {providerId: 'xxx'} + * @returns {Promise} - {providerId: 'xxx'} */ - send(emailData, recipients, memberSegment) { - const mailgunInstance = mailgunProvider.getInstance(); - if (!mailgunInstance) { + async send(emailData, recipients, memberSegment) { + const mailgunConfigured = mailgunClient.isConfigured(); + if (!mailgunConfigured) { + logging.warn('Bulk email has not been configured'); return; } @@ -247,10 +250,11 @@ module.exports = { emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment); - return mailgunProvider.send(emailData, recipientData, replacements).then((response) => { + try { + const response = await mailgunClient.send(emailData, recipientData, replacements); debug(`sent message (${Date.now() - startTime}ms)`); return response; - }).catch((error) => { + } catch (error) { // REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#errors let ghostError = new errors.EmailError({ err: error, @@ -263,6 +267,9 @@ module.exports = { debug(`failed to send message (${Date.now() - startTime}ms)`); throw ghostError; - }); - } + } + }, + + // NOTE: for testing only! + _mailgunClient: mailgunClient }; diff --git a/ghost/core/core/server/services/bulk-email/index.js b/ghost/core/core/server/services/bulk-email/index.js index 6b7f0aef4e..0c78693227 100644 --- a/ghost/core/core/server/services/bulk-email/index.js +++ b/ghost/core/core/server/services/bulk-email/index.js @@ -1,17 +1 @@ -const { - BATCH_SIZE, - SuccessfulBatch, - FailedBatch, - processEmail, - processEmailBatch, - send -} = require('./bulk-email-processor'); - -module.exports = { - BATCH_SIZE, - SuccessfulBatch, - FailedBatch, - processEmail, - processEmailBatch, - send -}; +module.exports = require('./bulk-email-processor'); diff --git a/ghost/core/core/server/services/bulk-email/mailgun.js b/ghost/core/core/server/services/bulk-email/mailgun.js deleted file mode 100644 index a8ba29614b..0000000000 --- a/ghost/core/core/server/services/bulk-email/mailgun.js +++ /dev/null @@ -1,122 +0,0 @@ -const _ = require('lodash'); -const {URL} = require('url'); -const logging = require('@tryghost/logging'); -const configService = require('../../../shared/config'); -const settingsCache = require('../../../shared/settings-cache'); - -const BATCH_SIZE = 1000; - -function createMailgun(config) { - const mailgun = require('mailgun-js'); - const baseUrl = new URL(config.baseUrl); - - return mailgun({ - apiKey: config.apiKey, - domain: config.domain, - protocol: baseUrl.protocol, - host: baseUrl.hostname, - port: baseUrl.port, - endpoint: baseUrl.pathname, - retry: 5 - }); -} - -function getInstance() { - const bulkEmailConfig = configService.get('bulkEmail'); - const bulkEmailSetting = { - apiKey: settingsCache.get('mailgun_api_key'), - domain: settingsCache.get('mailgun_domain'), - baseUrl: settingsCache.get('mailgun_base_url') - }; - const hasMailgunConfig = !!(bulkEmailConfig && bulkEmailConfig.mailgun); - const hasMailgunSetting = !!(bulkEmailSetting && bulkEmailSetting.apiKey && bulkEmailSetting.baseUrl && bulkEmailSetting.domain); - - if (!hasMailgunConfig && !hasMailgunSetting) { - logging.warn(`Bulk email service is not configured`); - } else { - let mailgunConfig = hasMailgunConfig ? bulkEmailConfig.mailgun : bulkEmailSetting; - return createMailgun(mailgunConfig); - } - return null; -} - -// recipientData format: -// { -// 'test@example.com': { -// name: 'Test User', -// unique_id: '12345abcde', -// unsubscribe_url: 'https://example.com/unsub/me' -// } -// } -function send(message, recipientData, replacements) { - if (recipientData.length > BATCH_SIZE) { - // err - too many recipients - } - - let messageData = {}; - - try { - const bulkEmailConfig = configService.get('bulkEmail'); - const mailgunInstance = getInstance(); - - const messageContent = _.pick(message, 'subject', 'html', 'plaintext'); - - // update content to use Mailgun variable syntax for replacements - replacements.forEach((replacement) => { - messageContent[replacement.format] = messageContent[replacement.format].replace( - replacement.match, - `%recipient.${replacement.id}%` - ); - }); - - messageData = { - to: Object.keys(recipientData), - from: message.from, - 'h:Reply-To': message.replyTo || message.reply_to, - subject: messageContent.subject, - html: messageContent.html, - text: messageContent.plaintext, - 'recipient-variables': recipientData - }; - - // add a reference to the original email record for easier mapping of mailgun event -> email - if (message.id) { - messageData['v:email-id'] = message.id; - } - - const tags = ['bulk-email']; - if (bulkEmailConfig && bulkEmailConfig.mailgun && bulkEmailConfig.mailgun.tag) { - tags.push(bulkEmailConfig.mailgun.tag); - } - messageData['o:tag'] = tags; - - if (bulkEmailConfig && bulkEmailConfig.mailgun && bulkEmailConfig.mailgun.testmode) { - messageData['o:testmode'] = true; - } - - // enable tracking if turned on for this email - if (message.track_opens) { - messageData['o:tracking-opens'] = true; - } - - return new Promise((resolve, reject) => { - mailgunInstance.messages().send(messageData, (error, body) => { - if (error || !body) { - return reject(error); - } - - return resolve({ - id: body.id - }); - }); - }); - } catch (error) { - return Promise.reject({error, messageData}); - } -} - -module.exports = { - BATCH_SIZE, - getInstance, - send -}; diff --git a/ghost/core/package.json b/ghost/core/package.json index 004cafe8c3..fbbea0fa25 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -156,7 +156,6 @@ "knex-migrator": "5.0.3", "lodash": "4.17.21", "luxon": "3.0.1", - "mailgun-js": "0.22.0", "metascraper": "5.30.1", "metascraper-author": "5.29.15", "metascraper-description": "5.29.15", diff --git a/ghost/core/test/integration/services/mega.test.js b/ghost/core/test/integration/services/mega.test.js index 97b5c9db94..0b1d12d543 100644 --- a/ghost/core/test/integration/services/mega.test.js +++ b/ghost/core/test/integration/services/mega.test.js @@ -4,7 +4,6 @@ const moment = require('moment'); const ObjectId = require('bson-objectid'); const models = require('../../../core/server/models'); const sinon = require('sinon'); -const mailgunProvider = require('../../../core/server/services/bulk-email/mailgun'); let agent; @@ -46,6 +45,7 @@ async function createPublishedPostEmail() { describe('MEGA', function () { let _sendEmailJob; + let _mailgunClient; describe('sendEmailJob', function () { before(async function () { @@ -53,6 +53,7 @@ describe('MEGA', function () { await fixtureManager.init('newsletters', 'members:newsletters'); await agent.loginAsOwner(); _sendEmailJob = require('../../../core/server/services/mega/mega')._sendEmailJob; + _mailgunClient = require('../../../core/server/services/bulk-email')._mailgunClient; }); afterEach(function () { @@ -60,8 +61,8 @@ describe('MEGA', function () { }); it('Can send a scheduled post email', async function () { - sinon.stub(mailgunProvider, 'getInstance').returns({}); - sinon.stub(mailgunProvider, 'send').callsFake(async () => { + sinon.stub(_mailgunClient, 'getInstance').returns({}); + sinon.stub(_mailgunClient, 'send').callsFake(async () => { return { id: 'stubbed-email-id' }; @@ -78,8 +79,8 @@ describe('MEGA', function () { }); it('Can handle a failed post email', async function () { - sinon.stub(mailgunProvider, 'getInstance').returns({}); - sinon.stub(mailgunProvider, 'send').callsFake(async () => { + sinon.stub(_mailgunClient, 'getInstance').returns({}); + sinon.stub(_mailgunClient, 'send').callsFake(async () => { throw new Error('Failed to send'); }); diff --git a/ghost/email-analytics-provider-mailgun/lib/provider-mailgun.js b/ghost/email-analytics-provider-mailgun/lib/provider-mailgun.js index a3524ee549..6bb64abf14 100644 --- a/ghost/email-analytics-provider-mailgun/lib/provider-mailgun.js +++ b/ghost/email-analytics-provider-mailgun/lib/provider-mailgun.js @@ -1,8 +1,6 @@ -const mailgunJs = require('mailgun-js'); +const MailgunClient = require('@tryghost/mailgun-client'); const moment = require('moment'); const {EventProcessingResult} = require('@tryghost/email-analytics-service'); -const debug = require('@tryghost/debug')('email-analytics-provider-mailgun'); -const logging = require('@tryghost/logging'); const EVENT_FILTER = 'delivered OR opened OR failed OR unsubscribed OR complained'; const PAGE_LIMIT = 300; @@ -10,52 +8,17 @@ const TRUST_THRESHOLD_S = 30 * 60; // 30 minutes const DEFAULT_TAGS = ['bulk-email']; class EmailAnalyticsProviderMailgun { - constructor({config, settings, mailgun} = {}) { - this.config = config; - this.settings = settings; + #mailgunClient; + + constructor({config, settings}) { + this.#mailgunClient = new MailgunClient({config, settings}); this.tags = [...DEFAULT_TAGS]; - this._mailgun = mailgun; - if (this.config.get('bulkEmail:mailgun:tag')) { - this.tags.push(this.config.get('bulkEmail:mailgun:tag')); + if (config.get('bulkEmail:mailgun:tag')) { + this.tags.push(config.get('bulkEmail:mailgun:tag')); } } - // unless an instance is passed in to the constructor, generate a new instance each - // time the getter is called to account for changes in config/settings over time - get mailgun() { - if (this._mailgun) { - return this._mailgun; - } - - const bulkEmailConfig = this.config.get('bulkEmail'); - const bulkEmailSetting = { - apiKey: this.settings.get('mailgun_api_key'), - domain: this.settings.get('mailgun_domain'), - baseUrl: this.settings.get('mailgun_base_url') - }; - const hasMailgunConfig = !!(bulkEmailConfig && bulkEmailConfig.mailgun); - const hasMailgunSetting = !!(bulkEmailSetting && bulkEmailSetting.apiKey && bulkEmailSetting.baseUrl && bulkEmailSetting.domain); - - if (!hasMailgunConfig && !hasMailgunSetting) { - logging.warn(`Bulk email service is not configured`); - return undefined; - } - - const mailgunConfig = hasMailgunConfig ? bulkEmailConfig.mailgun : bulkEmailSetting; - const baseUrl = new URL(mailgunConfig.baseUrl); - - return mailgunJs({ - apiKey: mailgunConfig.apiKey, - domain: mailgunConfig.domain, - protocol: baseUrl.protocol, - host: baseUrl.hostname, - port: baseUrl.port, - endpoint: baseUrl.pathname, - retry: 5 - }); - } - // do not start from a particular time, grab latest then work back through // pages until we get a blank response fetchAll(batchHandler, options) { @@ -65,7 +28,7 @@ class EmailAnalyticsProviderMailgun { tags: this.tags.join(' AND ') }; - return this._fetchPages(mailgunOptions, batchHandler, options); + return this.#fetchAnalytics(mailgunOptions, batchHandler, options); } // fetch from the last known timestamp-TRUST_THRESHOLD then work forwards @@ -82,54 +45,19 @@ class EmailAnalyticsProviderMailgun { ascending: 'yes' }; - return this._fetchPages(mailgunOptions, batchHandler, options); + return this.#fetchAnalytics(mailgunOptions, batchHandler, options); } - async _fetchPages(mailgunOptions, batchHandler, {maxEvents = Infinity} = {}) { - const {mailgun} = this; + async #fetchAnalytics(mailgunOptions, batchHandler, options) { + const events = await this.#mailgunClient.fetchEvents(mailgunOptions, batchHandler, options); - if (!mailgun) { - logging.warn(`Bulk email service is not configured`); - return new EventProcessingResult(); + const processingResult = new EventProcessingResult(); + + for (const event of events) { + processingResult.merge(event); } - const result = new EventProcessingResult(); - - debug(`_fetchPages: starting fetching first events page`); - let page = await mailgun.events().get(mailgunOptions); - let events = page && page.items && page.items.map(this.normalizeEvent) || []; - debug(`_fetchPages: finished fetching first page with ${events.length} events`); - - pagesLoop: - while (events.length !== 0) { - const batchResult = await batchHandler(events); - result.merge(batchResult); - - if (result.totalEvents >= maxEvents) { - break pagesLoop; - } - - const nextPageUrl = page.paging.next.replace(/https:\/\/api\.(eu\.)?mailgun\.net\/v3/, ''); - debug(`_fetchPages: starting fetching next page ${nextPageUrl}`); - page = await mailgun.get(nextPageUrl); - events = page && page.items && page.items.map(this.normalizeEvent) || []; - debug(`_fetchPages: finished fetching next page with ${events.length} events`); - } - - return result; - } - - normalizeEvent(event) { - let providerId = event.message && event.message.headers && event.message.headers['message-id']; - - return { - type: event.event, - severity: event.severity, - recipientEmail: event.recipient, - emailId: event['user-variables'] && event['user-variables']['email-id'], - providerId: providerId, - timestamp: new Date(event.timestamp * 1000) - }; + return processingResult; } } diff --git a/ghost/email-analytics-provider-mailgun/package.json b/ghost/email-analytics-provider-mailgun/package.json index 51103324f3..7baab62025 100644 --- a/ghost/email-analytics-provider-mailgun/package.json +++ b/ghost/email-analytics-provider-mailgun/package.json @@ -23,7 +23,7 @@ "dependencies": { "@tryghost/email-analytics-service": "0.0.0", "@tryghost/logging": "2.2.4", - "mailgun-js": "0.22.0", + "@tryghost/mailgun-client": "0.0.0", "moment": "2.29.1" } } diff --git a/ghost/email-analytics-provider-mailgun/test/provider-mailgun.test.js b/ghost/email-analytics-provider-mailgun/test/provider-mailgun.test.js index 129925ddae..d03c20ffc3 100644 --- a/ghost/email-analytics-provider-mailgun/test/provider-mailgun.test.js +++ b/ghost/email-analytics-provider-mailgun/test/provider-mailgun.test.js @@ -21,56 +21,6 @@ describe('EmailAnalyticsProviderMailgun', function () { sinon.restore(); }); - it('can connect via config', async function () { - const configStub = sinon.stub(config, 'get'); - configStub.withArgs('bulkEmail').returns({ - mailgun: { - apiKey: 'apiKey', - domain: 'domain.com', - baseUrl: 'https://api.mailgun.net/v3' - } - }); - - const eventsMock = nock('https://api.mailgun.net') - .get('/v3/domain.com/events') - .query({ - event: 'delivered OR opened OR failed OR unsubscribed OR complained', - limit: 300, - tags: 'bulk-email' - }) - .reply(200, {'Content-Type': 'application/json'}, { - items: [] - }); - - const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); - await mailgunProvider.fetchAll(() => {}); - - eventsMock.isDone().should.be.true(); - }); - - it('can connect via settings', async function () { - const settingsStub = sinon.stub(settings, 'get'); - settingsStub.withArgs('mailgun_api_key').returns('settingsApiKey'); - settingsStub.withArgs('mailgun_domain').returns('settingsdomain.com'); - settingsStub.withArgs('mailgun_base_url').returns('https://example.com/v3'); - - const eventsMock = nock('https://example.com') - .get('/v3/settingsdomain.com/events') - .query({ - event: 'delivered OR opened OR failed OR unsubscribed OR complained', - limit: 300, - tags: 'bulk-email' - }) - .reply(200, {'Content-Type': 'application/json'}, { - items: [] - }); - - const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); - await mailgunProvider.fetchAll(() => {}); - - eventsMock.isDone().should.be.true(); - }); - it('respects changes in settings', async function () { const settingsStub = sinon.stub(settings, 'get'); settingsStub.withArgs('mailgun_api_key').returns('settingsApiKey'); @@ -394,35 +344,4 @@ describe('EmailAnalyticsProviderMailgun', function () { batchHandler.callCount.should.eql(2); // one per page }); }); - - describe('normalizeEvent()', function () { - it('works', function () { - const event = { - event: 'testEvent', - severity: 'testSeverity', - recipient: 'testRecipient', - timestamp: 1614275662, - message: { - headers: { - 'message-id': 'testProviderId' - } - }, - 'user-variables': { - 'email-id': 'testEmailId' - } - }; - - const mailgunProvider = new EmailAnalyticsProviderMailgun({config, settings}); - const result = mailgunProvider.normalizeEvent(event); - - result.should.deepEqual({ - type: 'testEvent', - severity: 'testSeverity', - recipientEmail: 'testRecipient', - emailId: 'testEmailId', - providerId: 'testProviderId', - timestamp: new Date('2021-02-25T17:54:22.000Z') - }); - }); - }); }); diff --git a/ghost/mailgun-client/.eslintrc.js b/ghost/mailgun-client/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/mailgun-client/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/mailgun-client/README.md b/ghost/mailgun-client/README.md new file mode 100644 index 0000000000..256b821fa0 --- /dev/null +++ b/ghost/mailgun-client/README.md @@ -0,0 +1,11 @@ +# Mailgun Client + +Mailgun client for Ghost + +## Usage + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + diff --git a/ghost/mailgun-client/index.js b/ghost/mailgun-client/index.js new file mode 100644 index 0000000000..5a87aae9ca --- /dev/null +++ b/ghost/mailgun-client/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/mailgun-client'); diff --git a/ghost/mailgun-client/lib/mailgun-client.js b/ghost/mailgun-client/lib/mailgun-client.js new file mode 100644 index 0000000000..c83a43dce7 --- /dev/null +++ b/ghost/mailgun-client/lib/mailgun-client.js @@ -0,0 +1,203 @@ +const _ = require('lodash'); +const debug = require('@tryghost/debug'); +const logging = require('@tryghost/logging'); +const mailgun = require('mailgun-js'); + +module.exports.BATCH_SIZE = 1000; + +module.exports = class MailgunClient { + #config; + #settings; + + constructor({config, settings}) { + this.#config = config; + this.#settings = settings; + } + + /** + * Creates the data payload and sends to Mailgun + * + * @param {Object} message + * @param {Object} recipientData + * @param {Array} replacements + * + * recipientData format: + * { + * 'test@example.com': { + * name: 'Test User', + * unique_id: '12345abcde', + * unsubscribe_url: 'https://example.com/unsub/me' + * } + * } + */ + send(message, recipientData, replacements) { + if (recipientData.length > module.exports.BATCH_SIZE) { + // err - too many recipients + } + + const mailgunInstance = this.getInstance(); + if (!mailgunInstance) { + logging.warn(`Mailgun is not configured`); + return; + } + + let messageData = {}; + + try { + const bulkEmailConfig = this.#config.get('bulkEmail'); + const messageContent = _.pick(message, 'subject', 'html', 'plaintext'); + + // update content to use Mailgun variable syntax for replacements + replacements.forEach((replacement) => { + messageContent[replacement.format] = messageContent[replacement.format].replace( + replacement.match, + `%recipient.${replacement.id}%` + ); + }); + + messageData = { + to: Object.keys(recipientData), + from: message.from, + 'h:Reply-To': message.replyTo || message.reply_to, + subject: messageContent.subject, + html: messageContent.html, + text: messageContent.plaintext, + 'recipient-variables': recipientData + }; + + // add a reference to the original email record for easier mapping of mailgun event -> email + if (message.id) { + messageData['v:email-id'] = message.id; + } + + const tags = ['bulk-email']; + if (bulkEmailConfig && bulkEmailConfig.mailgun && bulkEmailConfig.mailgun.tag) { + tags.push(bulkEmailConfig.mailgun.tag); + } + messageData['o:tag'] = tags; + + if (bulkEmailConfig && bulkEmailConfig.mailgun && bulkEmailConfig.mailgun.testmode) { + messageData['o:testmode'] = true; + } + + // enable tracking if turned on for this email + if (message.track_opens) { + messageData['o:tracking-opens'] = true; + } + + return new Promise((resolve, reject) => { + mailgunInstance.messages().send(messageData, (error, body) => { + if (error || !body) { + return reject(error); + } + + return resolve({ + id: body.id + }); + }); + }); + } catch (error) { + return Promise.reject({error, messageData}); + } + } + + async fetchEvents(mailgunOptions, batchHandler, {maxEvents = Infinity} = {}) { + let result = []; + + const mailgunInstance = this.getInstance(); + if (!mailgunInstance) { + logging.warn(`Mailgun is not configured`); + return result; + } + + debug(`fetchEvents: starting fetching first events page`); + let page = await mailgunInstance.events().get(mailgunOptions); + let events = page && page.items && page.items.map(this.normalizeEvent) || []; + debug(`fetchEvents: finished fetching first page with ${events.length} events`); + + let eventCount = 0; + + pagesLoop: + while (events.length !== 0) { + const batchResult = await batchHandler(events); + + result = result.concat(batchResult); + eventCount += events.length; + + if (eventCount >= maxEvents) { + break pagesLoop; + } + + const nextPageUrl = page.paging.next.replace(/https:\/\/api\.(eu\.)?mailgun\.net\/v3/, ''); + debug(`fetchEvents: starting fetching next page ${nextPageUrl}`); + page = await mailgunInstance.get(nextPageUrl); + events = page && page.items && page.items.map(this.normalizeEvent) || []; + debug(`fetchEvents: finished fetching next page with ${events.length} events`); + } + + return result; + } + + normalizeEvent(event) { + const providerId = event?.message?.headers['message-id']; + + return { + type: event.event, + severity: event.severity, + recipientEmail: event.recipient, + emailId: event['user-variables'] && event['user-variables']['email-id'], + providerId: providerId, + timestamp: new Date(event.timestamp * 1000) + }; + } + + /** + * Returns an instance of the Mailgun client based upon the config or settings values + * + * We don't cache the instance so we can always get a fresh one based upon changed settings + * or config values over time + * + * Note: if the credentials are not configure, this method returns `null` and it is down to the + * consumer to act upon this/log this out + * + * @returns {import('mailgun-js').Mailgun} the Mailgun client instance + */ + getInstance() { + const bulkEmailConfig = this.#config.get('bulkEmail'); + const bulkEmailSetting = { + apiKey: this.#settings.get('mailgun_api_key'), + domain: this.#settings.get('mailgun_domain'), + baseUrl: this.#settings.get('mailgun_base_url') + }; + + const hasMailgunConfig = !!(bulkEmailConfig && bulkEmailConfig.mailgun); + const hasMailgunSetting = !!(bulkEmailSetting && bulkEmailSetting.apiKey && bulkEmailSetting.baseUrl && bulkEmailSetting.domain); + + if (!hasMailgunConfig && !hasMailgunSetting) { + return null; + } + + const mailgunConfig = hasMailgunConfig ? bulkEmailConfig.mailgun : bulkEmailSetting; + const baseUrl = new URL(mailgunConfig.baseUrl); + + return mailgun({ + apiKey: mailgunConfig.apiKey, + domain: mailgunConfig.domain, + protocol: baseUrl.protocol, + host: baseUrl.hostname, + port: baseUrl.port, + endpoint: baseUrl.pathname, + retry: 5 + }); + } + + /** + * Returns whether the Mailgun instance is configured via config/settings + * + * @returns {boolean} + */ + isConfigured() { + const instance = this.getInstance(); + return !!instance; + } +}; diff --git a/ghost/mailgun-client/package.json b/ghost/mailgun-client/package.json new file mode 100644 index 0000000000..9bd6c6d2bf --- /dev/null +++ b/ghost/mailgun-client/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tryghost/mailgun-client", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/mailgun-client", + "author": "Ghost Foundation", + "private": true, + "main": "index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'", + "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.12.0", + "mocha": "10.0.0", + "should": "13.2.3", + "sinon": "14.0.0" + }, + "dependencies": { + "@tryghost/logging": "2.2.3", + "lodash": "4.17.21", + "mailgun-js": "0.22.0" + } +} diff --git a/ghost/mailgun-client/test/.eslintrc.js b/ghost/mailgun-client/test/.eslintrc.js new file mode 100644 index 0000000000..829b601eb0 --- /dev/null +++ b/ghost/mailgun-client/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/mailgun-client/test/mailgun-client.test.js b/ghost/mailgun-client/test/mailgun-client.test.js new file mode 100644 index 0000000000..cb68934cd5 --- /dev/null +++ b/ghost/mailgun-client/test/mailgun-client.test.js @@ -0,0 +1,79 @@ +const assert = require('assert'); +const sinon = require('sinon'); + +// module under test +const MailgunClient = require('../'); + +describe('MailgunClient', function () { + let config, settings; + + beforeEach(function () { + // options objects that can be stubbed or spied + config = {get() {}}; + settings = {get() {}}; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('can connect via config', function () { + const configStub = sinon.stub(config, 'get'); + configStub.withArgs('bulkEmail').returns({ + mailgun: { + apiKey: 'apiKey', + domain: 'domain.com', + baseUrl: 'https://api.mailgun.net/v3' + } + }); + + const mailgunClient = new MailgunClient({config, settings}); + assert.equal(mailgunClient.isConfigured(), true); + }); + + it('can connect via settings', function () { + const settingsStub = sinon.stub(settings, 'get'); + settingsStub.withArgs('mailgun_api_key').returns('settingsApiKey'); + settingsStub.withArgs('mailgun_domain').returns('settingsdomain.com'); + settingsStub.withArgs('mailgun_base_url').returns('https://example.com/v3'); + + const mailgunClient = new MailgunClient({config, settings}); + assert.equal(mailgunClient.isConfigured(), true); + }); + + it('cannot configure Mailgun if config/settings missing', function () { + const mailgunClient = new MailgunClient({config, settings}); + assert.equal(mailgunClient.isConfigured(), false); + }); + + describe('normalizeEvent()', function () { + it('works', function () { + const event = { + event: 'testEvent', + severity: 'testSeverity', + recipient: 'testRecipient', + timestamp: 1614275662, + message: { + headers: { + 'message-id': 'testProviderId' + } + }, + 'user-variables': { + 'email-id': 'testEmailId' + } + }; + + const mailgunClient = new MailgunClient({config, settings}); + const result = mailgunClient.normalizeEvent(event); + + assert.deepStrictEqual(result, { + type: 'testEvent', + severity: 'testSeverity', + recipientEmail: 'testRecipient', + emailId: 'testEmailId', + providerId: 'testProviderId', + timestamp: new Date('2021-02-25T17:54:22.000Z') + }); + }); + }); +});