Ghost/ghost/email-service/lib/email-service.js
Simon Backx 26d51687b1
Implemented email previews and tests using new email flow (#15899)
fixes https://github.com/TryGhost/Team/issues/2330

Uses new flow for previewing and testing emails (only if email stability
flag is enabled)
2022-11-30 13:56:28 +01:00

224 lines
7.3 KiB
JavaScript

/* eslint-disable no-unused-vars */
/**
* @typedef {object} Post
* @typedef {object} Email
* @typedef {object} LimitService
*/
const BatchSendingService = require('./batch-sending-service');
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const EmailRenderer = require('./email-renderer');
const EmailSegmenter = require('./email-segmenter');
const SendingService = require('./sending-service');
const messages = {
archivedNewsletterError: 'Cannot send email to archived newsletters',
missingNewsletterError: 'The post does not have a newsletter relation'
};
class EmailService {
#batchSendingService;
#sendingService;
#models;
#settingsCache;
#emailRenderer;
#emailSegmenter;
#limitService;
#membersRepository;
/**
*
* @param {object} dependencies
* @param {BatchSendingService} dependencies.batchSendingService
* @param {SendingService} dependencies.sendingService
* @param {object} dependencies.models
* @param {object} dependencies.models.Email
* @param {object} dependencies.settingsCache
* @param {EmailRenderer} dependencies.emailRenderer
* @param {EmailSegmenter} dependencies.emailSegmenter
* @param {LimitService} dependencies.limitService
* @param {object} dependencies.membersRepository
*/
constructor({
batchSendingService,
sendingService,
models,
settingsCache,
emailRenderer,
emailSegmenter,
limitService,
membersRepository
}) {
this.#batchSendingService = batchSendingService;
this.#models = models;
this.#settingsCache = settingsCache;
this.#emailRenderer = emailRenderer;
this.#emailSegmenter = emailSegmenter;
this.#limitService = limitService;
this.#membersRepository = membersRepository;
this.#sendingService = sendingService;
}
/**
* @private
*/
async checkLimits() {
// Check host limit for allowed member count and throw error if over limit
// - do this even if it's a retry so that there's no way around the limit
if (this.#limitService.isLimited('members')) {
await this.#limitService.errorIfIsOverLimit('members');
}
// Check host limit for disabled emails or going over emails limit
if (this.#limitService.isLimited('emails')) {
await this.#limitService.errorIfWouldGoOverLimit('emails');
}
}
/**
*
* @param {Post} post
* @returns {Promise<Email>}
*/
async createEmail(post) {
let newsletter = await post.getLazyRelation('newsletter');
if (!newsletter) {
throw new errors.EmailError({
message: tpl(messages.missingNewsletterError)
});
}
if (newsletter.get('status') !== 'active') {
// A post might have been scheduled to an archived newsletter.
// Don't send it (people can't unsubscribe any longer).
throw new errors.EmailError({
message: tpl(messages.archivedNewsletterError)
});
}
const emailRecipientFilter = post.get('email_recipient_filter');
const email = await this.#models.Email.add({
post_id: post.id,
newsletter_id: newsletter.id,
status: 'pending',
submitted_at: new Date(),
track_opens: !!this.#settingsCache.get('email_track_opens'),
track_clicks: !!this.#settingsCache.get('email_track_clicks'),
feedback_enabled: !!newsletter.get('feedback_enabled'),
recipient_filter: emailRecipientFilter,
subject: this.#emailRenderer.getSubject(post),
from: this.#emailRenderer.getFromAddress(post, newsletter),
replyTo: this.#emailRenderer.getReplyToAddress(post, newsletter),
email_count: await this.#emailSegmenter.getMembersCount(newsletter, emailRecipientFilter),
source: post.get('lexical') || post.get('mobiledoc'),
source_type: post.get('lexical') ? 'lexical' : 'mobiledoc'
});
try {
await this.checkLimits();
this.#batchSendingService.scheduleEmail(email);
} catch (e) {
await email.save({
status: 'failed',
error: e.message || 'Something went wrong while scheduling the email'
}, {patch: true});
}
return email;
}
async retryEmail(email) {
await this.checkLimits();
this.#batchSendingService.scheduleEmail(email);
return email;
}
/**
* @private
* @param {string} [email] (optional) Search for a member with this email address and use it as the example. If not found, defaults to the default but still uses the provided email address.
* @return {Promise<import('./email-renderer').MemberLike>}
*/
async getExampleMember(email) {
/**
* @type {import('./email-renderer').MemberLike}
*/
const exampleMember = {
id: 'example-id',
uuid: 'example-uuid',
email: 'jamie@example.com',
name: 'Jamie Larson'
};
// fetch any matching members so that replacements use expected values
if (email) {
const member = await this.#membersRepository.get({email});
if (member) {
exampleMember.id = member.id;
exampleMember.uuid = member.get('uuid');
exampleMember.email = member.get('email');
exampleMember.name = member.get('name');
} else {
exampleMember.name = ''; // Force empty name to simulate name fallbacks
exampleMember.email = email;
}
}
return exampleMember;
}
/**
*
* @param {*} post
* @param {*} newsletter
* @param {import('./email-renderer').Segment} segment
* @returns {Promise<{subject: string, html: string, plaintext: string}>} Email preview
*/
async previewEmail(post, newsletter, segment) {
const exampleMember = await this.getExampleMember();
const subject = this.#emailRenderer.getSubject(post);
let {html, plaintext, replacements} = await this.#emailRenderer.renderBody(post, newsletter, segment, {clickTrackingEnabled: false});
// Do manual replacements with an example member
for (const replacement of replacements) {
html = html.replace(replacement.token, replacement.getValue(exampleMember));
plaintext = plaintext.replace(replacement.token, replacement.getValue(exampleMember));
}
return {
subject,
html,
plaintext
};
}
/**
*
* @param {*} post
* @param {*} newsletter
* @param {import('./email-renderer').Segment} segment
* @param {string[]} emails
*/
async sendTestEmail(post, newsletter, segment, emails) {
const members = [];
for (const email of emails) {
members.push(await this.getExampleMember(email));
}
await this.#sendingService.send({
post,
newsletter,
segment,
members,
emailId: null
}, {
clickTrackingEnabled: false,
openTrackingEnabled: false
});
}
}
module.exports = EmailService;