9601285c3d
refs https://github.com/TryGhost/Ghost/issues/15725 This pull request adds a new configuration option for the Mailgun email provider that allows the user to set the maximum number of recipients per email batch via a new config option `bulkEmail.batchSize`
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
const _ = require('lodash');
|
|
const debug = require('@tryghost/debug');
|
|
const logging = require('@tryghost/logging');
|
|
const metrics = require('@tryghost/metrics');
|
|
const errors = require('@tryghost/errors');
|
|
|
|
module.exports = class MailgunClient {
|
|
#config;
|
|
#settings;
|
|
|
|
static DEFAULT_BATCH_SIZE = 1000;
|
|
|
|
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'
|
|
* }
|
|
* }
|
|
*/
|
|
async send(message, recipientData, replacements) {
|
|
const mailgunInstance = this.getInstance();
|
|
if (!mailgunInstance) {
|
|
logging.warn(`Mailgun is not configured`);
|
|
return null;
|
|
}
|
|
|
|
const batchSize = this.getBatchSize();
|
|
if (Object.keys(recipientData).length > batchSize) {
|
|
throw new errors.IncorrectUsageError({
|
|
message: `Mailgun only supports sending to ${batchSize} recipients at a time`
|
|
});
|
|
}
|
|
|
|
let messageData = {};
|
|
|
|
let startTime;
|
|
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.regexp,
|
|
`%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': JSON.stringify(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', 'ghost-email'];
|
|
if (bulkEmailConfig?.mailgun?.tag) {
|
|
tags.push(bulkEmailConfig.mailgun.tag);
|
|
}
|
|
messageData['o:tag'] = tags;
|
|
|
|
if (bulkEmailConfig?.mailgun?.testmode) {
|
|
messageData['o:testmode'] = true;
|
|
}
|
|
|
|
// enable tracking if turned on for this email
|
|
if (message.track_opens) {
|
|
messageData['o:tracking-opens'] = true;
|
|
}
|
|
|
|
const mailgunConfig = this.#getConfig();
|
|
startTime = Date.now();
|
|
const response = await mailgunInstance.messages.create(mailgunConfig.domain, messageData);
|
|
metrics.metric('mailgun-send-mail', {
|
|
value: Date.now() - startTime,
|
|
statusCode: 200
|
|
});
|
|
|
|
return {
|
|
id: response.id
|
|
};
|
|
} catch (error) {
|
|
logging.error(error);
|
|
metrics.metric('mailgun-send-mail', {
|
|
value: Date.now() - startTime,
|
|
statusCode: error.status
|
|
});
|
|
return Promise.reject({error, messageData});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('mailgun.js').default} mailgunInstance
|
|
* @param {Object} mailgunConfig
|
|
* @param {Object} mailgunOptions
|
|
*/
|
|
async getEventsFromMailgun(mailgunInstance, mailgunConfig, mailgunOptions) {
|
|
const startTime = Date.now();
|
|
try {
|
|
const page = await mailgunInstance.events.get(mailgunConfig.domain, mailgunOptions);
|
|
metrics.metric('mailgun-get-events', {
|
|
value: Date.now() - startTime,
|
|
statusCode: 200
|
|
});
|
|
return page;
|
|
} catch (error) {
|
|
metrics.metric('mailgun-get-events', {
|
|
value: Date.now() - startTime,
|
|
statusCode: error.status
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches events from Mailgun
|
|
* @param {Object} mailgunOptions
|
|
* @param {Function} batchHandler
|
|
* @param {Object} options
|
|
* @param {Number} options.maxEvents Not a strict maximum. We stop fetching after we reached the maximum AND received at least one event after begin (not equal) to prevent deadlocks.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async fetchEvents(mailgunOptions, batchHandler, {maxEvents = Infinity} = {}) {
|
|
const mailgunInstance = this.getInstance();
|
|
if (!mailgunInstance) {
|
|
logging.warn(`Mailgun is not configured`);
|
|
return;
|
|
}
|
|
|
|
debug(`fetchEvents: starting fetching first events page`);
|
|
const mailgunConfig = this.#getConfig();
|
|
const startDate = new Date();
|
|
|
|
try {
|
|
let page = await this.getEventsFromMailgun(mailgunInstance, mailgunConfig, mailgunOptions);
|
|
|
|
// By limiting the processed events to ones created before this job started we cancel early ready for the next job run.
|
|
// Avoids chance of events being missed in long job runs due to mailgun's eventual-consistency creating events outside of our 30min sliding re-check window
|
|
let events = (page?.items?.map(this.normalizeEvent) || []).filter(e => !!e && e.timestamp <= startDate);
|
|
debug(`fetchEvents: finished fetching first page with ${events.length} events`);
|
|
|
|
let eventCount = 0;
|
|
const beginTimestamp = mailgunOptions.begin ? Math.ceil(mailgunOptions.begin * 1000) : undefined; // ceil here if we have rounding errors
|
|
|
|
while (events.length !== 0) {
|
|
await batchHandler(events);
|
|
eventCount += events.length;
|
|
|
|
if (eventCount >= maxEvents && (!beginTimestamp || !events[events.length - 1].timestamp || (events[events.length - 1].timestamp.getTime() > beginTimestamp))) {
|
|
break;
|
|
}
|
|
|
|
const nextPageId = page.pages.next.page;
|
|
debug(`fetchEvents: starting fetching next page ${nextPageId}`);
|
|
page = await this.getEventsFromMailgun(mailgunInstance, mailgunConfig, {
|
|
page: nextPageId,
|
|
...mailgunOptions
|
|
});
|
|
|
|
// We need to cap events at the time we started fetching them (see comment above)
|
|
events = (page?.items?.map(this.normalizeEvent) || []).filter(e => !!e && e.timestamp <= startDate);
|
|
debug(`fetchEvents: finished fetching next page with ${events.length} events`);
|
|
}
|
|
} catch (error) {
|
|
logging.error(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async removeSuppression(type, email) {
|
|
if (!this.isConfigured()) {
|
|
return false;
|
|
}
|
|
const instance = this.getInstance();
|
|
const config = this.#getConfig();
|
|
|
|
try {
|
|
await instance.suppressions.destroy(
|
|
config.domain,
|
|
type,
|
|
email
|
|
);
|
|
return true;
|
|
} catch (err) {
|
|
logging.error(err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async removeBounce(email) {
|
|
return this.removeSuppression('bounces', email);
|
|
}
|
|
|
|
async removeComplaint(email) {
|
|
return this.removeSuppression('complaints', email);
|
|
}
|
|
|
|
async removeUnsubscribe(email) {
|
|
return this.removeSuppression('unsubscribes', email);
|
|
}
|
|
|
|
normalizeEvent(event) {
|
|
const providerId = event?.message?.headers['message-id'];
|
|
|
|
if (!providerId && !(event['user-variables'] && event['user-variables']['email-id'])) {
|
|
logging.error('Received invalid event from Mailgun');
|
|
logging.error(event);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: event.id,
|
|
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),
|
|
|
|
error: event['delivery-status'] && (typeof (event['delivery-status'].message || event['delivery-status'].description) === 'string') ? {
|
|
code: event['delivery-status'].code,
|
|
message: (event['delivery-status'].message || event['delivery-status'].description).substring(0, 2000),
|
|
enhancedCode: event['delivery-status']['enhanced-code']?.toString()?.substring(0, 50) ?? null
|
|
} : null
|
|
};
|
|
}
|
|
|
|
#getConfig() {
|
|
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?.mailgun);
|
|
const hasMailgunSetting = !!(bulkEmailSetting && bulkEmailSetting.apiKey && bulkEmailSetting.baseUrl && bulkEmailSetting.domain);
|
|
|
|
if (!hasMailgunConfig && !hasMailgunSetting) {
|
|
return null;
|
|
}
|
|
|
|
const mailgunConfig = hasMailgunConfig ? bulkEmailConfig.mailgun : bulkEmailSetting;
|
|
return mailgunConfig;
|
|
}
|
|
|
|
/**
|
|
* 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')|null} the Mailgun client instance
|
|
*/
|
|
getInstance() {
|
|
const mailgunConfig = this.#getConfig();
|
|
if (!mailgunConfig) {
|
|
return null;
|
|
}
|
|
|
|
const formData = require('form-data');
|
|
const Mailgun = require('mailgun.js');
|
|
|
|
const baseUrl = new URL(mailgunConfig.baseUrl);
|
|
const mailgun = new Mailgun(formData);
|
|
|
|
return mailgun.client({
|
|
username: 'api',
|
|
key: mailgunConfig.apiKey,
|
|
url: baseUrl.origin,
|
|
timeout: 60000
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns whether the Mailgun instance is configured via config/settings
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
isConfigured() {
|
|
const instance = this.getInstance();
|
|
return !!instance;
|
|
}
|
|
|
|
/**
|
|
* Returns configured batch size
|
|
*
|
|
* @returns {number}
|
|
*/
|
|
getBatchSize() {
|
|
return this.#config.get('bulkEmail')?.batchSize ?? this.DEFAULT_BATCH_SIZE;
|
|
}
|
|
};
|