mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-26 12:21:36 +03:00
47cd7a7095
refs https://ghost.slack.com/archives/C02G9E68C/p1670916538764019 - We receive events that don't have an emailId or providerId. - We filter those events now and log them as an error
224 lines
7.3 KiB
JavaScript
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;
|