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 tpl = require('@tryghost/tpl');
|
||||||
const logging = require('@tryghost/logging');
|
const logging = require('@tryghost/logging');
|
||||||
const models = require('../../models');
|
const models = require('../../models');
|
||||||
const mailgunProvider = require('./mailgun');
|
const MailgunClient = require('@tryghost/mailgun-client');
|
||||||
const sentry = require('../../../shared/sentry');
|
const sentry = require('../../../shared/sentry');
|
||||||
const labs = require('../../../shared/labs');
|
const labs = require('../../../shared/labs');
|
||||||
const debug = require('@tryghost/debug')('mega');
|
const debug = require('@tryghost/debug')('mega');
|
||||||
const postEmailSerializer = require('../mega/post-email-serializer');
|
const postEmailSerializer = require('../mega/post-email-serializer');
|
||||||
|
const configService = require('../../../shared/config');
|
||||||
|
const settingsCache = require('../../../shared/settings-cache');
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
error: 'The email service was unable to send an email batch.'
|
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
|
* An object representing batch request result
|
||||||
@ -64,7 +66,7 @@ class FailedBatch extends BatchResultBase {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BATCH_SIZE,
|
BATCH_SIZE: MailgunClient.BATCH_SIZE,
|
||||||
SuccessfulBatch,
|
SuccessfulBatch,
|
||||||
FailedBatch,
|
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 {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 {[EmailRecipient]} recipients - The recipients to send the email to with their associated data
|
||||||
* @param {string?} memberSegment - The member segment of the recipients
|
* @param {string?} memberSegment - The member segment of the recipients
|
||||||
* @returns {Object} - {providerId: 'xxx'}
|
* @returns {Promise<Object>} - {providerId: 'xxx'}
|
||||||
*/
|
*/
|
||||||
send(emailData, recipients, memberSegment) {
|
async send(emailData, recipients, memberSegment) {
|
||||||
const mailgunInstance = mailgunProvider.getInstance();
|
const mailgunConfigured = mailgunClient.isConfigured();
|
||||||
if (!mailgunInstance) {
|
if (!mailgunConfigured) {
|
||||||
|
logging.warn('Bulk email has not been configured');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,10 +250,11 @@ module.exports = {
|
|||||||
|
|
||||||
emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment);
|
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)`);
|
debug(`sent message (${Date.now() - startTime}ms)`);
|
||||||
return response;
|
return response;
|
||||||
}).catch((error) => {
|
} catch (error) {
|
||||||
// REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#errors
|
// REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#errors
|
||||||
let ghostError = new errors.EmailError({
|
let ghostError = new errors.EmailError({
|
||||||
err: error,
|
err: error,
|
||||||
@ -263,6 +267,9 @@ module.exports = {
|
|||||||
|
|
||||||
debug(`failed to send message (${Date.now() - startTime}ms)`);
|
debug(`failed to send message (${Date.now() - startTime}ms)`);
|
||||||
throw ghostError;
|
throw ghostError;
|
||||||
});
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// NOTE: for testing only!
|
||||||
|
_mailgunClient: mailgunClient
|
||||||
};
|
};
|
||||||
|
@ -1,17 +1 @@
|
|||||||
const {
|
module.exports = require('./bulk-email-processor');
|
||||||
BATCH_SIZE,
|
|
||||||
SuccessfulBatch,
|
|
||||||
FailedBatch,
|
|
||||||
processEmail,
|
|
||||||
processEmailBatch,
|
|
||||||
send
|
|
||||||
} = require('./bulk-email-processor');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
BATCH_SIZE,
|
|
||||||
SuccessfulBatch,
|
|
||||||
FailedBatch,
|
|
||||||
processEmail,
|
|
||||||
processEmailBatch,
|
|
||||||
send
|
|
||||||
};
|
|
||||||
|
@ -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",
|
"knex-migrator": "5.0.3",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"luxon": "3.0.1",
|
"luxon": "3.0.1",
|
||||||
"mailgun-js": "0.22.0",
|
|
||||||
"metascraper": "5.30.1",
|
"metascraper": "5.30.1",
|
||||||
"metascraper-author": "5.29.15",
|
"metascraper-author": "5.29.15",
|
||||||
"metascraper-description": "5.29.15",
|
"metascraper-description": "5.29.15",
|
||||||
|
@ -4,7 +4,6 @@ const moment = require('moment');
|
|||||||
const ObjectId = require('bson-objectid');
|
const ObjectId = require('bson-objectid');
|
||||||
const models = require('../../../core/server/models');
|
const models = require('../../../core/server/models');
|
||||||
const sinon = require('sinon');
|
const sinon = require('sinon');
|
||||||
const mailgunProvider = require('../../../core/server/services/bulk-email/mailgun');
|
|
||||||
|
|
||||||
let agent;
|
let agent;
|
||||||
|
|
||||||
@ -46,6 +45,7 @@ async function createPublishedPostEmail() {
|
|||||||
|
|
||||||
describe('MEGA', function () {
|
describe('MEGA', function () {
|
||||||
let _sendEmailJob;
|
let _sendEmailJob;
|
||||||
|
let _mailgunClient;
|
||||||
|
|
||||||
describe('sendEmailJob', function () {
|
describe('sendEmailJob', function () {
|
||||||
before(async function () {
|
before(async function () {
|
||||||
@ -53,6 +53,7 @@ describe('MEGA', function () {
|
|||||||
await fixtureManager.init('newsletters', 'members:newsletters');
|
await fixtureManager.init('newsletters', 'members:newsletters');
|
||||||
await agent.loginAsOwner();
|
await agent.loginAsOwner();
|
||||||
_sendEmailJob = require('../../../core/server/services/mega/mega')._sendEmailJob;
|
_sendEmailJob = require('../../../core/server/services/mega/mega')._sendEmailJob;
|
||||||
|
_mailgunClient = require('../../../core/server/services/bulk-email')._mailgunClient;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function () {
|
||||||
@ -60,8 +61,8 @@ describe('MEGA', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Can send a scheduled post email', async function () {
|
it('Can send a scheduled post email', async function () {
|
||||||
sinon.stub(mailgunProvider, 'getInstance').returns({});
|
sinon.stub(_mailgunClient, 'getInstance').returns({});
|
||||||
sinon.stub(mailgunProvider, 'send').callsFake(async () => {
|
sinon.stub(_mailgunClient, 'send').callsFake(async () => {
|
||||||
return {
|
return {
|
||||||
id: 'stubbed-email-id'
|
id: 'stubbed-email-id'
|
||||||
};
|
};
|
||||||
@ -78,8 +79,8 @@ describe('MEGA', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Can handle a failed post email', async function () {
|
it('Can handle a failed post email', async function () {
|
||||||
sinon.stub(mailgunProvider, 'getInstance').returns({});
|
sinon.stub(_mailgunClient, 'getInstance').returns({});
|
||||||
sinon.stub(mailgunProvider, 'send').callsFake(async () => {
|
sinon.stub(_mailgunClient, 'send').callsFake(async () => {
|
||||||
throw new Error('Failed to send');
|
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 moment = require('moment');
|
||||||
const {EventProcessingResult} = require('@tryghost/email-analytics-service');
|
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 EVENT_FILTER = 'delivered OR opened OR failed OR unsubscribed OR complained';
|
||||||
const PAGE_LIMIT = 300;
|
const PAGE_LIMIT = 300;
|
||||||
@ -10,52 +8,17 @@ const TRUST_THRESHOLD_S = 30 * 60; // 30 minutes
|
|||||||
const DEFAULT_TAGS = ['bulk-email'];
|
const DEFAULT_TAGS = ['bulk-email'];
|
||||||
|
|
||||||
class EmailAnalyticsProviderMailgun {
|
class EmailAnalyticsProviderMailgun {
|
||||||
constructor({config, settings, mailgun} = {}) {
|
#mailgunClient;
|
||||||
this.config = config;
|
|
||||||
this.settings = settings;
|
constructor({config, settings}) {
|
||||||
|
this.#mailgunClient = new MailgunClient({config, settings});
|
||||||
this.tags = [...DEFAULT_TAGS];
|
this.tags = [...DEFAULT_TAGS];
|
||||||
this._mailgun = mailgun;
|
|
||||||
|
|
||||||
if (this.config.get('bulkEmail:mailgun:tag')) {
|
if (config.get('bulkEmail:mailgun:tag')) {
|
||||||
this.tags.push(this.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
|
// do not start from a particular time, grab latest then work back through
|
||||||
// pages until we get a blank response
|
// pages until we get a blank response
|
||||||
fetchAll(batchHandler, options) {
|
fetchAll(batchHandler, options) {
|
||||||
@ -65,7 +28,7 @@ class EmailAnalyticsProviderMailgun {
|
|||||||
tags: this.tags.join(' AND ')
|
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
|
// fetch from the last known timestamp-TRUST_THRESHOLD then work forwards
|
||||||
@ -82,54 +45,19 @@ class EmailAnalyticsProviderMailgun {
|
|||||||
ascending: 'yes'
|
ascending: 'yes'
|
||||||
};
|
};
|
||||||
|
|
||||||
return this._fetchPages(mailgunOptions, batchHandler, options);
|
return this.#fetchAnalytics(mailgunOptions, batchHandler, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _fetchPages(mailgunOptions, batchHandler, {maxEvents = Infinity} = {}) {
|
async #fetchAnalytics(mailgunOptions, batchHandler, options) {
|
||||||
const {mailgun} = this;
|
const events = await this.#mailgunClient.fetchEvents(mailgunOptions, batchHandler, options);
|
||||||
|
|
||||||
if (!mailgun) {
|
const processingResult = new EventProcessingResult();
|
||||||
logging.warn(`Bulk email service is not configured`);
|
|
||||||
return new EventProcessingResult();
|
for (const event of events) {
|
||||||
|
processingResult.merge(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = new EventProcessingResult();
|
return processingResult;
|
||||||
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tryghost/email-analytics-service": "0.0.0",
|
"@tryghost/email-analytics-service": "0.0.0",
|
||||||
"@tryghost/logging": "2.2.4",
|
"@tryghost/logging": "2.2.4",
|
||||||
"mailgun-js": "0.22.0",
|
"@tryghost/mailgun-client": "0.0.0",
|
||||||
"moment": "2.29.1"
|
"moment": "2.29.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,56 +21,6 @@ describe('EmailAnalyticsProviderMailgun', function () {
|
|||||||
sinon.restore();
|
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 () {
|
it('respects changes in settings', async function () {
|
||||||
const settingsStub = sinon.stub(settings, 'get');
|
const settingsStub = sinon.stub(settings, 'get');
|
||||||
settingsStub.withArgs('mailgun_api_key').returns('settingsApiKey');
|
settingsStub.withArgs('mailgun_api_key').returns('settingsApiKey');
|
||||||
@ -394,35 +344,4 @@ describe('EmailAnalyticsProviderMailgun', function () {
|
|||||||
batchHandler.callCount.should.eql(2); // one per page
|
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