diff --git a/ghost/core/core/server/api/endpoints/automated-emails.js b/ghost/core/core/server/api/endpoints/automated-emails.js index e058cc6f86a..eb86db0c0e8 100644 --- a/ghost/core/core/server/api/endpoints/automated-emails.js +++ b/ghost/core/core/server/api/endpoints/automated-emails.js @@ -1,3 +1,4 @@ +const _ = require('lodash'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const models = require('../../models'); @@ -7,6 +8,32 @@ const messages = { automatedEmailNotFound: 'Automated email not found.' }; +// NOTE: This file is in a transitionary state. The `automated_emails` database table was split into +// `welcome_email_automations` (automation metadata: status, name, slug) and +// `welcome_email_automated_emails` (email content: subject, lexical, sender fields). This controller +// acts as a facade that joins/splits data between those two models while preserving the original +// `automated_emails` API shape externally. +const AUTOMATION_FIELDS = ['status', 'name', 'slug']; +const EMAIL_FIELDS = ['subject', 'lexical', 'sender_name', 'sender_email', 'sender_reply_to', 'email_design_setting_id']; + +function flattenAutomation(automation, email = automation.related('welcomeEmailAutomatedEmail')) { + const result = { + id: automation.id, + status: automation.get('status'), + name: automation.get('name'), + slug: automation.get('slug'), + subject: email.get('subject'), + lexical: email.get('lexical'), + sender_name: email.get('sender_name'), + sender_email: email.get('sender_email'), + sender_reply_to: email.get('sender_reply_to'), + email_design_setting_id: email.get('email_design_setting_id'), + created_at: automation.get('created_at'), + updated_at: automation.get('updated_at') + }; + return result; +} + /** @type {import('@tryghost/api-framework').Controller} */ const controller = { docName: 'automated_emails', @@ -23,8 +50,15 @@ const controller = { 'page' ], permissions: true, - query(frame) { - return models.AutomatedEmail.findPage(frame.options); + async query(frame) { + const result = await models.WelcomeEmailAutomation.findPage({ + ...frame.options, + withRelated: ['welcomeEmailAutomatedEmail'] + }); + return { + ...result, + data: result.data.map(automation => flattenAutomation(automation)) + }; } }, @@ -41,14 +75,17 @@ const controller = { ], permissions: true, async query(frame) { - const model = await models.AutomatedEmail.findOne(frame.data, frame.options); + const model = await models.WelcomeEmailAutomation.findOne(frame.data, { + ...frame.options, + withRelated: ['welcomeEmailAutomatedEmail'] + }); if (!model) { throw new errors.NotFoundError({ message: tpl(messages.automatedEmailNotFound) }); } - return model; + return flattenAutomation(model); } }, @@ -60,7 +97,22 @@ const controller = { permissions: true, async query(frame) { const data = frame.data.automated_emails[0]; - return models.AutomatedEmail.add(data, frame.options); + + const emailData = _.pick(data, EMAIL_FIELDS); + const automationData = _.pick(data, AUTOMATION_FIELDS); + + return models.Base.transaction(async (transacting) => { + const automation = await models.WelcomeEmailAutomation.add(automationData, {...frame.options, transacting}); + const email = await models.WelcomeEmailAutomatedEmail.add( + { + ...emailData, + welcome_email_automation_id: automation.id, + delay_days: 0 + }, + {...frame.options, transacting} + ); + return flattenAutomation(automation, email); + }); } }, @@ -79,16 +131,42 @@ const controller = { } }, permissions: true, + // eslint-disable-next-line ghost/ghost-custom/max-api-complexity async query(frame) { const data = frame.data.automated_emails[0]; - const model = await models.AutomatedEmail.edit(data, frame.options); - if (!model) { - throw new errors.NotFoundError({ - message: tpl(messages.automatedEmailNotFound) + + const emailData = _.pick(data, EMAIL_FIELDS); + const automationData = _.pick(data, AUTOMATION_FIELDS); + + return models.Base.transaction(async (transacting) => { + let automation = await models.WelcomeEmailAutomation.findOne({id: frame.options.id}, { + transacting, + withRelated: ['welcomeEmailAutomatedEmail'] }); - } + if (!automation) { + throw new errors.NotFoundError({ + message: tpl(messages.automatedEmailNotFound) + }); + } + let email = automation.related('welcomeEmailAutomatedEmail'); + + if (Object.keys(emailData).length > 0) { + email = await models.WelcomeEmailAutomatedEmail.edit(emailData, { + ...frame.options, + transacting, + id: email.id + }); + } + + if (Object.keys(automationData).length > 0) { + automation = await models.WelcomeEmailAutomation.edit(automationData, { + ...frame.options, + transacting + }); + } - return model; + return flattenAutomation(automation, email); + }); } }, @@ -102,12 +180,15 @@ const controller = { async query(frame) { memberWelcomeEmailService.init(); const data = frame.data; - - return memberWelcomeEmailService.api.editSharedSenderOptions({ + const result = await memberWelcomeEmailService.api.editSharedSenderOptions({ sender_name: data.sender_name, sender_email: data.sender_email, sender_reply_to: data.sender_reply_to }); + return { + ...result, + data: result.data.map(automation => flattenAutomation(automation)) + }; } }, @@ -123,7 +204,11 @@ const controller = { ], async query(frame) { memberWelcomeEmailService.init(); - return memberWelcomeEmailService.api.verifySenderPropertyUpdate(frame.data.token); + const result = await memberWelcomeEmailService.api.verifySenderPropertyUpdate(frame.data.token); + return { + ...result, + data: result.data.map(automation => flattenAutomation(automation)) + }; } }, sendTestEmail: { diff --git a/ghost/core/core/server/data/migrations/versions/6.27/2026-04-06-15-55-20-split-automated-emails-into-welcome-email-tables.js b/ghost/core/core/server/data/migrations/versions/6.27/2026-04-06-15-55-20-split-automated-emails-into-welcome-email-tables.js new file mode 100644 index 00000000000..beb03cbf7c0 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.27/2026-04-06-15-55-20-split-automated-emails-into-welcome-email-tables.js @@ -0,0 +1,66 @@ +const logging = require('@tryghost/logging'); +const {createTransactionalMigration} = require('../../utils'); +const ObjectId = require('bson-objectid').default; + +module.exports = createTransactionalMigration( + async function up(knex) { + // The welcome_email_automations and welcome_email_automated_emails tables + // already exist from a prior dormant migration. This migration copies data + // from the old automated_emails table into them. + + const oldTableExists = await knex.schema.hasTable('automated_emails'); + if (!oldTableExists) { + logging.warn('Skipping data migration - automated_emails table does not exist'); + return; + } + + const rows = await knex('automated_emails').select('*'); + logging.info(`Migrating ${rows.length} rows from automated_emails to new tables`); + + // Only 2 rows exist (free + paid welcome emails), so sequential iteration is fine + // eslint-disable-next-line no-restricted-syntax + for (const row of rows) { + // Check if already migrated (idempotency) by looking for a matching slug + const existingAutomation = await knex('welcome_email_automations').where('slug', row.slug).first(); + if (existingAutomation) { + logging.warn(`Skipping row for slug ${row.slug} - already migrated`); + continue; + } + + const automationId = ObjectId().toHexString(); + + // Insert automation first (emails reference automations via FK) + await knex('welcome_email_automations').insert({ + id: automationId, + status: row.status, + name: row.name, + slug: row.slug, + created_at: row.created_at, + updated_at: row.updated_at + }); + + // Reuse the original automated_email id so the existing + // automated_email_recipients rows continue to reference the same id + await knex('welcome_email_automated_emails').insert({ + id: row.id, + welcome_email_automation_id: automationId, + delay_days: 0, + subject: row.subject, + lexical: row.lexical, + sender_name: row.sender_name, + sender_email: row.sender_email, + sender_reply_to: row.sender_reply_to, + email_design_setting_id: row.email_design_setting_id, + created_at: row.created_at, + updated_at: row.updated_at + }); + } + }, + + async function down(knex) { + // Remove migrated data from new tables + logging.info('Removing migrated data from new tables'); + await knex('welcome_email_automated_emails').del(); + await knex('welcome_email_automations').del(); + } +); diff --git a/ghost/core/core/server/data/migrations/versions/6.27/2026-04-07-15-10-32-update-automated-email-recipients-foreign-key.js b/ghost/core/core/server/data/migrations/versions/6.27/2026-04-07-15-10-32-update-automated-email-recipients-foreign-key.js new file mode 100644 index 00000000000..8515b9be367 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.27/2026-04-07-15-10-32-update-automated-email-recipients-foreign-key.js @@ -0,0 +1,79 @@ +const logging = require('@tryghost/logging'); +const {commands} = require('../../../schema'); +const {createTransactionalMigration} = require('../../utils'); + +module.exports = createTransactionalMigration( + async function up(knex) { + const recipientsTableExists = await knex.schema.hasTable('automated_email_recipients'); + if (!recipientsTableExists) { + logging.warn('Skipping foreign key migration - automated_email_recipients table does not exist'); + return; + } + + const oldTableExists = await knex.schema.hasTable('automated_emails'); + if (!oldTableExists) { + logging.warn('Skipping foreign key migration - automated_emails table does not exist'); + return; + } + + const newTableExists = await knex.schema.hasTable('welcome_email_automated_emails'); + if (!newTableExists) { + logging.warn('Skipping foreign key migration - welcome_email_automated_emails table does not exist'); + return; + } + + logging.info('Updating foreign key on automated_email_recipients'); + await commands.dropForeign({ + fromTable: 'automated_email_recipients', + fromColumn: 'automated_email_id', + toTable: 'automated_emails', + toColumn: 'id', + transaction: knex + }); + + await commands.addForeign({ + fromTable: 'automated_email_recipients', + fromColumn: 'automated_email_id', + toTable: 'welcome_email_automated_emails', + toColumn: 'id', + transaction: knex + }); + }, + + async function down(knex) { + const recipientsTableExists = await knex.schema.hasTable('automated_email_recipients'); + if (!recipientsTableExists) { + logging.warn('Skipping foreign key rollback - automated_email_recipients table does not exist'); + return; + } + + const oldTableExists = await knex.schema.hasTable('automated_emails'); + if (!oldTableExists) { + logging.warn('Skipping foreign key rollback - automated_emails table does not exist'); + return; + } + + const newTableExists = await knex.schema.hasTable('welcome_email_automated_emails'); + if (!newTableExists) { + logging.warn('Skipping foreign key rollback - welcome_email_automated_emails table does not exist'); + return; + } + + logging.info('Restoring foreign key on automated_email_recipients'); + await commands.dropForeign({ + fromTable: 'automated_email_recipients', + fromColumn: 'automated_email_id', + toTable: 'welcome_email_automated_emails', + toColumn: 'id', + transaction: knex + }); + + await commands.addForeign({ + fromTable: 'automated_email_recipients', + fromColumn: 'automated_email_id', + toTable: 'automated_emails', + toColumn: 'id', + transaction: knex + }); + } +); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 12623095ad8..52fb1189804 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -1209,7 +1209,7 @@ module.exports = { }, automated_email_recipients: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, - automated_email_id: {type: 'string', maxlength: 24, nullable: false, references: 'automated_emails.id'}, + automated_email_id: {type: 'string', maxlength: 24, nullable: false, references: 'welcome_email_automated_emails.id'}, member_id: {type: 'string', maxlength: 24, nullable: false, index: true}, member_uuid: {type: 'string', maxlength: 36, nullable: false}, member_email: {type: 'string', maxlength: 191, nullable: false}, diff --git a/ghost/core/core/server/models/automated-email-recipient.js b/ghost/core/core/server/models/automated-email-recipient.js index 75484514187..28121c60f6b 100644 --- a/ghost/core/core/server/models/automated-email-recipient.js +++ b/ghost/core/core/server/models/automated-email-recipient.js @@ -5,7 +5,7 @@ const AutomatedEmailRecipient = ghostBookshelf.Model.extend({ hasTimestamps: true, automatedEmail() { - return this.belongsTo('AutomatedEmail', 'automated_email_id'); + return this.belongsTo('WelcomeEmailAutomatedEmail', 'automated_email_id'); }, member() { return this.belongsTo('Member', 'member_id'); diff --git a/ghost/core/core/server/models/automated-email.js b/ghost/core/core/server/models/automated-email.js deleted file mode 100644 index 8a078ab2510..00000000000 --- a/ghost/core/core/server/models/automated-email.js +++ /dev/null @@ -1,101 +0,0 @@ -const ghostBookshelf = require('./base'); -const errors = require('@tryghost/errors'); -const logging = require('@tryghost/logging'); -const urlUtils = require('../../shared/url-utils'); -const lexicalLib = require('../lib/lexical'); -const {MEMBER_WELCOME_EMAIL_SLUGS, DEFAULT_EMAIL_DESIGN_SETTING_SLUG} = require('../services/member-welcome-emails/constants'); - -const MEMBER_WELCOME_EMAIL_SLUG_SET = new Set(Object.values(MEMBER_WELCOME_EMAIL_SLUGS)); - -const AutomatedEmail = ghostBookshelf.Model.extend({ - tableName: 'automated_emails', - - defaults() { - return { - status: 'inactive' - }; - }, - - /** - * @returns {import('bookshelf').Model} - */ - emailDesignSetting() { - return this.belongsTo('EmailDesignSetting', 'email_design_setting_id', 'id'); - }, - - parse() { - const attrs = ghostBookshelf.Model.prototype.parse.apply(this, arguments); - - // transform URLs from __GHOST_URL__ to absolute - if (attrs.lexical) { - attrs.lexical = urlUtils.transformReadyToAbsolute(attrs.lexical); - } - - return attrs; - }, - - async onCreating(model, attrs, options) { - if (!model.get('email_design_setting_id')) { - const emailDesignSetting = await ghostBookshelf.model('EmailDesignSetting').findOne({ - slug: DEFAULT_EMAIL_DESIGN_SETTING_SLUG - }, options); - - if (!emailDesignSetting) { - throw new errors.InternalServerError({ - message: 'Missing default email design setting for automated emails' - }); - } - - model.set('email_design_setting_id', emailDesignSetting.get('id')); - } - - return ghostBookshelf.Model.prototype.onCreating.call(this, model, attrs, options); - }, - - // Alternative to Bookshelf's .format() that is only called when writing to db - formatOnWrite(attrs) { - // Ensure lexical URLs are stored as transform-ready with __GHOST_URL__ representing config.url - if (attrs.lexical) { - attrs.lexical = urlUtils.lexicalToTransformReady(attrs.lexical, { - nodes: lexicalLib.nodes, - transformMap: lexicalLib.urlTransformMap - }); - } - - return attrs; - }, - - onSaved(model) { - if (!model?.id) { - return; - } - - const slug = model.get('slug'); - - if (!MEMBER_WELCOME_EMAIL_SLUG_SET.has(slug)) { - return; - } - - const previousStatus = model.previous('status'); - const currentStatus = model.get('status'); - const isNewModel = previousStatus === undefined; - const isEnableTransition = currentStatus === 'active' && (isNewModel || previousStatus === 'inactive'); - const isDisableTransition = previousStatus === 'active' && currentStatus === 'inactive'; - - if (!isEnableTransition && !isDisableTransition) { - return; - } - - logging.info({ - system: { - event: isEnableTransition ? 'welcome_email.enabled' : 'welcome_email.disabled', - automated_email_id: model.id, - slug - } - }, isEnableTransition ? 'Welcome email enabled' : 'Welcome email disabled'); - } -}); - -module.exports = { - AutomatedEmail: ghostBookshelf.model('AutomatedEmail', AutomatedEmail) -}; diff --git a/ghost/core/core/server/models/welcome-email-automated-email.js b/ghost/core/core/server/models/welcome-email-automated-email.js index 818cf29dd5b..c1d75e88a93 100644 --- a/ghost/core/core/server/models/welcome-email-automated-email.js +++ b/ghost/core/core/server/models/welcome-email-automated-email.js @@ -1,6 +1,8 @@ const ghostBookshelf = require('./base'); +const errors = require('@tryghost/errors'); const urlUtils = require('../../shared/url-utils'); const lexicalLib = require('../lib/lexical'); +const {DEFAULT_EMAIL_DESIGN_SETTING_SLUG} = require('../services/member-welcome-emails/constants'); const WelcomeEmailAutomatedEmail = ghostBookshelf.Model.extend({ tableName: 'welcome_email_automated_emails', @@ -31,6 +33,24 @@ const WelcomeEmailAutomatedEmail = ghostBookshelf.Model.extend({ return attrs; }, + async onCreating(model, attrs, options) { + if (!model.get('email_design_setting_id')) { + const emailDesignSetting = await ghostBookshelf.model('EmailDesignSetting').findOne({ + slug: DEFAULT_EMAIL_DESIGN_SETTING_SLUG + }, options); + + if (!emailDesignSetting) { + throw new errors.InternalServerError({ + message: 'Missing default email design setting for automated emails' + }); + } + + model.set('email_design_setting_id', emailDesignSetting.get('id')); + } + + return ghostBookshelf.Model.prototype.onCreating.call(this, model, attrs, options); + }, + // Alternative to Bookshelf's .format() that is only called when writing to db formatOnWrite(attrs) { // Ensure lexical URLs are stored as transform-ready with __GHOST_URL__ representing config.url diff --git a/ghost/core/core/server/models/welcome-email-automation.js b/ghost/core/core/server/models/welcome-email-automation.js index 1f63bee16cb..00d258f37f4 100644 --- a/ghost/core/core/server/models/welcome-email-automation.js +++ b/ghost/core/core/server/models/welcome-email-automation.js @@ -1,4 +1,8 @@ const ghostBookshelf = require('./base'); +const logging = require('@tryghost/logging'); +const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../services/member-welcome-emails/constants'); + +const MEMBER_WELCOME_EMAIL_SLUG_SET = new Set(Object.values(MEMBER_WELCOME_EMAIL_SLUGS)); const WelcomeEmailAutomation = ghostBookshelf.Model.extend({ tableName: 'welcome_email_automations', @@ -9,8 +13,42 @@ const WelcomeEmailAutomation = ghostBookshelf.Model.extend({ }; }, + welcomeEmailAutomatedEmail() { + return this.hasOne('WelcomeEmailAutomatedEmail', 'welcome_email_automation_id'); + }, + welcomeEmailAutomatedEmails() { return this.hasMany('WelcomeEmailAutomatedEmail', 'welcome_email_automation_id'); + }, + + onSaved(model) { + if (!model?.id) { + return; + } + + const slug = model.get('slug'); + + if (!MEMBER_WELCOME_EMAIL_SLUG_SET.has(slug)) { + return; + } + + const previousStatus = model.previous('status'); + const currentStatus = model.get('status'); + const isNewModel = previousStatus === undefined; + const isEnableTransition = currentStatus === 'active' && (isNewModel || previousStatus === 'inactive'); + const isDisableTransition = previousStatus === 'active' && currentStatus === 'inactive'; + + if (!isEnableTransition && !isDisableTransition) { + return; + } + + logging.info({ + system: { + event: isEnableTransition ? 'welcome_email.enabled' : 'welcome_email.disabled', + automation_id: model.id, + slug + } + }, isEnableTransition ? 'Welcome email automation enabled' : 'Welcome email automation disabled'); } }); diff --git a/ghost/core/core/server/services/member-welcome-emails/service.js b/ghost/core/core/server/services/member-welcome-emails/service.js index 01620afd28d..f9c86b726e0 100644 --- a/ghost/core/core/server/services/member-welcome-emails/service.js +++ b/ghost/core/core/server/services/member-welcome-emails/service.js @@ -11,7 +11,7 @@ const settingsHelpers = require('../settings-helpers'); const EmailAddressParser = require('../email-address/email-address-parser'); const mail = require('../mail'); // @ts-expect-error type checker has trouble with the dynamic exporting in models -const {AutomatedEmail, Newsletter} = require('../../models'); +const {WelcomeEmailAutomation, WelcomeEmailAutomatedEmail, Newsletter} = require('../../models'); const MemberWelcomeEmailRenderer = require('./member-welcome-email-renderer'); const {MEMBER_WELCOME_EMAIL_LOG_KEY, MEMBER_WELCOME_EMAIL_TAG, MEMBER_WELCOME_EMAIL_SLUGS, MESSAGES} = require('./constants'); @@ -178,8 +178,9 @@ class MemberWelcomeEmailService { } async #loadWelcomeEmailsCollection() { - return AutomatedEmail.findAll({ - filter: WELCOME_EMAIL_FILTER + return WelcomeEmailAutomation.findAll({ + filter: WELCOME_EMAIL_FILTER, + withRelated: ['welcomeEmailAutomatedEmail'] }); } @@ -201,6 +202,13 @@ class MemberWelcomeEmailService { async #loadRequiredWelcomeEmailRows() { const {free, paid} = await this.#loadWelcomeEmailsMap({requireAll: true}); + + if (!free.related('welcomeEmailAutomatedEmail')?.id || !paid.related('welcomeEmailAutomatedEmail')?.id) { + throw new errors.NotFoundError({ + message: MESSAGES.NO_MEMBER_WELCOME_EMAIL + }); + } + return [free, paid]; } @@ -229,7 +237,7 @@ class MemberWelcomeEmailService { #hasSharedSenderFieldChanged(rows, field, value) { return rows.some((row) => { - const currentValue = row.get(field); + const currentValue = row.related('welcomeEmailAutomatedEmail')?.get(field); return trimValue(currentValue) !== trimValue(value); }); } @@ -285,7 +293,10 @@ class MemberWelcomeEmailService { return; } - await Promise.all(rows.map(row => AutomatedEmail.edit(attrs, {id: row.id}))); + await Promise.all(rows.map((row) => { + const email = row.related('welcomeEmailAutomatedEmail'); + return WelcomeEmailAutomatedEmail.edit(attrs, {id: email.id}); + })); } async #sendSharedSenderVerifications(emailsToVerify = []) { @@ -325,25 +336,34 @@ class MemberWelcomeEmailService { this.#defaultNewsletterSenderOptions = await this.#getDefaultNewsletterSenderOptions(); for (const [memberStatus, slug] of Object.entries(MEMBER_WELCOME_EMAIL_SLUGS)) { - const row = this.#useDesignCustomization() - ? await AutomatedEmail.findOne({slug}, {withRelated: ['emailDesignSetting']}) - : await AutomatedEmail.findOne({slug}); + const row = await WelcomeEmailAutomation.findOne({slug}, { + withRelated: this.#useDesignCustomization() + ? ['welcomeEmailAutomatedEmail', 'welcomeEmailAutomatedEmail.emailDesignSetting'] + : ['welcomeEmailAutomatedEmail'] + }); - if (!row || !row.get('lexical')) { + if (!row) { this.#memberWelcomeEmails[memberStatus] = null; continue; } - const designSettings = this.#useDesignCustomization() ? row.related('emailDesignSetting') : null; + const email = row.related('welcomeEmailAutomatedEmail'); + + if (!email || !email.get('lexical')) { + this.#memberWelcomeEmails[memberStatus] = null; + continue; + } + + const designSettings = this.#useDesignCustomization() ? email.related('emailDesignSetting') : null; this.#memberWelcomeEmails[memberStatus] = { - lexical: row.get('lexical'), - subject: row.get('subject'), + lexical: email.get('lexical'), + subject: email.get('subject'), status: row.get('status'), designSettings: designSettings?.id ? designSettings.toJSON() : null, - senderName: row.get('sender_name'), - senderEmail: row.get('sender_email'), - senderReplyTo: row.get('sender_reply_to') + senderName: email.get('sender_name'), + senderEmail: email.get('sender_email'), + senderReplyTo: email.get('sender_reply_to') }; } } @@ -409,17 +429,24 @@ class MemberWelcomeEmailService { return false; } - const row = await AutomatedEmail.findOne({slug}); - return Boolean(row && row.get('lexical') && row.get('status') === 'active'); + const row = await WelcomeEmailAutomation.findOne({slug}, {withRelated: ['welcomeEmailAutomatedEmail']}); + if (!row) { + return false; + } + const email = row.related('welcomeEmailAutomatedEmail'); + return Boolean(email && email.get('lexical') && row.get('status') === 'active'); } async sendTestEmail({email, subject, lexical, automatedEmailId}) { // Still validate the automated email exists (for permission purposes) - const automatedEmail = this.#useDesignCustomization() - ? await AutomatedEmail.findOne({id: automatedEmailId}, {withRelated: ['emailDesignSetting']}) - : await AutomatedEmail.findOne({id: automatedEmailId}); + const automation = await WelcomeEmailAutomation.findOne({id: automatedEmailId}, { + withRelated: this.#useDesignCustomization() + ? ['welcomeEmailAutomatedEmail', 'welcomeEmailAutomatedEmail.emailDesignSetting'] + : ['welcomeEmailAutomatedEmail'] + }); + const automatedEmail = automation?.related('welcomeEmailAutomatedEmail'); - if (!automatedEmail) { + if (!automation || !automatedEmail?.id) { throw new errors.NotFoundError({ message: MESSAGES.NO_MEMBER_WELCOME_EMAIL }); diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index c76e982f444..1824b777d38 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -241,7 +241,7 @@ function createApiInstance(config) { MemberFeedback: models.MemberFeedback, EmailSpamComplaintEvent: models.EmailSpamComplaintEvent, Outbox: models.Outbox, - AutomatedEmail: models.AutomatedEmail, + WelcomeEmailAutomation: models.WelcomeEmailAutomation, AutomatedEmailRecipient: models.AutomatedEmailRecipient }, stripeAPIService: stripeService.api, diff --git a/ghost/core/core/server/services/members/members-api/members-api.js b/ghost/core/core/server/services/members/members-api/members-api.js index b0d5b0ec545..542a68f53b1 100644 --- a/ghost/core/core/server/services/members/members-api/members-api.js +++ b/ghost/core/core/server/services/members/members-api/members-api.js @@ -65,7 +65,7 @@ module.exports = function MembersAPI({ Comment, MemberFeedback, Outbox, - AutomatedEmail, + WelcomeEmailAutomation, AutomatedEmailRecipient }, tiersService, @@ -101,7 +101,7 @@ module.exports = function MembersAPI({ tokenService, newslettersService, productRepository, - AutomatedEmail, + WelcomeEmailAutomation, Member, MemberNewsletter, MemberCancelEvent, diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index 83207b31458..9fbf8e058a1 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -887,7 +887,7 @@ module.exports = class EventRepository { async getAutomatedEmailSentEvents(options = {}, filter) { options = { ...options, - withRelated: ['member', 'automatedEmail'], + withRelated: ['member', 'automatedEmail.welcomeEmailAutomation'], filter: 'custom:true', useBasicCount: true, mongoTransformer: chainTransformers( @@ -902,7 +902,14 @@ module.exports = class EventRepository { const {data: models, meta} = await this._AutomatedEmailRecipient.findPage(options); const data = models.map((model) => { - const automatedEmail = model.related('automatedEmail').toJSON(); + const automatedEmail = model.related('automatedEmail'); + const automation = automatedEmail.related('welcomeEmailAutomation'); + if (!automation || !automation.id) { + throw new errors.InternalServerError({ + message: `Automated email recipient ${model.id} has no associated welcome email automation` + }); + } + return { type: 'automated_email_sent_event', data: { @@ -912,7 +919,7 @@ module.exports = class EventRepository { member: model.related('member').toJSON(), automatedEmail: { id: automatedEmail.id, - slug: automatedEmail.slug + slug: automation.get('slug') } } }; diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index dcfd4b239a9..451451aebdc 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -64,7 +64,7 @@ module.exports = class MemberRepository { * @param {any} deps.offersAPI * @param {ITokenService} deps.tokenService * @param {any} deps.newslettersService - * @param {any} deps.AutomatedEmail + * @param {any} deps.WelcomeEmailAutomation */ constructor({ Member, @@ -84,7 +84,7 @@ module.exports = class MemberRepository { offersAPI, tokenService, newslettersService, - AutomatedEmail + WelcomeEmailAutomation }) { this._Member = Member; this._MemberNewsletter = MemberNewsletter; @@ -103,7 +103,7 @@ module.exports = class MemberRepository { this._offersAPI = offersAPI; this.tokenService = tokenService; this._newslettersService = newslettersService; - this._AutomatedEmail = AutomatedEmail; + this._WelcomeEmailAutomation = WelcomeEmailAutomation; DomainEvents.subscribe(OfferRedemptionEvent, async function (event) { if (!event.data.offerId) { @@ -359,9 +359,18 @@ module.exports = class MemberRepository { const shouldCheckFreeWelcomeEmail = WELCOME_EMAIL_SOURCES.includes(source) && isFreeSignup; let isFreeWelcomeEmailActive = false; - if (shouldCheckFreeWelcomeEmail) { - const freeWelcomeEmail = this._AutomatedEmail ? await this._AutomatedEmail.findOne({slug: MEMBER_WELCOME_EMAIL_SLUGS.free}) : null; - isFreeWelcomeEmailActive = freeWelcomeEmail && freeWelcomeEmail.get('lexical') && freeWelcomeEmail.get('status') === 'active'; + if (shouldCheckFreeWelcomeEmail && this._WelcomeEmailAutomation) { + const freeWelcomeAutomation = await this._WelcomeEmailAutomation.findOne( + {slug: MEMBER_WELCOME_EMAIL_SLUGS.free}, + {...options, withRelated: ['welcomeEmailAutomatedEmail']} + ); + const freeWelcomeEmail = freeWelcomeAutomation?.related('welcomeEmailAutomatedEmail'); + isFreeWelcomeEmailActive = Boolean( + freeWelcomeAutomation && + freeWelcomeEmail && + freeWelcomeEmail.get('lexical') && + freeWelcomeAutomation.get('status') === 'active' + ); } if (isFreeWelcomeEmailActive && isFreeSignup) { @@ -1468,9 +1477,18 @@ module.exports = class MemberRepository { const source = this._resolveContextSource(context); const shouldSendPaidWelcomeEmail = WELCOME_EMAIL_SOURCES.includes(source); let isPaidWelcomeEmailActive = false; - if (shouldSendPaidWelcomeEmail && this._AutomatedEmail) { - const paidWelcomeEmail = await this._AutomatedEmail.findOne({slug: MEMBER_WELCOME_EMAIL_SLUGS.paid}, options); - isPaidWelcomeEmailActive = paidWelcomeEmail && paidWelcomeEmail.get('lexical') && paidWelcomeEmail.get('status') === 'active'; + if (shouldSendPaidWelcomeEmail && this._WelcomeEmailAutomation) { + const paidWelcomeAutomation = await this._WelcomeEmailAutomation.findOne( + {slug: MEMBER_WELCOME_EMAIL_SLUGS.paid}, + {...options, withRelated: ['welcomeEmailAutomatedEmail']} + ); + const paidWelcomeEmail = paidWelcomeAutomation?.related('welcomeEmailAutomatedEmail'); + isPaidWelcomeEmailActive = Boolean( + paidWelcomeAutomation && + paidWelcomeEmail && + paidWelcomeEmail.get('lexical') && + paidWelcomeAutomation.get('status') === 'active' + ); } // Send paid welcome email if: // 1. The paid welcome email is active diff --git a/ghost/core/core/server/services/outbox/handlers/member-created.js b/ghost/core/core/server/services/outbox/handlers/member-created.js index 1746ead96bd..ad80129d746 100644 --- a/ghost/core/core/server/services/outbox/handlers/member-created.js +++ b/ghost/core/core/server/services/outbox/handlers/member-created.js @@ -1,7 +1,7 @@ const {OUTBOX_LOG_KEY} = require('../jobs/lib/constants'); const memberWelcomeEmailService = require('../../member-welcome-emails/service'); const logging = require('@tryghost/logging'); -const {AutomatedEmail, AutomatedEmailRecipient} = require('../../../models'); +const {WelcomeEmailAutomation, AutomatedEmailRecipient} = require('../../../models'); const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../../member-welcome-emails/constants'); const LOG_KEY = `${OUTBOX_LOG_KEY}[MEMBER-WELCOME-EMAIL]`; @@ -21,8 +21,8 @@ async function handle({payload}) { return; } - const automatedEmail = await AutomatedEmail.findOne({slug}); - if (!automatedEmail) { + const automation = await WelcomeEmailAutomation.findOne({slug}, {withRelated: ['welcomeEmailAutomatedEmail']}); + if (!automation) { logging.warn({ system: { event: 'outbox.member_created.no_automated_email', @@ -32,9 +32,33 @@ async function handle({payload}) { return; } + // NOTE(NY-1190): This naively assumes each drip sequence will have + // just one email. When we change that assumption, this line will need + // to change to something like: + // + // ``` + // SELECT * FROM welcome_email_automated_emails + // WHERE welcome_email_automation_id IS ? + // AND id NOT IN ( + // SELECT next_id FROM welcome_email_automated_emails + // WHERE next_id IS NOT NULL + // AND welcome_email_automation_id IS ? + // ); + // ``` + const email = automation.related('welcomeEmailAutomatedEmail'); + if (!email || !email.id) { + logging.warn({ + system: { + event: 'outbox.member_created.no_automated_email', + slug + } + }, `${LOG_KEY} No automated email content found for slug: ${slug}`); + return; + } + await AutomatedEmailRecipient.add({ member_id: payload.memberId, - automated_email_id: automatedEmail.id, + automated_email_id: email.id, member_uuid: payload.uuid, member_email: payload.email, member_name: payload.name diff --git a/ghost/core/test/e2e-api/admin/automated-emails.test.js b/ghost/core/test/e2e-api/admin/automated-emails.test.js index 05cbec410db..a8b0fa4ef9f 100644 --- a/ghost/core/test/e2e-api/admin/automated-emails.test.js +++ b/ghost/core/test/e2e-api/admin/automated-emails.test.js @@ -39,7 +39,8 @@ describe('Automated Emails API', function () { beforeEach(async function () { await dbUtils.truncate('brute'); - await dbUtils.truncate('automated_emails'); + await dbUtils.truncate('welcome_email_automated_emails'); + await dbUtils.truncate('welcome_email_automations'); }); describe('Browse', function () { @@ -202,10 +203,10 @@ describe('Automated Emails API', function () { sinon.assert.calledWithMatch(infoStub, { system: { event: 'welcome_email.enabled', - automated_email_id: automatedEmail.id, + automation_id: automatedEmail.id, slug: 'member-welcome-email-free' } - }, 'Welcome email enabled'); + }, 'Welcome email automation enabled'); }); it('Does not log when a welcome email is created as inactive', async function () { @@ -368,10 +369,10 @@ describe('Automated Emails API', function () { sinon.assert.calledWithMatch(infoStub, { system: { event: 'welcome_email.enabled', - automated_email_id: automatedEmail.id, + automation_id: automatedEmail.id, slug: 'member-welcome-email-free' } - }, 'Welcome email enabled'); + }, 'Welcome email automation enabled'); }); it('Logs when a welcome email is disabled', async function () { @@ -388,10 +389,10 @@ describe('Automated Emails API', function () { sinon.assert.calledWithMatch(infoStub, { system: { event: 'welcome_email.disabled', - automated_email_id: automatedEmail.id, + automation_id: automatedEmail.id, slug: 'member-welcome-email-free' } - }, 'Welcome email disabled'); + }, 'Welcome email automation disabled'); }); it('Does not log when status does not change', async function () { diff --git a/ghost/core/test/integration/jobs/process-outbox.test.js b/ghost/core/test/integration/jobs/process-outbox.test.js index fb01070ead7..65b44e496bf 100644 --- a/ghost/core/test/integration/jobs/process-outbox.test.js +++ b/ghost/core/test/integration/jobs/process-outbox.test.js @@ -28,7 +28,7 @@ describe('Process Outbox Job', function () { afterEach(async function () { sinon.restore(); await db.knex('outbox').del(); - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); try { await jobService.removeJob(JOB_NAME); } catch (err) { @@ -64,14 +64,21 @@ describe('Process Outbox Job', function () { } }); - await db.knex('automated_emails').insert({ - id: ObjectId().toHexString(), - email_design_setting_id: defaultEmailDesignSettingId, + const automationId = ObjectId().toHexString(); + await db.knex('welcome_email_automations').insert({ + id: automationId, status: 'active', name: 'Free Member Welcome Email', slug: MEMBER_WELCOME_EMAIL_SLUGS.free, + created_at: new Date() + }); + await db.knex('welcome_email_automated_emails').insert({ + id: ObjectId().toHexString(), + welcome_email_automation_id: automationId, + delay_days: 0, subject: 'Welcome to {site_title}', lexical, + email_design_setting_id: defaultEmailDesignSettingId, created_at: new Date() }); }); diff --git a/ghost/core/test/integration/services/member-welcome-emails.test.js b/ghost/core/test/integration/services/member-welcome-emails.test.js index 1e13f561794..462ea6b547d 100644 --- a/ghost/core/test/integration/services/member-welcome-emails.test.js +++ b/ghost/core/test/integration/services/member-welcome-emails.test.js @@ -66,25 +66,39 @@ describe('Member Welcome Emails Integration', function () { } }); - await db.knex('automated_emails').insert({ - id: ObjectId().toHexString(), - email_design_setting_id: defaultEmailDesignSettingId, + const freeAutomationId = ObjectId().toHexString(); + await db.knex('welcome_email_automations').insert({ + id: freeAutomationId, status: 'active', name: 'Free Member Welcome Email', slug: MEMBER_WELCOME_EMAIL_SLUGS.free, + created_at: new Date() + }); + await db.knex('welcome_email_automated_emails').insert({ + id: ObjectId().toHexString(), + welcome_email_automation_id: freeAutomationId, + delay_days: 0, subject: 'Welcome to {site_title}', lexical, + email_design_setting_id: defaultEmailDesignSettingId, created_at: new Date() }); - await db.knex('automated_emails').insert({ - id: ObjectId().toHexString(), - email_design_setting_id: defaultEmailDesignSettingId, + const paidAutomationId = ObjectId().toHexString(); + await db.knex('welcome_email_automations').insert({ + id: paidAutomationId, status: 'active', name: 'Paid Member Welcome Email', slug: MEMBER_WELCOME_EMAIL_SLUGS.paid, + created_at: new Date() + }); + await db.knex('welcome_email_automated_emails').insert({ + id: ObjectId().toHexString(), + welcome_email_automation_id: paidAutomationId, + delay_days: 0, subject: 'Welcome paid member to {site_title}', lexical, + email_design_setting_id: defaultEmailDesignSettingId, created_at: new Date() }); }); @@ -105,8 +119,8 @@ describe('Member Welcome Emails Integration', function () { await db.knex('automated_email_recipients').del(); await db.knex('outbox').del(); await db.knex('members').del(); - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid).del(); }); describe('Member creation with welcome emails', function () { @@ -215,8 +229,15 @@ describe('Member Welcome Emails Integration', function () { await jobService.awaitCompletion(JOB_NAME); } + async function getAutomatedEmailBySlug(slug) { + return db.knex('welcome_email_automated_emails') + .join('welcome_email_automations', 'welcome_email_automated_emails.welcome_email_automation_id', 'welcome_email_automations.id') + .where('welcome_email_automations.slug', slug) + .first('welcome_email_automated_emails.*'); + } + it('does not send email when template is inactive', async function () { - await db.knex('automated_emails') + await db.knex('welcome_email_automations') .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) .update({status: 'inactive'}); @@ -242,7 +263,7 @@ describe('Member Welcome Emails Integration', function () { }); it('does not send email when no template exists', async function () { - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); await models.Outbox.add({ event_type: 'MemberCreatedEvent', @@ -266,7 +287,7 @@ describe('Member Welcome Emails Integration', function () { }); it('does not send email when paid template is inactive but entry has status paid', async function () { - await db.knex('automated_emails') + await db.knex('welcome_email_automations') .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid) .update({status: 'inactive'}); @@ -292,7 +313,7 @@ describe('Member Welcome Emails Integration', function () { }); it('does not send email when no paid template exists but entry has status paid', async function () { - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid).del(); await models.Outbox.add({ event_type: 'MemberCreatedEvent', @@ -348,9 +369,10 @@ describe('Member Welcome Emails Integration', function () { assert.equal(record.member_email, memberEmail); assert.equal(record.member_name, memberName); - const automatedEmail = await db.knex('automated_emails') - .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) - .first(); + const automatedEmail = await db.knex('welcome_email_automated_emails') + .join('welcome_email_automations', 'welcome_email_automated_emails.welcome_email_automation_id', 'welcome_email_automations.id') + .where('welcome_email_automations.slug', MEMBER_WELCOME_EMAIL_SLUGS.free) + .first('welcome_email_automated_emails.id'); assert.equal(record.automated_email_id, automatedEmail.id); }); @@ -421,8 +443,10 @@ describe('Member Welcome Emails Integration', function () { sender_reply_to: 'newsletter-reply@example.com' }); - await db.knex('automated_emails') - .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) + const automatedEmail = await getAutomatedEmailBySlug(MEMBER_WELCOME_EMAIL_SLUGS.free); + + await db.knex('welcome_email_automated_emails') + .where('id', automatedEmail.id) .update({ sender_name: 'Automation Sender', sender_email: 'automation@example.com', @@ -450,7 +474,7 @@ describe('Member Welcome Emails Integration', function () { }); it('uses mock member UUID when sending test welcome emails', async function () { - const automatedEmail = await db.knex('automated_emails') + const automation = await db.knex('welcome_email_automations') .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) .first(); @@ -475,7 +499,7 @@ describe('Member Welcome Emails Integration', function () { email: 'test-member@example.com', subject: 'Welcome test', lexical, - automatedEmailId: automatedEmail.id + automatedEmailId: automation.id }); sinon.assert.calledOnce(mailService.GhostMailer.prototype.send); @@ -488,11 +512,12 @@ describe('Member Welcome Emails Integration', function () { it('uses automated sender overrides for test welcome emails', async function () { memberWelcomeEmailService.init(); - const automatedEmail = await db.knex('automated_emails') + const automation = await db.knex('welcome_email_automations') .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) .first(); + const automatedEmail = await getAutomatedEmailBySlug(MEMBER_WELCOME_EMAIL_SLUGS.free); - await db.knex('automated_emails') + await db.knex('welcome_email_automated_emails') .where('id', automatedEmail.id) .update({ sender_name: 'Automation Sender', @@ -516,7 +541,7 @@ describe('Member Welcome Emails Integration', function () { version: 1 } }), - automatedEmailId: automatedEmail.id + automatedEmailId: automation.id }); sinon.assert.calledOnce(mailService.GhostMailer.prototype.send); diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index ee9585f80a4..506e2cf00e3 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '5af830f163cc019b1d29188c68c96990'; + const currentSchemaHash = 'f57e57fd042ecee9dd93410ae87c0454'; const currentFixturesHash = '2f86ab1e3820e86465f9ad738dd0ee93'; const currentSettingsHash = 'a102b80d2ab0cd92325ed007c94d7da6'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; diff --git a/ghost/core/test/unit/server/models/automated-email.test.js b/ghost/core/test/unit/server/models/automated-email.test.js deleted file mode 100644 index d3eff8c474d..00000000000 --- a/ghost/core/test/unit/server/models/automated-email.test.js +++ /dev/null @@ -1,185 +0,0 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const models = require('../../../../core/server/models'); -const config = require('../../../../core/shared/config'); - -describe('Unit: models/automated-email', function () { - before(function () { - models.init(); - }); - - afterEach(function () { - sinon.restore(); - }); - - describe('defaults', function () { - it('sets default status to inactive', function () { - const model = new models.AutomatedEmail(); - const defaults = model.defaults(); - - assert.equal(defaults.status, 'inactive'); - }); - - it('returns expected default values', function () { - const model = new models.AutomatedEmail(); - const defaults = model.defaults(); - - assert.ok(defaults); - assert.equal(Object.keys(defaults).length, 1); - assert.equal(defaults.status, 'inactive'); - }); - }); - - describe('parse', function () { - it('transforms __GHOST_URL__ to absolute URL in lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.parse({ - id: '123', - lexical: '{"root":{"children":[{"type":"paragraph","children":[{"type":"link","url":"__GHOST_URL__/test"}]}]}}' - }); - - assert.ok(result.lexical.includes(`${config.get('url')}/test`)); - assert.ok(!result.lexical.includes('__GHOST_URL__')); - }); - - it('handles null lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.parse({ - id: '123', - lexical: null - }); - - assert.equal(result.lexical, null); - }); - - it('handles undefined lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.parse({ - id: '123' - }); - - assert.equal(result.lexical, undefined); - }); - - it('preserves other fields', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.parse({ - id: '123', - name: 'welcome_email', - subject: 'Welcome!', - status: 'active', - lexical: '{"root":{"children":[]}}' - }); - - assert.equal(result.id, '123'); - assert.equal(result.name, 'welcome_email'); - assert.equal(result.subject, 'Welcome!'); - assert.equal(result.status, 'active'); - }); - }); - - describe('formatOnWrite', function () { - it('transforms absolute URLs to __GHOST_URL__ in lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const siteUrl = config.get('url'); - const result = model.formatOnWrite({ - lexical: `{"root":{"children":[{"type":"paragraph","children":[{"type":"link","url":"${siteUrl}/test"}]}]}}` - }); - - assert.ok(result.lexical.includes('__GHOST_URL__/test')); - assert.ok(!result.lexical.includes(siteUrl)); - }); - - it('handles null lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.formatOnWrite({ - lexical: null - }); - - assert.equal(result.lexical, null); - }); - - it('handles undefined lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.formatOnWrite({ - name: 'welcome_email' - }); - - assert.equal(result.lexical, undefined); - assert.equal(result.name, 'welcome_email'); - }); - - it('preserves other fields', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.formatOnWrite({ - id: '123', - name: 'welcome_email', - subject: 'Welcome!', - status: 'active', - lexical: '{"root":{"children":[]}}' - }); - - assert.equal(result.id, '123'); - assert.equal(result.name, 'welcome_email'); - assert.equal(result.subject, 'Welcome!'); - assert.equal(result.status, 'active'); - }); - }); - - describe('onCreating', function () { - it('assigns the default email design setting when not provided', async function () { - const model = models.AutomatedEmail.forge(); - const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.AutomatedEmail.prototype), 'onCreating').resolves(); - const findOne = sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); - - await model.onCreating(model, {}, {}); - - sinon.assert.calledOnceWithExactly(findOne, {slug: 'default-automated-email'}, {}); - assert.equal(model.get('email_design_setting_id'), 'default-setting-id'); - sinon.assert.calledOnceWithExactly(baseOnCreating, model, {}, {}); - }); - - it('assigns the default email design setting when null is provided', async function () { - const model = models.AutomatedEmail.forge({email_design_setting_id: null}); - sinon.stub(Object.getPrototypeOf(models.AutomatedEmail.prototype), 'onCreating').resolves(); - sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); - - await model.onCreating(model, {}, {}); - - assert.equal(model.get('email_design_setting_id'), 'default-setting-id'); - }); - - it('keeps the provided email design setting id', async function () { - const model = models.AutomatedEmail.forge({email_design_setting_id: 'custom-setting-id'}); - const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.AutomatedEmail.prototype), 'onCreating').resolves(); - const findOne = sinon.stub(models.EmailDesignSetting, 'findOne'); - - await model.onCreating(model, {}, {}); - - sinon.assert.notCalled(findOne); - assert.equal(model.get('email_design_setting_id'), 'custom-setting-id'); - sinon.assert.calledOnceWithExactly(baseOnCreating, model, {}, {}); - }); - - it('throws when the default email design setting is missing', async function () { - const model = models.AutomatedEmail.forge(); - sinon.stub(Object.getPrototypeOf(models.AutomatedEmail.prototype), 'onCreating').resolves(); - sinon.stub(models.EmailDesignSetting, 'findOne').resolves(null); - - await assert.rejects( - model.onCreating(model, {}, {}), - { - errorType: 'InternalServerError' - } - ); - }); - }); -}); diff --git a/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js b/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js index 2c861a66046..2658ee1cffe 100644 --- a/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js +++ b/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js @@ -107,4 +107,51 @@ describe('Unit: models/welcome-email-automated-email', function () { assert.equal(result.subject, 'Welcome!'); }); }); + + describe('onCreating', function () { + it('assigns the default email design setting when not provided', async function () { + const model = models.WelcomeEmailAutomatedEmail.forge(); + const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + const findOne = sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); + + await model.onCreating(model, {}, {}); + + assert.equal(model.get('email_design_setting_id'), 'default-setting-id'); + sinon.assert.calledOnce(findOne); + sinon.assert.calledOnce(baseOnCreating); + }); + + it('assigns the default email design setting when null is provided', async function () { + const model = models.WelcomeEmailAutomatedEmail.forge({email_design_setting_id: null}); + sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); + + await model.onCreating(model, {}, {}); + + assert.equal(model.get('email_design_setting_id'), 'default-setting-id'); + }); + + it('keeps the provided email design setting id', async function () { + const model = models.WelcomeEmailAutomatedEmail.forge({email_design_setting_id: 'custom-setting-id'}); + const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + const findOne = sinon.stub(models.EmailDesignSetting, 'findOne'); + + await model.onCreating(model, {}, {}); + + assert.equal(model.get('email_design_setting_id'), 'custom-setting-id'); + sinon.assert.notCalled(findOne); + sinon.assert.calledOnce(baseOnCreating); + }); + + it('throws when the default email design setting is missing', async function () { + const model = models.WelcomeEmailAutomatedEmail.forge(); + sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + sinon.stub(models.EmailDesignSetting, 'findOne').resolves(null); + + await assert.rejects( + () => model.onCreating(model, {}, {}), + {message: 'Missing default email design setting for automated emails'} + ); + }); + }); }); diff --git a/ghost/core/test/unit/server/models/welcome-email-automation.test.js b/ghost/core/test/unit/server/models/welcome-email-automation.test.js index 8e5d560bc9d..f1d7bf90b31 100644 --- a/ghost/core/test/unit/server/models/welcome-email-automation.test.js +++ b/ghost/core/test/unit/server/models/welcome-email-automation.test.js @@ -1,6 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); +const logging = require('@tryghost/logging'); describe('Unit: models/welcome-email-automation', function () { before(function () { @@ -28,4 +29,68 @@ describe('Unit: models/welcome-email-automation', function () { assert.equal(defaults.status, 'inactive'); }); }); + + describe('onSaved', function () { + it('logs when a welcome email is enabled', function () { + const infoStub = sinon.stub(logging, 'info'); + const model = models.WelcomeEmailAutomation.forge({ + id: 'test-id', + slug: 'member-welcome-email-free', + status: 'active' + }); + sinon.stub(model, 'previous').withArgs('status').returns('inactive'); + + model.onSaved(model); + + sinon.assert.calledOnce(infoStub); + const logArg = infoStub.firstCall.args[0]; + assert.equal(logArg.system.event, 'welcome_email.enabled'); + assert.equal(logArg.system.slug, 'member-welcome-email-free'); + }); + + it('logs when a welcome email is disabled', function () { + const infoStub = sinon.stub(logging, 'info'); + const model = models.WelcomeEmailAutomation.forge({ + id: 'test-id', + slug: 'member-welcome-email-paid', + status: 'inactive' + }); + sinon.stub(model, 'previous').withArgs('status').returns('active'); + + model.onSaved(model); + + sinon.assert.calledOnce(infoStub); + const logArg = infoStub.firstCall.args[0]; + assert.equal(logArg.system.event, 'welcome_email.disabled'); + assert.equal(logArg.system.slug, 'member-welcome-email-paid'); + }); + + it('does not log for non-welcome-email slugs', function () { + const infoStub = sinon.stub(logging, 'info'); + const model = models.WelcomeEmailAutomation.forge({ + id: 'test-id', + slug: 'some-other-slug', + status: 'active' + }); + sinon.stub(model, 'previous').withArgs('status').returns('inactive'); + + model.onSaved(model); + + sinon.assert.notCalled(infoStub); + }); + + it('does not log when status has not changed', function () { + const infoStub = sinon.stub(logging, 'info'); + const model = models.WelcomeEmailAutomation.forge({ + id: 'test-id', + slug: 'member-welcome-email-free', + status: 'active' + }); + sinon.stub(model, 'previous').withArgs('status').returns('active'); + + model.onSaved(model); + + sinon.assert.notCalled(infoStub); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js index 3835c942734..5ab19764d30 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js @@ -315,10 +315,15 @@ describe('EventRepository', function () { } if (relation === 'automatedEmail') { return { - toJSON: () => ({ - id: 'ae123', - slug: 'member-welcome-email-free' - }) + id: 'ae123', + related: (rel) => { + if (rel === 'welcomeEmailAutomation') { + return { + id: 'auto123', + get: key => (key === 'slug' ? 'member-welcome-email-free' : undefined) + }; + } + } }; } }, @@ -351,7 +356,7 @@ describe('EventRepository', function () { }); sinon.assert.calledOnceWithMatch(fake, { - withRelated: ['member', 'automatedEmail'], + withRelated: ['member', 'automatedEmail.welcomeEmailAutomation'], filter: 'custom:true', order: 'created_at desc, id desc' }); @@ -365,7 +370,7 @@ describe('EventRepository', function () { }); sinon.assert.calledOnceWithMatch(fake, { - withRelated: ['member', 'automatedEmail'], + withRelated: ['member', 'automatedEmail.welcomeEmailAutomation'], filter: 'custom:true', order: 'created_at desc, id desc' }); @@ -380,7 +385,7 @@ describe('EventRepository', function () { }); sinon.assert.calledOnceWithMatch(fake, { - withRelated: ['member', 'automatedEmail'], + withRelated: ['member', 'automatedEmail.welcomeEmailAutomation'], filter: 'custom:true', order: 'created_at desc, id desc' }); diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js index 291c9fbe912..133b2c74389 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js @@ -1454,7 +1454,7 @@ describe('MemberRepository', function () { let MemberStatusEvent; let MemberSubscribeEvent; let newslettersService; - let AutomatedEmail; + let WelcomeEmailAutomation; const oldNodeEnv = process.env.NODE_ENV; beforeEach(function () { @@ -1508,11 +1508,20 @@ describe('MemberRepository', function () { getAll: sinon.stub().resolves([]) }; - AutomatedEmail = { + WelcomeEmailAutomation = { findOne: sinon.stub().resolves({ get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}', status: 'active'}; + const data = {status: 'active'}; return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; }) }) }; @@ -1529,7 +1538,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1553,7 +1562,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1577,7 +1586,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1595,7 +1604,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1606,10 +1615,19 @@ describe('MemberRepository', function () { }); it('does NOT create outbox entry when welcome email is inactive', async function () { - AutomatedEmail.findOne.resolves({ + WelcomeEmailAutomation.findOne.resolves({ get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}', status: 'inactive'}; + const data = {status: 'inactive'}; return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; }) }); @@ -1619,7 +1637,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1638,7 +1656,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, StripeCustomer, OfferRedemption: mockOfferRedemption }); @@ -1667,7 +1685,7 @@ describe('MemberRepository', function () { // The free welcome email should NOT be sent when stripeCustomer is present sinon.assert.notCalled(Outbox.add); - sinon.assert.notCalled(AutomatedEmail.findOne); + sinon.assert.notCalled(WelcomeEmailAutomation.findOne); sinon.assert.notCalled(Member.transaction); }); }); @@ -1681,7 +1699,7 @@ describe('MemberRepository', function () { let MemberStatusEvent; let stripeAPIService; let productRepository; - let AutomatedEmail; + let WelcomeEmailAutomation; let subscriptionData; beforeEach(function () { @@ -1803,11 +1821,20 @@ describe('MemberRepository', function () { update: sinon.stub().resolves({}) }; - AutomatedEmail = { + WelcomeEmailAutomation = { findOne: sinon.stub().resolves({ get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}', status: 'active'}; + const data = {status: 'active'}; return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; }) }) }; @@ -1836,7 +1863,7 @@ describe('MemberRepository', function () { MemberStatusEvent, stripeAPIService, productRepository, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1881,7 +1908,7 @@ describe('MemberRepository', function () { MemberStatusEvent, stripeAPIService, productRepository, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1918,10 +1945,19 @@ describe('MemberRepository', function () { }) }); - AutomatedEmail.findOne.resolves({ + WelcomeEmailAutomation.findOne.resolves({ get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}', status: 'inactive'}; + const data = {status: 'inactive'}; return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; }) }); @@ -1934,7 +1970,7 @@ describe('MemberRepository', function () { MemberStatusEvent, stripeAPIService, productRepository, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); diff --git a/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js b/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js index 8fa021eb0fe..96425b50bd5 100644 --- a/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js +++ b/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js @@ -6,7 +6,7 @@ const {captureLoggerOutput, findByEvent} = require('../../../../../utils/logging describe('member-created handler', function () { let handler; let memberWelcomeEmailServiceStub; - let AutomatedEmailStub; + let WelcomeEmailAutomationStub; let AutomatedEmailRecipientStub; let logCapture; @@ -19,8 +19,15 @@ describe('member-created handler', function () { } }; - AutomatedEmailStub = { - findOne: sinon.stub().resolves({id: 'ae123'}) + WelcomeEmailAutomationStub = { + findOne: sinon.stub().resolves({ + id: 'automation123', + related: sinon.stub().callsFake((relation) => { + if (relation === 'welcomeEmailAutomatedEmail') { + return {id: 'ae123'}; + } + }) + }) }; AutomatedEmailRecipientStub = { @@ -30,7 +37,7 @@ describe('member-created handler', function () { logCapture = captureLoggerOutput(); handler.__set__('memberWelcomeEmailService', memberWelcomeEmailServiceStub); - handler.__set__('AutomatedEmail', AutomatedEmailStub); + handler.__set__('WelcomeEmailAutomation', WelcomeEmailAutomationStub); handler.__set__('AutomatedEmailRecipient', AutomatedEmailRecipientStub); }); @@ -95,7 +102,7 @@ describe('member-created handler', function () { }); it('logs warning when no automated email found for slug', async function () { - AutomatedEmailStub.findOne.resolves(null); + WelcomeEmailAutomationStub.findOne.resolves(null); await handler.handle({ payload: {