Ghost/ghost/email-service/lib/sending-service.js
Simon Backx fb3cbe5fc8
Added short lived caching to email batch body (#16233)
fixes https://github.com/TryGhost/Team/issues/2522

When sending an email for multiple batches at the same time, we now
reuse the same email body for each batch in the same segment. This
reduces the amount of database queries and makes the sending more
reliable in case of database failures.

The cache is short lived. After sending the email it is automatically
garbage collected.
2023-02-07 11:01:49 +01:00

161 lines
4.8 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("./email-renderer").ReplacementDefinition[]} replacementDefinitions
*
* @typedef {object} IEmailProviderService
* @prop {(emailData: EmailData, options: EmailSendingOptions) => Promise<EmailProviderSuccessResponse>} send
* @prop {() => number} getMaximumRecipients
*
* @typedef {object} Post
* @typedef {object} Newsletter
*/
/**
* @typedef {import("./email-renderer")} EmailRenderer
* @typedef {import("./email-renderer").EmailBody} EmailBody
*/
/**
* @typedef {object} EmailSendingOptions
* @prop {boolean} clickTrackingEnabled
* @prop {boolean} openTrackingEnabled
* @prop {{get(id: string): EmailBody | null, set(id: string, body: EmailBody): void}} [emailBodyCache]
*/
/**
* @typedef {import("./email-renderer").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();
}
/**
* 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');
/**
* @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),
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
});
}
/**
* @private
* @param {MemberLike[]} members
* @param {import("./email-renderer").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;