mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 18:31:57 +03:00
Extracted Mailgun client to separate package
refs https://github.com/TryGhost/Toolbox/issues/363 - this commit pulls all code involving the Mailgun client SDK into one new package called `mailgun-client` - this means we should be able to replace `mailgun-js` (deprecated) with `mailgun.js` (the new, official one) without editing code all over the place - this also lays some groundwork for better testing of smaller components
This commit is contained in:
parent
82a3133ace
commit
bf254b9c6a
@ -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<Object>} - {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
|
||||
};
|
||||
|
@ -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');
|
||||
|
@ -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
|
||||
};
|
@ -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",
|
||||
|
@ -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');
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
6
ghost/mailgun-client/.eslintrc.js
Normal file
6
ghost/mailgun-client/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
11
ghost/mailgun-client/README.md
Normal file
11
ghost/mailgun-client/README.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Mailgun Client
|
||||
|
||||
Mailgun client for Ghost
|
||||
|
||||
## Usage
|
||||
|
||||
## Test
|
||||
|
||||
- `yarn lint` run just eslint
|
||||
- `yarn test` run lint and tests
|
||||
|
1
ghost/mailgun-client/index.js
Normal file
1
ghost/mailgun-client/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./lib/mailgun-client');
|
203
ghost/mailgun-client/lib/mailgun-client.js
Normal file
203
ghost/mailgun-client/lib/mailgun-client.js
Normal file
@ -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<Object>} 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;
|
||||
}
|
||||
};
|
30
ghost/mailgun-client/package.json
Normal file
30
ghost/mailgun-client/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
6
ghost/mailgun-client/test/.eslintrc.js
Normal file
6
ghost/mailgun-client/test/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
79
ghost/mailgun-client/test/mailgun-client.test.js
Normal file
79
ghost/mailgun-client/test/mailgun-client.test.js
Normal file
@ -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')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user