const {promises: fs, readFileSync} = require('fs'); const path = require('path'); const moment = require('moment'); const glob = require('glob'); const {EmailAddressParser} = require('@tryghost/email-addresses'); class StaffServiceEmails { constructor({logging, models, mailer, settingsHelpers, settingsCache, blogIcon, urlUtils, labs}) { this.logging = logging; this.models = models; this.mailer = mailer; this.settingsHelpers = settingsHelpers; this.settingsCache = settingsCache; this.blogIcon = blogIcon; this.urlUtils = urlUtils; this.labs = labs; this.Handlebars = require('handlebars').create(); this.registerPartials(); this.registerHelpers(); } async notifyFreeMemberSignup({ member, attribution }, options) { const users = await this.models.User.getEmailAlertUsers('free-signup', options); for (const user of users) { const to = user.email; const memberData = this.getMemberData(member); const subject = `🥳 Free member signup: ${memberData.name}`; let attributionTitle = attribution?.title || ''; // In case of a homepage attribution, we want to show the title as "Homepage" on email if (attributionTitle === 'homepage') { attributionTitle = 'Homepage'; } let staffUrl = this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}`); const templateData = { memberData, attributionTitle, attributionUrl: attribution?.url || '', referrerSource: attribution?.referrerSource, siteTitle: this.settingsCache.get('title'), siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}), siteUrl: this.urlUtils.getSiteUrl(), siteDomain: this.siteDomain, accentColor: this.settingsCache.get('accent_color'), fromEmail: this.fromEmailAddress, toEmail: to, staffUrl: staffUrl }; const {html, text} = await this.renderEmailTemplate('new-free-signup', templateData); await this.sendMail({ to, subject, html, text }); } } async notifyPaidSubscriptionStarted({member, subscription, offer, tier, attribution}, options = {}) { const users = await this.models.User.getEmailAlertUsers('paid-started', options); for (const user of users) { const to = user.email; const memberData = this.getMemberData(member); const subject = `💸 Paid subscription started: ${memberData.name}`; const amount = this.getAmount(subscription?.amount); const formattedAmount = this.getFormattedAmount({currency: subscription?.currency, amount}); const interval = subscription?.interval || ''; const tierData = { name: tier?.name || '', details: `${formattedAmount}/${interval}` }; const subscriptionData = { startedOn: this.getFormattedDate(subscription.startDate) }; let offerData = this.getOfferData(offer); let attributionTitle = attribution?.title || ''; // In case of a homepage attribution, we want to show the title as "Homepage" on email if (attributionTitle === 'homepage') { attributionTitle = 'Homepage'; } let staffUrl = this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}`); const templateData = { memberData, attributionTitle, attributionUrl: attribution?.url || '', referrerSource: attribution?.referrerSource, tierData, offerData, subscriptionData, siteTitle: this.settingsCache.get('title'), siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}), siteUrl: this.urlUtils.getSiteUrl(), siteDomain: this.siteDomain, accentColor: this.settingsCache.get('accent_color'), fromEmail: this.fromEmailAddress, toEmail: to, staffUrl: staffUrl }; const {html, text} = await this.renderEmailTemplate('new-paid-started', templateData); await this.sendMail({ to, subject, html, text }); } } async notifyPaidSubscriptionCanceled({member, tier, subscription, cancelNow, expiryAt, canceledAt}, options = {}) { const users = await this.models.User.getEmailAlertUsers('paid-canceled', options); for (const user of users) { const to = user.email; const memberData = this.getMemberData(member); const subject = `⚠️ Cancellation: ${memberData.name}`; const amount = this.getAmount(subscription?.amount); const formattedAmount = this.getFormattedAmount({currency: subscription?.currency, amount}); const interval = subscription?.interval; const tierDetail = `${formattedAmount}/${interval}`; const tierData = { name: tier?.name || '', details: tierDetail }; const subscriptionData = { expiryAt: this.getFormattedDate(expiryAt), cancelNow: cancelNow, canceledAt: this.getFormattedDate(canceledAt), cancellationReason: subscription.cancellationReason || '' }; let staffUrl = this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}`); const templateData = { memberData, tierData, subscriptionData, siteTitle: this.settingsCache.get('title'), siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}), siteUrl: this.urlUtils.getSiteUrl(), siteDomain: this.siteDomain, accentColor: this.settingsCache.get('accent_color'), fromEmail: this.fromEmailAddress, toEmail: to, staffUrl: staffUrl }; const {html, text} = await this.renderEmailTemplate('new-paid-cancellation', templateData); await this.sendMail({ to, subject, html, text }); } } /** * @param {object} recipient * @param {string} recipient.email * @param {string} recipient.slug */ async getSharedData(recipient) { let staffUrl = this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${recipient.slug}`); return { siteTitle: this.settingsCache.get('title'), siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}), siteUrl: this.urlUtils.getSiteUrl(), siteDomain: this.siteDomain, accentColor: this.settingsCache.get('accent_color'), fromEmail: this.fromEmailAddress, toEmail: recipient.email, staffUrl: staffUrl }; } /** * * @param {object} eventData * @param {object} eventData.milestone * * @returns {Promise} */ async notifyMilestoneReceived({milestone}) { if (!milestone?.emailSentAt || milestone?.meta?.reason) { // Do not send an email when no email was set to be sent or a reason // not to send provided return; } const formattedValue = this.getFormattedAmount({currency: milestone?.currency, amount: milestone.value, maximumFractionDigits: 0}); const milestoneEmailConfig = require('./milestone-email-config')(this.settingsCache.get('title'), formattedValue); const emailData = milestoneEmailConfig?.[milestone.type]?.[milestone.value]; if (!emailData || Object.keys(emailData).length === 0) { // Do not attempt to send an email with invalid or missing data this.logging.warn('No Milestone email sent. Invalid or missing data.'); return; } const emailPromises = []; const users = await this.models.User.getEmailAlertUsers('milestone-received'); for (const user of users) { const to = user.email; let staffUrl = this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}`); const templateData = { siteTitle: this.settingsCache.get('title'), siteUrl: this.urlUtils.getSiteUrl(), siteDomain: this.siteDomain, fromEmail: this.fromEmailAddress, ...emailData, partial: `milestones/${milestone.value}`, toEmail: to, adminUrl: this.urlUtils.urlFor('admin', true), staffUrl: staffUrl }; const {html, text} = await this.renderEmailTemplate('new-milestone-received', templateData); emailPromises.push(await this.sendMail({ to, subject: emailData.subject, html, text })); } const results = await Promise.allSettled(emailPromises); for (const result of results) { if (result.status === 'rejected') { this.logging.warn(result?.reason); } } } /** * * @param {object} eventData * @param {import('@tryghost/donations').DonationPaymentEvent} eventData.donationPaymentEvent * * @returns {Promise} */ async notifyDonationReceived({donationPaymentEvent}) { const emailPromises = []; const users = await this.models.User.getEmailAlertUsers('donation'); const formattedAmount = this.getFormattedAmount({currency: donationPaymentEvent.currency, amount: donationPaymentEvent.amount / 100}); const subject = `💰 One-time payment received: ${formattedAmount} from ${donationPaymentEvent.name ?? donationPaymentEvent.email}`; const memberData = donationPaymentEvent.memberId ? this.getMemberData({ id: donationPaymentEvent.memberId, name: donationPaymentEvent.name ?? null, email: donationPaymentEvent.email }) : null; for (const user of users) { const to = user.email; let staffUrl = this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${user.slug}`); const templateData = { siteTitle: this.settingsCache.get('title'), siteUrl: this.urlUtils.getSiteUrl(), siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}), siteDomain: this.siteDomain, fromEmail: this.fromEmailAddress, toEmail: to, adminUrl: this.urlUtils.urlFor('admin', true), staffUrl: staffUrl, donation: { name: donationPaymentEvent.name ?? donationPaymentEvent.email, email: donationPaymentEvent.email, amount: formattedAmount, donationMessage: donationPaymentEvent.donationMessage }, memberData, accentColor: this.settingsCache.get('accent_color') }; const {html, text} = await this.renderEmailTemplate('donation', templateData); emailPromises.push(await this.sendMail({ to, subject, html, text })); } const results = await Promise.allSettled(emailPromises); for (const result of results) { if (result.status === 'rejected') { this.logging.warn(result?.reason); } } } // Utils /** @private */ getMemberData(member) { let name = member?.name || member?.email; return { name, email: member?.email, showEmail: !!member?.name, adminUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/members/${member.id}`), initials: this.extractInitials(name), location: this.getGeolocationData(member.geolocation), createdAt: member.created_at ? moment(member.created_at).format('D MMM YYYY') : null }; } /** @private */ getGeolocationData(geolocation) { if (!geolocation) { return null; } try { const geolocationData = JSON.parse(geolocation); return geolocationData?.country || null; } catch (e) { return null; } } /** @private */ getFormattedAmount({amount = 0, currency, maximumFractionDigits = 2}) { if (!currency) { return amount > 0 ? Intl.NumberFormat('en', {maximumFractionDigits}).format(amount) : ''; } return Intl.NumberFormat('en', { style: 'currency', currency, currencyDisplay: 'symbol', maximumFractionDigits, // see https://github.com/andyearnshaw/Intl.js/issues/123 minimumFractionDigits: maximumFractionDigits }).format(amount); } /** @private */ getAmount(amount) { if (!amount) { return 0; } return amount / 100; } /** @private */ getFormattedDate(date) { if (!date) { return ''; } return moment(date).format('D MMM YYYY'); } /** @private */ getOfferData(offer) { if (offer) { let offAmount = ''; let offDuration = ''; if (offer.duration === 'once') { offDuration = ', first payment'; } else if (offer.duration === 'repeating') { offDuration = `, first ${offer.durationInMonths} months`; } else if (offer.duration === 'forever') { offDuration = `, forever`; } else if (offer.duration === 'trial') { offDuration = ''; } if (offer.type === 'percent') { offAmount = `${offer.amount}% off`; } else if (offer.type === 'fixed') { const amount = this.getAmount(offer.amount); offAmount = `${this.getFormattedAmount({currency: offer.currency, amount})} off`; } else if (offer.type === 'trial') { offAmount = `${offer.amount} days free`; } return { name: offer.name, details: `${offAmount}${offDuration}` }; } } get siteDomain() { const [, siteDomain] = this.urlUtils.getSiteUrl() .match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i')); return siteDomain; } get defaultEmailDomain() { return this.settingsHelpers.getDefaultEmailDomain(); } get fromEmailAddress() { if (this.settingsHelpers.useNewEmailAddresses()) { return EmailAddressParser.stringify(this.settingsHelpers.getDefaultEmail()); } return `ghost@${this.defaultEmailDomain}`; } extractInitials(name = '') { const names = name.split(' '); const initials = names.length > 1 ? [names[0][0], names[names.length - 1][0]] : [names[0][0]]; return initials.join('').toUpperCase(); } async sendMail(message) { if (process.env.NODE_ENV !== 'production') { this.logging.warn(message.text); } let msg = Object.assign({ from: this.fromEmailAddress, forceTextContent: true }, message); return this.mailer.send(msg); } registerPartials() { const rootDirname = './email-templates/partials/'; const files = glob.sync('*.hbs', {cwd: path.join(__dirname, rootDirname)}); files.forEach((fileName) => { const name = fileName.replace(/.hbs$/, ''); const filePath = path.join(__dirname, rootDirname, `${name}.hbs`); const content = readFileSync(filePath, 'utf8'); this.Handlebars.registerPartial(name, content); }); } registerHelpers() { this.Handlebars.registerHelper('eq', function (arg, value, options) { if (arg === value) { return options.fn(this); } else { return options.inverse(this); } }); this.Handlebars.registerHelper('limit', function (array, limit) { if (!Array.isArray(array)) { return []; } return array.slice(0,limit); }); this.Handlebars.registerHelper('encodeURIComponent', function (string) { return encodeURIComponent(string); }); } async renderHTML(templateName, data) { const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `${templateName}.hbs`), 'utf8'); const htmlTemplate = this.Handlebars.compile(Buffer.from(htmlTemplateSource).toString()); let sharedData = {}; if (data.recipient) { sharedData = await this.getSharedData(data.recipient); } const html = htmlTemplate({ ...data, ...sharedData }); const juice = require('juice'); return juice(html, {inlinePseudoElements: true, removeStyleTags: true}); } async renderText(templateName, data) { const textTemplate = require(`./email-templates/${templateName}.txt.js`); let sharedData = {}; if (data.recipient) { sharedData = await this.getSharedData(data.recipient); } return textTemplate({ ...data, ...sharedData }); } async renderEmailTemplate(templateName, data) { const html = await this.renderHTML(templateName, data); const text = await this.renderText(templateName, data); return {html, text}; } } module.exports = StaffServiceEmails;