Ghost/ghost/email-service/lib/MailgunEmailProvider.js
naz 320c659a1e
Added forced debug output for EmailRecipients fetching (#16823)
refs TryGhost/Team#3229

- The issue we are observing that even though the returned amount of email recipients should not ever accede the max batch size (1000 in case of MailGun), there are rare glitches when this number is doubled and we fetch 2000 records instead.
- The fix takes it's best guess in de-duping data in the batch and then truncates it if the amount of records is still above the threshold. This ensures we at least end up sending the emails out to some of the recipients instead of none.
2023-05-19 17:57:24 +07:00

180 lines
5.5 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/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
};
// 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 MailgunEmailProvider.BATCH_SIZE;
}
}
module.exports = MailgunEmailProvider;