mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-27 10:42:45 +03:00
ee514a397c
ref https://linear.app/tryghost/issue/ONC-217/implement-the-deliverytime-option-in-mailgun-api-calls Ghost experiences its highest peak load immediately after sending out a newsletter, as it recieves an influx of traffic from users clicking on the links in the email, a burst of email analytics events to process from mailgun, and an increase in organic traffic to the site's frontend as well as the admin analytics pages. The `BatchSendingService` currently sends all the batches to Mailgun as quickly as possible, which may contribute to higher peak loads. This commit adds a `deliverytime` parameter to our API calls to Mailgun, which allows us to specify a time in the future when we want the email to be delivered. This will allow us to moderate the rate at which emails are delivered, and in turn that should moderate the peak traffic volume that Ghost receives in the first 2-3 minutes after sending an email. The `deliverytime` is calculated based on a configurable parameter: `bulkEmail.targetDeliveryWindow`, which specifies the maximum allowable time (in milliseconds) after the email is first sent for Ghost to instruct Mailgun to deliver the emails. Ghost will attempt to space out all the batches as evenly as possible throughout the specified window. For example, if the targetDeliveryWindow is set to `300000` (5 minutes) and there are 100 batches, Ghost will set the `deliveryTime` for each batch ~3 seconds apart.
192 lines
5.9 KiB
JavaScript
192 lines
5.9 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
|
|
* @prop {Date} deliveryTime
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} EmailProviderSuccessResponse
|
|
* @prop {string} id
|
|
*/
|
|
|
|
class MailgunEmailProvider {
|
|
#mailgunClient;
|
|
#errorHandler;
|
|
|
|
/**
|
|
* @param {object} dependencies
|
|
* @param {import('@tryghost/mailgun-client/lib/MailgunClient')} 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;
|
|
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 || 'Mailgun Error') + (error?.details ? (': ' + error.details) : '');
|
|
return message.slice(0, 2000);
|
|
}
|
|
|
|
/**
|
|
* Send an email using the Mailgun API
|
|
* @param {import('./SendingService').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
|
|
};
|
|
|
|
if (options.deliveryTime && options.deliveryTime instanceof Date) {
|
|
messageData.deliveryTime = options.deliveryTime;
|
|
}
|
|
|
|
// 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: this.#createMailgunErrorMessage(e),
|
|
errorDetails: undefined,
|
|
context: e.context || 'Mailgun Error',
|
|
code: 'BULK_EMAIL_SEND_FAILED'
|
|
});
|
|
}
|
|
|
|
debug(`failed to send message (${Date.now() - startTime}ms)`);
|
|
|
|
throw ghostError;
|
|
}
|
|
}
|
|
|
|
getMaximumRecipients() {
|
|
return this.#mailgunClient.getBatchSize();
|
|
}
|
|
|
|
/**
|
|
* Returns the configured delay between batches in milliseconds
|
|
*
|
|
* @returns {number}
|
|
*/
|
|
getTargetDeliveryWindow() {
|
|
return this.#mailgunClient.getTargetDeliveryWindow();
|
|
}
|
|
}
|
|
|
|
module.exports = MailgunEmailProvider;
|