Ghost/ghost/email-service/lib/SendingService.js
Chris Raible ee514a397c
Added configurable target delivery window for batch sending (#20719)
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.
2024-09-05 22:28:40 -07:00

174 lines
5.2 KiB
JavaScript

const validator = require('@tryghost/validator');
const logging = require('@tryghost/logging');
/**
* @typedef {object} EmailData
* @prop {string} html
* @prop {string} plaintext
* @prop {string} subject
* @prop {string} from
* @prop {string} emailId
* @prop {string} [replyTo]
* @prop {Recipient[]} recipients
* @prop {import("./EmailRenderer").ReplacementDefinition[]} replacementDefinitions
*
* @typedef {object} IEmailProviderService
* @prop {(emailData: EmailData, options: EmailSendingOptions) => Promise<EmailProviderSuccessResponse>} send
* @prop {() => number} getMaximumRecipients
* @prop {() => number} getTargetDeliveryWindow
*
* @typedef {object} Post
* @typedef {object} Newsletter
*/
/**
* @typedef {import("./EmailRenderer")} EmailRenderer
* @typedef {import("./EmailRenderer").EmailBody} EmailBody
*/
/**
* @typedef {object} EmailSendingOptions
* @prop {boolean} clickTrackingEnabled
* @prop {boolean} openTrackingEnabled
* @prop {Date} deliveryTime
* @prop {{get(id: string): EmailBody | null, set(id: string, body: EmailBody): void}} [emailBodyCache]
*/
/**
* @typedef {import("./EmailRenderer").MemberLike} MemberLike
*/
/**
* @typedef {object} Recipient
* @prop {string} email
* @prop {Replacement[]} replacements
*/
/**
* @typedef {object} Replacement
* @prop {string} id
* @prop {RegExp} token
* @prop {string} value
*/
/**
* @typedef {object} EmailProviderSuccessResponse
* @prop {string} id
*/
class SendingService {
#emailProvider;
#emailRenderer;
/**
* @param {object} dependencies
* @param {IEmailProviderService} dependencies.emailProvider
* @param {EmailRenderer} dependencies.emailRenderer
*/
constructor({
emailProvider,
emailRenderer
}) {
this.#emailProvider = emailProvider;
this.#emailRenderer = emailRenderer;
}
getMaximumRecipients() {
return this.#emailProvider.getMaximumRecipients();
}
/**
* Returns the configured target delivery window in seconds
*
* @returns {number}
*/
getTargetDeliveryWindow() {
return this.#emailProvider.getTargetDeliveryWindow();
}
/**
* Send a given post, rendered for a given newsletter and segment to the members provided in the list
* @param {object} data
* @param {Post} data.post
* @param {Newsletter} data.newsletter
* @param {string|null} data.segment
* @param {string|null} data.emailId
* @param {MemberLike[]} data.members
* @param {EmailSendingOptions} options
* @returns {Promise<EmailProviderSuccessResponse>}
*/
async send({post, newsletter, segment, members, emailId}, options) {
const cacheId = emailId + '-' + (segment ?? 'null');
const isTestEmail = options.isTestEmail ?? false;
/**
* @type {EmailBody | null}
*/
let emailBody = null;
if (options.emailBodyCache) {
emailBody = options.emailBodyCache.get(cacheId);
}
if (!emailBody) {
emailBody = await this.#emailRenderer.renderBody(
post,
newsletter,
segment,
{
clickTrackingEnabled: !!options.clickTrackingEnabled
}
);
if (options.emailBodyCache) {
options.emailBodyCache.set(cacheId, emailBody);
}
}
const recipients = this.buildRecipients(members, emailBody.replacements);
return await this.#emailProvider.send({
subject: this.#emailRenderer.getSubject(post, isTestEmail),
from: this.#emailRenderer.getFromAddress(post, newsletter),
replyTo: this.#emailRenderer.getReplyToAddress(post, newsletter) ?? undefined,
html: emailBody.html,
plaintext: emailBody.plaintext,
recipients,
emailId: emailId,
replacementDefinitions: emailBody.replacements
}, {
clickTrackingEnabled: !!options.clickTrackingEnabled,
openTrackingEnabled: !!options.openTrackingEnabled,
...(options.deliveryTime && {deliveryTime: options.deliveryTime})
});
}
/**
* @private
* @param {MemberLike[]} members
* @param {import("./EmailRenderer").ReplacementDefinition[]} replacementDefinitions
* @returns {Recipient[]}
*/
buildRecipients(members, replacementDefinitions) {
return members.map((member) => {
return {
email: member.email?.trim(),
replacements: replacementDefinitions.map((def) => {
return {
id: def.id,
token: def.token,
value: def.getValue(member) || ''
};
})
};
}).filter((recipient) => {
// Remove invalid recipient email addresses
const isValidRecipient = validator.isEmail(recipient.email, {legacy: false});
if (!isValidRecipient) {
logging.warn(`Removed recipient ${recipient.email} from list because it is not a valid email address`);
}
return isValidRecipient;
});
}
}
module.exports = SendingService;