4c166e11df
refs https://github.com/TryGhost/Team/issues/2339 - Includes a new pattern in the job manager that allows us to properly await jobs. - Added new convenience mocking methods to stub settings - Tests the main flows for bulk sending: - Sending in multiple batches - Sending to multiple segments - Handling a failed batch and retrying that batch - Fixes bug in batch generation (ordering not working) In a different PR I'll add more detailed tests.
186 lines
5.7 KiB
JavaScript
186 lines
5.7 KiB
JavaScript
const logging = require('@tryghost/logging');
|
|
const errors = require('@tryghost/errors');
|
|
const debug = require('@tryghost/debug')('email-service:mailgun-provider-service');
|
|
|
|
/**
|
|
* @typedef {object} Recipient
|
|
* @prop {string} email
|
|
* @prop {Replacement[]} replacements
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} Replacement
|
|
* @prop {string} token
|
|
* @prop {string} value
|
|
* @prop {string} id
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} EmailSendingOptions
|
|
* @prop {boolean} clickTrackingEnabled
|
|
* @prop {boolean} openTrackingEnabled
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} EmailProviderSuccessResponse
|
|
* @prop {string} id
|
|
*/
|
|
|
|
class MailgunEmailProvider {
|
|
#mailgunClient;
|
|
#errorHandler;
|
|
|
|
static BATCH_SIZE = 1000;
|
|
|
|
/**
|
|
* @param {object} dependencies
|
|
* @param {import('@tryghost/mailgun-client/lib/mailgun-client')} dependencies.mailgunClient - mailgun client to send emails
|
|
* @param {Function} [dependencies.errorHandler] - custom error handler for logging exceptions
|
|
*/
|
|
constructor({
|
|
mailgunClient,
|
|
errorHandler
|
|
}) {
|
|
this.#mailgunClient = mailgunClient;
|
|
this.#errorHandler = errorHandler;
|
|
}
|
|
|
|
#createRecipientData(replacements) {
|
|
let recipientData = {};
|
|
|
|
recipientData = replacements.reduce((acc, replacement) => {
|
|
const {id, value} = replacement;
|
|
if (!acc[id]) {
|
|
acc[id] = {};
|
|
}
|
|
acc[id] = value;
|
|
return acc;
|
|
}, {});
|
|
|
|
return recipientData;
|
|
}
|
|
|
|
#updateRecipientVariables(data, replacementDefinitions) {
|
|
for (const def of replacementDefinitions) {
|
|
data = data.replace(
|
|
def.token,
|
|
`%recipient.${def.id}%`
|
|
);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Create mailgun error message for storing in the database
|
|
* @param {Object} error
|
|
* @param {string} error.message
|
|
* @param {string} error.details
|
|
* @returns {string}
|
|
*/
|
|
#createMailgunErrorMessage(error) {
|
|
const message = (error?.message || '') + ':' + (error?.details || '');
|
|
return message.slice(0, 2000);
|
|
}
|
|
|
|
/**
|
|
* Send an email using the Mailgun API
|
|
* @param {import('./sending-service').EmailData} data
|
|
* @param {EmailSendingOptions} options
|
|
* @returns {Promise<EmailProviderSuccessResponse>}
|
|
*/
|
|
async send(data, options) {
|
|
const {
|
|
subject,
|
|
html,
|
|
plaintext,
|
|
from,
|
|
replyTo,
|
|
emailId,
|
|
recipients,
|
|
replacementDefinitions
|
|
} = data;
|
|
|
|
logging.info(`Sending email to ${recipients.length} recipients`);
|
|
const startTime = Date.now();
|
|
debug(`sending message to ${recipients.length} recipients`);
|
|
|
|
try {
|
|
const messageData = {
|
|
subject,
|
|
html,
|
|
plaintext,
|
|
from,
|
|
replyTo,
|
|
id: emailId,
|
|
track_opens: !!options.openTrackingEnabled,
|
|
track_clicks: !!options.clickTrackingEnabled
|
|
};
|
|
|
|
// create recipient data for Mailgun using replacement definitions
|
|
const recipientData = recipients.reduce((acc, recipient) => {
|
|
acc[recipient.email] = this.#createRecipientData(recipient.replacements);
|
|
return acc;
|
|
}, {});
|
|
|
|
// update content to use Mailgun variable syntax for all replacements
|
|
['html', 'plaintext'].forEach((key) => {
|
|
if (messageData[key]) {
|
|
messageData[key] = this.#updateRecipientVariables(messageData[key], replacementDefinitions);
|
|
}
|
|
});
|
|
|
|
// send the email using Mailgun
|
|
// uses empty replacements array as we've already replaced all tokens with Mailgun variables
|
|
const response = await this.#mailgunClient.send(
|
|
messageData,
|
|
recipientData,
|
|
[]
|
|
);
|
|
|
|
debug(`sent message (${Date.now() - startTime}ms)`);
|
|
logging.info(`Sent message (${Date.now() - startTime}ms)`);
|
|
|
|
// Return mailgun provider id, trim <> from response
|
|
return {
|
|
id: response.id.trim().replace(/^<|>$/g, '')
|
|
};
|
|
} catch (e) {
|
|
let ghostError;
|
|
if (e.error && e.messageData) {
|
|
const {error, messageData} = e;
|
|
|
|
// REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#status-codes
|
|
ghostError = new errors.EmailError({
|
|
statusCode: error.status,
|
|
message: this.#createMailgunErrorMessage(error),
|
|
errorDetails: JSON.stringify({error, messageData}),
|
|
context: `Mailgun Error ${error.status}: ${error.details}`,
|
|
help: `https://ghost.org/docs/newsletters/#bulk-email-configuration`,
|
|
code: 'BULK_EMAIL_SEND_FAILED'
|
|
});
|
|
} else {
|
|
ghostError = new errors.EmailError({
|
|
statusCode: undefined,
|
|
message: e.message,
|
|
errorDetails: undefined,
|
|
context: e.context || 'Mailgun Error',
|
|
code: 'BULK_EMAIL_SEND_FAILED'
|
|
});
|
|
}
|
|
|
|
logging.warn(ghostError);
|
|
debug(`failed to send message (${Date.now() - startTime}ms)`);
|
|
|
|
// log error to custom error handler. ex sentry
|
|
this.#errorHandler(ghostError);
|
|
throw ghostError;
|
|
}
|
|
}
|
|
|
|
getMaximumRecipients() {
|
|
return MailgunEmailProvider.BATCH_SIZE;
|
|
}
|
|
}
|
|
|
|
module.exports = MailgunEmailProvider;
|