From bbc4a951458fa3b7f2fedb80463a2fa2ec105a63 Mon Sep 17 00:00:00 2001 From: Sodbileg Gansukh Date: Thu, 23 Apr 2026 18:15:59 +0800 Subject: [PATCH 01/11] Updated gift subscription reminder email copy and layout (#27509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no issues Follow-up to #27436 with a few design tweaks to the new gift reminder email. Also aligns the CTA with the in-progress "Continue subscription" button on the Portal account page. - Rewrote the body copy and renamed the CTA *"Manage subscription"* → *"Continue subscription"* to match the in-progress Portal account button - Removed the conditional *"Hi {name},"* greeting so the h1 leads directly - Replaced the cadence row with a *"Price after gift ends"* row, and wired tier currency + monthly/yearly price through the reminder flow --- .../gifts/email-templates/gift-reminder.hbs | 10 +++--- .../gifts/email-templates/gift-reminder.ts | 15 ++++----- .../services/gifts/gift-email-service.ts | 12 +++---- .../server/services/gifts/gift-service.ts | 25 +++++++++----- .../services/gifts/gift-email-service.test.js | 30 ++++++++--------- .../services/gifts/gift-service.test.ts | 33 +++++++++++++++++-- 6 files changed, 80 insertions(+), 45 deletions(-) diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs index 2f8b842e397..dc796f23c74 100644 --- a/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs +++ b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.hbs @@ -40,7 +40,7 @@

Your gift subscription is ending soon

-

{{#if memberName}}Hi {{memberName}}, your{{else}}Your{{/if}} gift subscription to {{siteTitle}} ends on {{gift.consumesAt}}. Continue with a paid subscription to keep your access.

+

Your gift subscription to {{siteTitle}} ends on {{gift.consumesAt}}. Continue with a paid subscription to keep reading.

@@ -49,9 +49,11 @@

Gift subscription

-

{{gift.tierName}} • {{gift.cadenceLabel}}

+

{{gift.tierName}}

Ends on

-

{{gift.consumesAt}}

+

{{gift.consumesAt}}

+

Price after gift ends

+

{{gift.priceAfter}}

@@ -63,7 +65,7 @@ - Manage subscription + Continue subscription diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts index d0c990e3f47..d8f858db160 100644 --- a/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts +++ b/ghost/core/core/server/services/gifts/email-templates/gift-reminder.ts @@ -5,25 +5,24 @@ export interface GiftReminderData { siteDomain: string; accentColor: string | undefined; memberEmail: string; - memberName: string | null; gift: { tierName: string; - cadenceLabel: string; consumesAt: string; + priceAfter: string; manageSubscriptionUrl: string; }; } export function renderText(data: GiftReminderData): string { - const greeting = data.memberName ? `Hi ${data.memberName},` : 'Hi,'; + return `Your gift subscription is ending soon - return `${greeting} +Your gift subscription to ${data.siteTitle} ends on ${data.gift.consumesAt}. Continue with a paid subscription to keep reading. -Your gift subscription to ${data.siteTitle} ends on ${data.gift.consumesAt}. +Gift subscription: ${data.gift.tierName} +Ends on: ${data.gift.consumesAt} +Price after gift ends: ${data.gift.priceAfter} -Gift subscription: ${data.gift.tierName} • ${data.gift.cadenceLabel} - -To keep your access, continue with a paid subscription before your gift ends: +Continue subscription: ${data.gift.manageSubscriptionUrl} --- diff --git a/ghost/core/core/server/services/gifts/gift-email-service.ts b/ghost/core/core/server/services/gifts/gift-email-service.ts index d102762931a..823dd6e1b6e 100644 --- a/ghost/core/core/server/services/gifts/gift-email-service.ts +++ b/ghost/core/core/server/services/gifts/gift-email-service.ts @@ -37,10 +37,10 @@ interface PurchaseConfirmationData { interface ReminderData { memberEmail: string; - memberName: string | null; tierName: string; + tierPrice: number; + tierCurrency: string; cadence: 'month' | 'year'; - duration: number; consumesAt: Date; } @@ -116,12 +116,13 @@ export class GiftEmailService { }); } - async sendReminder({memberEmail, memberName, tierName, cadence, duration, consumesAt}: ReminderData): Promise { + async sendReminder({memberEmail, tierName, tierPrice, tierCurrency, cadence, consumesAt}: ReminderData): Promise { const siteDomain = this.siteDomain; const siteUrl = this.urlUtils.getSiteUrl(); const siteTitle = this.settingsCache.get('title') ?? siteDomain; - const cadenceLabel = duration === 1 ? `1 ${cadence}` : `${duration} ${cadence}s`; + const formattedPrice = this.formatAmount({currency: tierCurrency, amount: tierPrice / 100}); + const priceAfter = `${formattedPrice}/${cadence}`; const manageSubscriptionUrl = new URL('#/portal/account', siteUrl).href; @@ -132,11 +133,10 @@ export class GiftEmailService { siteDomain, accentColor: this.settingsCache.get('accent_color'), memberEmail, - memberName, gift: { tierName, - cadenceLabel, consumesAt: moment(consumesAt).format('D MMM YYYY'), + priceAfter, manageSubscriptionUrl } }; diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 973c44e9beb..3583ddcd92b 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -34,6 +34,9 @@ interface MemberRepository { type Tier = { name: string; + currency: string | null; + monthlyPrice: number | null; + yearlyPrice: number | null; toJSON?: () => { id: string; name: string; @@ -61,10 +64,10 @@ interface GiftEmailService { }): Promise; sendReminder(data: { memberEmail: string; - memberName: string | null; tierName: string; + tierPrice: number; + tierCurrency: string; cadence: 'month' | 'year'; - duration: number; consumesAt: Date; }): Promise; } @@ -114,9 +117,7 @@ interface GiftServiceDeps { interface ReminderSend { memberEmail: string; - memberName: string | null; cadence: 'month' | 'year'; - duration: number; consumesAt: Date; } @@ -491,6 +492,16 @@ export class GiftService { throw new errors.NotFoundError({message: `Tier not found for gift: ${gift.tierId}`}); } + // Throw before the transaction so the gift isn't marked as reminded; + // the next run recovers after an admin restores pricing. + const tierPrice = gift.cadence === 'month' ? tier.monthlyPrice : tier.yearlyPrice; + + if (tierPrice === null || tier.currency === null) { + throw new errors.NotFoundError({ + message: `Tier missing ${gift.cadence}ly pricing for gift: ${gift.tierId}` + }); + } + const result = await this.deps.giftRepository.transaction(async (transacting): Promise => { const locked = await this.deps.giftRepository.getByToken(token, {transacting, forUpdate: true}); @@ -537,9 +548,7 @@ export class GiftService { return { memberEmail: member.get('email'), - memberName: member.get('name'), cadence: locked.cadence, - duration: locked.duration, consumesAt: locked.consumesAt }; }); @@ -550,10 +559,10 @@ export class GiftService { await this.deps.giftEmailService.sendReminder({ memberEmail: result.memberEmail, - memberName: result.memberName, tierName: tier.name, + tierPrice, + tierCurrency: tier.currency, cadence: result.cadence, - duration: result.duration, consumesAt: result.consumesAt }); diff --git a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js index c16e57e40ed..2adf86e3d0e 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js +++ b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js @@ -134,10 +134,10 @@ describe('GiftEmailService', function () { describe('sendReminder', function () { const reminderData = { memberEmail: 'member@example.com', - memberName: 'Member Name', tierName: 'Gold', + tierPrice: 10000, + tierCurrency: 'usd', cadence: 'year', - duration: 1, consumesAt: new Date('2026-04-23T00:00:00.000Z') }; @@ -152,40 +152,38 @@ describe('GiftEmailService', function () { })); }); - it('includes tier name, cadence, consumesAt, and manage subscription url in both HTML and text', async function () { + it('includes tier name, consumesAt, post-gift price and manage subscription url in both HTML and text', async function () { await service.sendReminder(reminderData); const msg = mailer.send.getCall(0).args[0]; for (const field of ['html', 'text']) { sinon.assert.match(msg[field], sinon.match('Gold')); - sinon.assert.match(msg[field], sinon.match('1 year')); sinon.assert.match(msg[field], sinon.match('23 Apr 2026')); + sinon.assert.match(msg[field], sinon.match('$100.00/year')); sinon.assert.match(msg[field], sinon.match('https://example.com/#/portal/account')); } }); - it('uses a generic greeting when the member has no name', async function () { - await service.sendReminder({...reminderData, memberName: null}); + it('renders a "Continue subscription" CTA', async function () { + await service.sendReminder(reminderData); const msg = mailer.send.getCall(0).args[0]; - sinon.assert.match(msg.text, sinon.match(/^Hi,/)); + sinon.assert.match(msg.html, sinon.match('Continue subscription')); + sinon.assert.match(msg.text, sinon.match('Continue subscription')); }); - it('includes the member name when provided', async function () { - await service.sendReminder(reminderData); - - const msg = mailer.send.getCall(0).args[0]; + it('formats month cadence in the post-gift price', async function () { + await service.sendReminder({...reminderData, cadence: 'month', tierPrice: 1000}); - sinon.assert.match(msg.text, sinon.match('Hi Member Name,')); - sinon.assert.match(msg.html, sinon.match('Hi Member Name,')); + sinon.assert.calledWith(mailer.send, sinon.match.has('html', sinon.match('$10.00/month'))); }); - it('formats month cadence correctly', async function () { - await service.sendReminder({...reminderData, cadence: 'month', duration: 3}); + it('formats non-USD currency correctly in the post-gift price', async function () { + await service.sendReminder({...reminderData, tierCurrency: 'eur', tierPrice: 1500}); - sinon.assert.calledWith(mailer.send, sinon.match.has('html', sinon.match('3 months'))); + sinon.assert.calledWith(mailer.send, sinon.match.has('html', sinon.match('€15.00/year'))); }); }); }); diff --git a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts index 4287b5ccbdb..b7ad56e10d3 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts @@ -103,7 +103,10 @@ describe('GiftService', function () { id: 'tier_1', name: 'Bronze', description: 'Tier description', - benefits: ['Benefit 1', 'Benefit 2'] + benefits: ['Benefit 1', 'Benefit 2'], + currency: 'usd', + monthlyPrice: 1000, + yearlyPrice: 10000 }) } }; @@ -765,10 +768,10 @@ describe('GiftService', function () { const emailArgs = giftEmailService.sendReminder.getCall(0).args[0]; assert.equal(emailArgs.memberEmail, 'member_1@example.com'); - assert.equal(emailArgs.memberName, 'Member Name'); assert.equal(emailArgs.tierName, 'Bronze'); + assert.equal(emailArgs.tierCurrency, 'usd'); + assert.equal(emailArgs.tierPrice, 10000); assert.equal(emailArgs.cadence, gift.cadence); - assert.equal(emailArgs.duration, gift.duration); assert.equal(emailArgs.consumesAt, gift.consumesAt); sinon.assert.calledOnce(giftRepository.update); @@ -908,6 +911,30 @@ describe('GiftService', function () { sinon.assert.notCalled(giftEmailService.sendReminder); }); + it('does not mark the gift as reminded when tier pricing is missing for the gift cadence', async function () { + const gift = buildRedeemedGift({cadence: 'year'}); + + giftRepository.findPendingReminder.resolves([gift]); + giftRepository.getByToken.resolves(gift); + tiersService.api.read.resolves({ + id: 'tier_1', + name: 'Bronze', + currency: 'usd', + monthlyPrice: 1000, + yearlyPrice: null + }); + + const service = createService(); + const result = await service.processReminders(); + + assert.equal(result.remindedCount, 0); + assert.equal(result.skippedCount, 0); + assert.equal(result.failedCount, 1); + + sinon.assert.notCalled(giftRepository.update); + sinon.assert.notCalled(giftEmailService.sendReminder); + }); + it('continues processing the batch when one gift fails', async function () { // Gift 1 will fail at the email stage; gift 2 should still be processed. const gift1 = buildRedeemedGift({token: 'gift-1', redeemerMemberId: 'member_1'}); From 14ac5f5eb0d18af6ed0e1e36e5b1ecb1697bc8b7 Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Thu, 23 Apr 2026 11:16:47 +0100 Subject: [PATCH 02/11] Add beehiiv to self-service migrations (#27515) ref https://linear.app/ghost/issue/MIG-1354/ - Adds a beehiiv button to the migration tools - Add beehiiv as a search term in Settings --- apps/admin-x-design-system/src/assets/icons/beehiiv.svg | 7 +++++++ .../src/components/settings/advanced/advanced-settings.tsx | 2 +- .../advanced/migration-tools/migration-tools-import.tsx | 7 +++++++ .../test/acceptance/advanced/migration-tools.test.ts | 1 + 4 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 apps/admin-x-design-system/src/assets/icons/beehiiv.svg diff --git a/apps/admin-x-design-system/src/assets/icons/beehiiv.svg b/apps/admin-x-design-system/src/assets/icons/beehiiv.svg new file mode 100644 index 00000000000..f3bbb1457a8 --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/beehiiv.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx b/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx index c32cb323c47..d29d0062a52 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx @@ -9,7 +9,7 @@ import SearchableSection from '../../searchable-section'; export const searchKeywords = { integrations: ['advanced', 'integrations', 'zapier', 'slack', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github', 'webhooks'], - migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium', 'wordpress', 'wp', 'squarespace'], + migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium', 'wordpress', 'wp', 'squarespace', 'beehiiv'], codeInjection: ['advanced', 'code injection', 'head', 'footer'], labs: ['advanced', 'labs', 'alpha', 'private', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'], history: ['advanced', 'history', 'log', 'events', 'user events', 'staff', 'audit', 'action'], diff --git a/apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-import.tsx b/apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-import.tsx index 66696dafa63..e477958d861 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-import.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-import.tsx @@ -45,6 +45,13 @@ const MigrationToolsImport: React.FC = () => { title='Substack' onClick={() => updateRoute({isExternal: true, route: '/migrate/substack'})} /> + + } + title='beehiiv' + onClick={() => updateRoute({isExternal: true, route: '/migrate/beehiiv'})} + /> diff --git a/apps/admin-x-settings/test/acceptance/advanced/migration-tools.test.ts b/apps/admin-x-settings/test/acceptance/advanced/migration-tools.test.ts index 45c8aa90326..8733faa43e5 100644 --- a/apps/admin-x-settings/test/acceptance/advanced/migration-tools.test.ts +++ b/apps/admin-x-settings/test/acceptance/advanced/migration-tools.test.ts @@ -14,6 +14,7 @@ test.describe('Migration tools', async () => { const migrators = [ {name: 'Substack', route: '/migrate/substack'}, + {name: 'beehiiv', route: '/migrate/beehiiv'}, {name: 'WordPress', route: '/migrate/wordpress'}, {name: 'Medium', route: '/migrate/medium'}, {name: 'Mailchimp', route: '/migrate/mailchimp'} From 034c6a5292923f6f0e5fe14dfb06b99c41da28a3 Mon Sep 17 00:00:00 2001 From: Sag Date: Thu, 23 Apr 2026 14:37:25 +0200 Subject: [PATCH 03/11] Added ability for gift members to continue as paid without losing remaining days (#27432) ref https://linear.app/ghost/issue/BER-3477 Allows gift members to continue as a paid subscriber without losing the remainder of their gift: any remaining gift days are converted to free trial days. As the trial mechanism only works if the member continues on the same tier (can't convert gift days on tier A to trial days on tier B), the UI for gift members now only shows an option to continue on the same tier. --- apps/portal/package.json | 2 +- apps/portal/src/actions.js | 15 ++ .../components/account-main.js | 2 + .../components/account-welcome.js | 8 +- .../continue-gift-subscription-banner.js | 44 +++++ .../components/paid-account-actions.js | 15 +- apps/portal/src/utils/api.js | 50 ++++++ apps/portal/src/utils/helpers.js | 4 + .../AccountHomePage/account-welcome.test.js | 34 ---- apps/portal/test/utils/helpers.test.js | 33 ++++ .../gifts/gift-bookshelf-repository.ts | 9 + .../server/services/gifts/gift-repository.ts | 1 + .../server/services/gifts/gift-service.ts | 17 ++ .../controllers/router-controller.js | 52 +++++- .../members/members-api/members-api.js | 3 +- .../members-api/services/payments-service.js | 11 +- .../members/gift-subscriptions.test.js | 163 ++++++++++++++++++ .../gifts/gift-bookshelf-repository.test.ts | 106 ++++++++++++ .../services/gifts/gift-service.test.ts | 136 +++++++++++++++ 19 files changed, 655 insertions(+), 50 deletions(-) create mode 100644 apps/portal/src/components/pages/AccountHomePage/components/continue-gift-subscription-banner.js diff --git a/apps/portal/package.json b/apps/portal/package.json index 55f15c926d5..561f558a171 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.68.14", + "version": "2.68.15", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/actions.js b/apps/portal/src/actions.js index 35063506f32..91077a89df2 100644 --- a/apps/portal/src/actions.js +++ b/apps/portal/src/actions.js @@ -316,6 +316,20 @@ async function checkoutPlan({data, state, api}) { } } +async function continueGiftSubscription({state, api}) { + try { + await api.member.continueGiftCheckout(); + } catch (e) { + return { + action: 'continueGiftSubscription:failed', + popupNotification: createPopupNotification({ + type: 'continueGiftSubscription:failed', autoHide: false, closeable: true, state, status: 'error', + message: t('Failed to process checkout, please try again') + }) + }; + } +} + async function checkoutGift({data, state, api}) { try { const {tierId, cadence} = data; @@ -790,6 +804,7 @@ const Actions = { editBilling, manageBilling, checkoutPlan, + continueGiftSubscription, checkoutGift, updateNewsletterPreference, showPopupNotification, diff --git a/apps/portal/src/components/pages/AccountHomePage/components/account-main.js b/apps/portal/src/components/pages/AccountHomePage/components/account-main.js index 99e6f7d9659..0eda2b628dd 100644 --- a/apps/portal/src/components/pages/AccountHomePage/components/account-main.js +++ b/apps/portal/src/components/pages/AccountHomePage/components/account-main.js @@ -3,6 +3,7 @@ import CloseButton from '../../../common/close-button'; import UserHeader from './user-header'; import AccountWelcome from './account-welcome'; import ContinueSubscriptionButton from './continue-subscription-button'; +import ContinueGiftSubscriptionBanner from './continue-gift-subscription-banner'; import AccountActions from './account-actions'; const AccountMain = () => { @@ -12,6 +13,7 @@ const AccountMain = () => {
+
diff --git a/apps/portal/src/components/pages/AccountHomePage/components/account-welcome.js b/apps/portal/src/components/pages/AccountHomePage/components/account-welcome.js index 6868fc273bd..5458dbc4f5e 100644 --- a/apps/portal/src/components/pages/AccountHomePage/components/account-welcome.js +++ b/apps/portal/src/components/pages/AccountHomePage/components/account-welcome.js @@ -1,5 +1,5 @@ import AppContext from '../../../../app-context'; -import {getSubscriptionExpiry, getMemberSubscription, hasOnlyFreePlan, isComplimentaryMember, subscriptionHasFreeTrial} from '../../../../utils/helpers'; +import {getSubscriptionExpiry, getMemberSubscription, hasOnlyFreePlan, isComplimentaryMember, isGiftMember, subscriptionHasFreeTrial} from '../../../../utils/helpers'; import {getDateString} from '../../../../utils/date-time'; import {useContext} from 'react'; @@ -15,14 +15,16 @@ const AccountWelcome = () => { } const subscription = getMemberSubscription({member}); const isComplimentary = isComplimentaryMember({member}); - const isGiftMember = member?.status === 'gift'; if (isComplimentary && !subscription) { return null; } if (subscription) { const currentPeriodEnd = subscription?.current_period_end; const subscriptionExpiry = getSubscriptionExpiry({member}); - if ((isComplimentary || isGiftMember) && subscriptionExpiry) { + if (isGiftMember({member})) { + return null; + } + if (isComplimentary && subscriptionExpiry) { return (

{t(`Your subscription will expire on {expiryDate}`, {expiryDate: subscriptionExpiry})}

diff --git a/apps/portal/src/components/pages/AccountHomePage/components/continue-gift-subscription-banner.js b/apps/portal/src/components/pages/AccountHomePage/components/continue-gift-subscription-banner.js new file mode 100644 index 00000000000..2a57838c0f4 --- /dev/null +++ b/apps/portal/src/components/pages/AccountHomePage/components/continue-gift-subscription-banner.js @@ -0,0 +1,44 @@ +import AppContext from '../../../../app-context'; +import ActionButton from '../../../common/action-button'; +import {getSubscriptionExpiry, isGiftMember} from '../../../../utils/helpers'; +import {useContext} from 'react'; + +const ContinueGiftSubscriptionBanner = () => { + const {member, doAction, action, brandColor} = useContext(AppContext); + + if (!isGiftMember({member})) { + return null; + } + + const expiryDate = getSubscriptionExpiry({member}); + if (!expiryDate) { + return null; + } + + const isRunning = action === 'continueGiftSubscription:running'; + + // TODO: Add translation strings once copy has been finalised + /* eslint-disable i18next/no-literal-string */ + return ( +
+
+

+ Your gift subscription ends on {expiryDate}. Continue with a paid subscription to keep reading. Any remaining days will be added as free trial time. +

+ doAction('continueGiftSubscription')} + isRunning={isRunning} + disabled={isRunning} + isPrimary={true} + brandColor={brandColor} + label='Continue subscription' + style={{ + width: '100%' + }} + /> +
+
+ ); +}; + +export default ContinueGiftSubscriptionBanner; diff --git a/apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js b/apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js index a6e594c58ef..9b7dc75e123 100644 --- a/apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js +++ b/apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js @@ -1,5 +1,5 @@ import AppContext from '../../../../app-context'; -import {getSubscriptionExpiry, getMemberSubscription, getMemberTierName, hasMultipleProductsFeature, hasOnlyFreePlan, isComplimentaryMember, isPaidMember, subscriptionHasFreeTrial} from '../../../../utils/helpers'; +import {getSubscriptionExpiry, getMemberSubscription, getMemberTierName, hasMultipleProductsFeature, hasOnlyFreePlan, isComplimentaryMember, isGiftMember, isPaidMember, subscriptionHasFreeTrial} from '../../../../utils/helpers'; import {getDateString} from '../../../../utils/date-time'; import {ReactComponent as GiftIcon} from '../../../../images/icons/gift.svg'; import {ReactComponent as LoaderIcon} from '../../../../images/icons/loader.svg'; @@ -35,9 +35,8 @@ const PaidAccountActions = () => { } const subscriptionExpiry = getSubscriptionExpiry({member}); - const isGiftMember = member?.status === 'gift'; - if (isGiftMember && subscriptionExpiry) { + if (isGiftMember({member}) && subscriptionExpiry) { return (

@@ -101,6 +100,16 @@ const PaidAccountActions = () => { if (hasOnlyFreePlan({site}) && !isPaid) { return null; } + if (isGiftMember({member})) { + return ( + + ); + } return (

} + sidebar={} + testId="welcome-email-customize-modal" + title="Customize welcome email" + onClose={() => { + onClose?.(); + setOpen(false); + }} + onSave={() => {}} + /> + + ); +}; + +describe('Welcome email customize modal', function () { + afterEach(function () { + vi.useRealTimers(); + }); + + it('keeps the customize modal open when Escape is pressed after the color picker has mounted', async function () { + vi.useFakeTimers(); + const onClose = vi.fn(); + + render(); + + fireEvent.click(screen.getByText('Button color')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + assert.ok(screen.getByRole('textbox')); + + await act(async () => { + fireEvent.keyDown(document, {key: 'Escape'}); + }); + + assert.equal(onClose.mock.calls.length, 0); + assert.ok(screen.getByTestId('welcome-email-customize-modal')); + }); + + it('keeps the customize modal open when Escape is pressed immediately after opening the color picker', async function () { + vi.useFakeTimers(); + const onClose = vi.fn(); + + render(); + + await act(async () => { + fireEvent.click(screen.getByText('Button color')); + assert.equal(screen.queryByRole('textbox'), null); + fireEvent.keyDown(document, {key: 'Escape'}); + }); + + assert.ok(screen.queryByTestId('welcome-email-customize-modal')); + assert.equal(onClose.mock.calls.length, 0); + }); +}); diff --git a/e2e/tests/admin/settings/member-welcome-emails.test.ts b/e2e/tests/admin/settings/member-welcome-emails.test.ts index 2c525d438dc..97d306f7531 100644 --- a/e2e/tests/admin/settings/member-welcome-emails.test.ts +++ b/e2e/tests/admin/settings/member-welcome-emails.test.ts @@ -347,7 +347,7 @@ test.describe('Ghost Admin - Welcome Email Customize Button', () => { await expect(welcomeEmailsSection.customizeModalFooterTextarea).toHaveValue('Persisted footer'); }); - test('Escape shows unsaved changes confirmation for welcome email customization', async ({page}) => { + test('escape behavior - shows unsaved changes confirmation for welcome email customization', async ({page}) => { const welcomeEmailsSection = new MemberWelcomeEmailsSection(page); await welcomeEmailsSection.goto(); @@ -363,7 +363,7 @@ test.describe('Ghost Admin - Welcome Email Customize Button', () => { await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/); }); - test('Escape closes welcome email customization confirmation without closing the customize modal', async ({page}) => { + test('escape behavior - closes welcome email customization confirmation without closing the customize modal', async ({page}) => { const welcomeEmailsSection = new MemberWelcomeEmailsSection(page); await welcomeEmailsSection.goto(); @@ -381,7 +381,7 @@ test.describe('Ghost Admin - Welcome Email Customize Button', () => { await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/); }); - test.skip('Escape closes welcome email color picker without bypassing unsaved changes confirmation', async ({page}) => { + test('escape behavior - closes welcome email color picker without bypassing unsaved changes confirmation', async ({page}) => { const welcomeEmailsSection = new MemberWelcomeEmailsSection(page); await welcomeEmailsSection.goto(); @@ -411,7 +411,7 @@ test.describe('Ghost Admin - Welcome Email Customize Button', () => { await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/); }); - test('Escape closes welcome email font select without bypassing unsaved changes confirmation', async ({page}) => { + test('escape behavior - closes welcome email font select without bypassing unsaved changes confirmation', async ({page}) => { const welcomeEmailsSection = new MemberWelcomeEmailsSection(page); await welcomeEmailsSection.goto(); From 3057c590fe768250997683ca212b4fbdbdc1edbb Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Thu, 23 Apr 2026 09:15:40 -0500 Subject: [PATCH 08/11] Moved @tryghost framework pins to pnpm catalog (#27533) no ref Moves three `@tryghost/*` packages to `pnpm-workspace.yaml`'s `catalog:` entry so the version is declared in one place instead of being mirrored across up to seven workspaces and `pnpm.overrides`. --- apps/comments-ui/package.json | 4 ++-- apps/portal/package.json | 4 ++-- apps/signup-form/package.json | 4 ++-- apps/sodo-search/package.json | 4 ++-- e2e/package.json | 4 ++-- ghost/core/package.json | 6 +++--- ghost/core/scripts/pack.js | 31 +++++++++++++++++++++++++++++-- ghost/i18n/package.json | 2 +- package.json | 4 ++-- pnpm-lock.yaml | 21 ++++++++++++--------- pnpm-workspace.yaml | 3 +++ 11 files changed, 60 insertions(+), 27 deletions(-) diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index 7980617018d..cbeefcb949e 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/comments-ui", - "version": "1.4.8", + "version": "1.4.9", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", @@ -55,7 +55,7 @@ "@tiptap/extension-text": "2.26.3", "@tiptap/pm": "2.26.3", "@tiptap/react": "2.26.3", - "@tryghost/debug": "2.1.0", + "@tryghost/debug": "catalog:", "react": "17.0.2", "react-dom": "17.0.2", "react-string-replace": "1.1.1" diff --git a/apps/portal/package.json b/apps/portal/package.json index 561f558a171..2695e50632e 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.68.15", + "version": "2.68.16", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", @@ -133,7 +133,7 @@ "vitest": "3.2.4" }, "dependencies": { - "@tryghost/debug": "2.1.0" + "@tryghost/debug": "catalog:" }, "nx": { "tags": [ diff --git a/apps/signup-form/package.json b/apps/signup-form/package.json index 1ebcc7e1bf4..53ba1d1b0dd 100644 --- a/apps/signup-form/package.json +++ b/apps/signup-form/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/signup-form", - "version": "0.3.16", + "version": "0.3.17", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", @@ -31,7 +31,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "@tryghost/debug": "2.1.0", + "@tryghost/debug": "catalog:", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/apps/sodo-search/package.json b/apps/sodo-search/package.json index 09cde542d63..264280919fd 100644 --- a/apps/sodo-search/package.json +++ b/apps/sodo-search/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/sodo-search", - "version": "1.8.13", + "version": "1.8.14", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", @@ -14,7 +14,7 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { - "@tryghost/debug": "2.1.0", + "@tryghost/debug": "catalog:", "@tryghost/i18n": "workspace:*", "flexsearch": "0.8.153", "react": "17.0.2", diff --git a/e2e/package.json b/e2e/package.json index 96a0870c55f..b39b495e70e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -31,8 +31,8 @@ "@eslint/js": "catalog:", "@faker-js/faker": "8.4.1", "@playwright/test": "1.59.1", - "@tryghost/debug": "2.1.0", - "@tryghost/logging": "2.5.5", + "@tryghost/debug": "catalog:", + "@tryghost/logging": "catalog:", "@types/dockerode": "3.3.47", "@types/express": "4.17.25", "busboy": "^1.6.0", diff --git a/ghost/core/package.json b/ghost/core/package.json index d38c52f3a24..dcf120b8f64 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -97,10 +97,10 @@ "@tryghost/config-url-helpers": "1.0.23", "@tryghost/custom-fonts": "1.0.8", "@tryghost/database-info": "0.3.35", - "@tryghost/debug": "2.1.0", + "@tryghost/debug": "catalog:", "@tryghost/domain-events": "1.0.8", "@tryghost/email-mock-receiver": "2.1.0", - "@tryghost/errors": "1.3.13", + "@tryghost/errors": "catalog:", "@tryghost/helpers": "1.1.103", "@tryghost/html-to-plaintext": "1.0.8", "@tryghost/http-cache-utils": "0.1.25", @@ -119,7 +119,7 @@ "@tryghost/kg-markdown-html-renderer": "7.2.0", "@tryghost/kg-mobiledoc-html-renderer": "7.2.0", "@tryghost/limit-service": "1.5.2", - "@tryghost/logging": "2.5.5", + "@tryghost/logging": "catalog:", "@tryghost/members-csv": "2.0.5", "@tryghost/metrics": "1.0.43", "@tryghost/mongo-utils": "0.6.3", diff --git a/ghost/core/scripts/pack.js b/ghost/core/scripts/pack.js index e768246e4b0..ebe206d152a 100644 --- a/ghost/core/scripts/pack.js +++ b/ghost/core/scripts/pack.js @@ -23,11 +23,31 @@ const fs = require('node:fs'); const path = require('node:path'); const {execFileSync} = require('node:child_process'); const fsExtra = require('fs-extra'); +const yaml = require('js-yaml'); const CORE_DIR = path.resolve(__dirname, '..'); const ROOT_DIR = path.resolve(CORE_DIR, '../..'); const DEPLOY_DIR = path.join(CORE_DIR, 'package'); +// pnpm-workspace.yaml's `catalog:` entries are only understood inside the +// pnpm workspace. After ghost-cli unpacks this tarball the install runs +// outside any workspace, so any `catalog:` specifier that leaks through +// would crash pnpm with ERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER. +// We resolve them here with the exact versions from the workspace yaml. +const workspaceYaml = yaml.load(fs.readFileSync(path.join(ROOT_DIR, 'pnpm-workspace.yaml'), 'utf8')); +const catalog = workspaceYaml.catalog || {}; + +function resolveCatalogSpec(name, spec) { + if (spec === 'catalog:' || spec === `catalog:${name}` || spec === 'catalog:default') { + const resolved = catalog[name]; + if (!resolved) { + throw new Error(`Catalog reference for ${name} not found in pnpm-workspace.yaml`); + } + return resolved; + } + return spec; +} + // 1. Run pnpm deploy // inject-workspace-packages is enabled only for deploy (not workspace-wide) // because it conflicts with packages that have build outputs in their files field. @@ -84,8 +104,12 @@ for (const [key, val] of Object.entries(pkg.dependencies || {})) { const tgzName = `${slug}-${depPkg.version}.tgz`; console.log(` Packing ${key} → components/${tgzName}`); + // pnpm pack (vs npm pack) substitutes `workspace:` and `catalog:` + // specifiers in the packed package.json with concrete versions, + // which is required once the tarball is installed outside the + // workspace by ghost-cli. execFileSync( - 'npm', + 'pnpm', ['pack', '--pack-destination', componentsDir], {cwd: depDir, stdio: 'pipe'} ); @@ -107,8 +131,11 @@ console.log(` Set packageManager: ${rootPkg.packageManager.split('+')[0]}`); // creates a standalone context where these don't apply, so we merge them in. if (rootPkg.pnpm?.overrides || rootPkg.overrides) { const rootOverrides = rootPkg.pnpm?.overrides || rootPkg.overrides; + const resolvedOverrides = Object.fromEntries( + Object.entries(rootOverrides).map(([name, spec]) => [name, resolveCatalogSpec(name, spec)]) + ); pkg.pnpm = pkg.pnpm || {}; - pkg.pnpm.overrides = {...rootOverrides, ...pkg.pnpm.overrides}; + pkg.pnpm.overrides = {...resolvedOverrides, ...pkg.pnpm.overrides}; console.log(` Merged ${Object.keys(rootOverrides).length} root overrides into package.json`); } diff --git a/ghost/i18n/package.json b/ghost/i18n/package.json index 5292ec24179..c100131f4e2 100644 --- a/ghost/i18n/package.json +++ b/ghost/i18n/package.json @@ -37,7 +37,7 @@ "mocha": "11.7.5" }, "dependencies": { - "@tryghost/debug": "2.1.0", + "@tryghost/debug": "catalog:", "i18next": "23.16.8" }, "nx": { diff --git a/package.json b/package.json index 84b845a933c..f1cd52a51ea 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,8 @@ }, "pnpm": { "overrides": { - "@tryghost/errors": "^1.3.7", - "@tryghost/logging": "2.5.5", + "@tryghost/errors": "catalog:", + "@tryghost/logging": "catalog:", "jackspeak": "2.3.6", "moment": "2.24.0", "moment-timezone": "0.5.45", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 718a56b03f4..12bc8019f63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@eslint/js': specifier: 8.57.1 version: 8.57.1 + '@tryghost/debug': + specifier: 2.1.0 + version: 2.1.0 eslint: specifier: 8.57.1 version: 8.57.1 @@ -21,7 +24,7 @@ catalogs: version: 9.37.0 overrides: - '@tryghost/errors': ^1.3.7 + '@tryghost/errors': 1.3.13 '@tryghost/logging': 2.5.5 jackspeak: 2.3.6 moment: 2.24.0 @@ -777,7 +780,7 @@ importers: specifier: 2.26.3 version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) '@tryghost/debug': - specifier: 2.1.0 + specifier: 'catalog:' version: 2.1.0 react: specifier: 17.0.2 @@ -865,7 +868,7 @@ importers: apps/portal: dependencies: '@tryghost/debug': - specifier: 2.1.0 + specifier: 'catalog:' version: 2.1.0 devDependencies: '@babel/eslint-parser': @@ -1273,7 +1276,7 @@ importers: apps/signup-form: dependencies: '@tryghost/debug': - specifier: 2.1.0 + specifier: 'catalog:' version: 2.1.0 react: specifier: 18.3.1 @@ -1379,7 +1382,7 @@ importers: apps/sodo-search: dependencies: '@tryghost/debug': - specifier: 2.1.0 + specifier: 'catalog:' version: 2.1.0 '@tryghost/i18n': specifier: workspace:* @@ -1519,7 +1522,7 @@ importers: specifier: 1.59.1 version: 1.59.1 '@tryghost/debug': - specifier: 2.1.0 + specifier: 'catalog:' version: 2.1.0 '@tryghost/logging': specifier: 2.5.5 @@ -2012,7 +2015,7 @@ importers: specifier: 0.3.35 version: 0.3.35 '@tryghost/debug': - specifier: 2.1.0 + specifier: 'catalog:' version: 2.1.0 '@tryghost/domain-events': specifier: 1.0.8 @@ -2021,7 +2024,7 @@ importers: specifier: 2.1.0 version: 2.1.0 '@tryghost/errors': - specifier: ^1.3.7 + specifier: 1.3.13 version: 1.3.13 '@tryghost/helpers': specifier: 1.1.103 @@ -2599,7 +2602,7 @@ importers: ghost/i18n: dependencies: '@tryghost/debug': - specifier: 2.1.0 + specifier: 'catalog:' version: 2.1.0 i18next: specifier: 23.16.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b3c52097568..e4723bbf1a2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,9 @@ strictDepBuilds: true catalog: '@eslint/js': 8.57.1 eslint: 8.57.1 + '@tryghost/debug': 2.1.0 + '@tryghost/errors': 1.3.13 + '@tryghost/logging': 2.5.5 catalogs: eslint9: From b01f1c0ed2e0e04660b779b3d3f0752f4a36c2d5 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 23 Apr 2026 09:18:21 -0500 Subject: [PATCH 09/11] Created "poll automations" permission for scheduler (#27519) ref https://linear.app/ghost/issue/NY-1191 ref https://github.com/TryGhost/Ghost/pull/27437 This creates a new permission, "Poll automations", which the Scheduler Integration role gets. This will be used in [an upcoming change][0]. It's a separate patch because it contains a database migration. [0]: https://github.com/TryGhost/Ghost/pull/27437 Co-authored-by: Troy Ciesco --- ...tomations-poll-permission-to-scheduler-integration.js | 9 +++++++++ .../core/core/server/data/schema/fixtures/fixtures.json | 8 +++++++- ghost/core/test/integration/migrations/migration.test.js | 3 ++- .../server/data/schema/fixtures/fixture-manager.test.js | 2 +- .../core/test/unit/server/data/schema/integrity.test.js | 2 +- ghost/core/test/utils/fixtures/fixtures.json | 8 +++++++- 6 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 ghost/core/core/server/data/migrations/versions/6.33/2026-04-22-20-53-24-add-automations-poll-permission-to-scheduler-integration.js diff --git a/ghost/core/core/server/data/migrations/versions/6.33/2026-04-22-20-53-24-add-automations-poll-permission-to-scheduler-integration.js b/ghost/core/core/server/data/migrations/versions/6.33/2026-04-22-20-53-24-add-automations-poll-permission-to-scheduler-integration.js new file mode 100644 index 00000000000..022d28ff355 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.33/2026-04-22-20-53-24-add-automations-poll-permission-to-scheduler-integration.js @@ -0,0 +1,9 @@ +const {addPermissionWithRoles} = require('../../utils'); + +module.exports = addPermissionWithRoles({ + name: 'Poll automations', + action: 'poll', + object: 'automation' +}, [ + 'Scheduler Integration' +]); diff --git a/ghost/core/core/server/data/schema/fixtures/fixtures.json b/ghost/core/core/server/data/schema/fixtures/fixtures.json index cd8895acd18..b66563f1248 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixtures.json +++ b/ghost/core/core/server/data/schema/fixtures/fixtures.json @@ -773,6 +773,11 @@ "name": "Delete recommendations", "action_type": "destroy", "object_type": "recommendation" + }, + { + "name": "Poll automations", + "action_type": "poll", + "object_type": "automation" } ] }, @@ -948,7 +953,8 @@ "member": "browse" }, "Scheduler Integration": { - "post": "publish" + "post": "publish", + "automation": "poll" }, "Ghost Explore Integration": { "explore": "read" diff --git a/ghost/core/test/integration/migrations/migration.test.js b/ghost/core/test/integration/migrations/migration.test.js index 3ede942b9ca..1ae56324f7f 100644 --- a/ghost/core/test/integration/migrations/migration.test.js +++ b/ghost/core/test/integration/migrations/migration.test.js @@ -91,7 +91,7 @@ describe('Migrations', function () { // Custom assertion to wrap all permissions function assertCompletePermissions(permissions) { // If you have to change this number, please add the relevant `assertHavePermission` checks below - assert.equal(permissions.length, 130); + assert.equal(permissions.length, 131); assertHavePermission(permissions, 'Export database', ['Administrator', 'DB Backup Integration']); assertHavePermission(permissions, 'Import database', ['Administrator', 'Self-Serve Migration Integration', 'DB Backup Integration']); @@ -241,6 +241,7 @@ describe('Migrations', function () { assertHavePermission(permissions, 'Edit automated emails', ['Administrator', 'Admin Integration']); assertHavePermission(permissions, 'Add automated emails', ['Administrator', 'Admin Integration']); assertHavePermission(permissions, 'Delete automated emails', ['Administrator', 'Admin Integration']); + assertHavePermission(permissions, 'Poll automations', ['Scheduler Integration']); assertHavePermission(permissions, 'Browse email design settings', ['Administrator', 'Admin Integration']); assertHavePermission(permissions, 'Read email design settings', ['Administrator', 'Admin Integration']); diff --git a/ghost/core/test/unit/server/data/schema/fixtures/fixture-manager.test.js b/ghost/core/test/unit/server/data/schema/fixtures/fixture-manager.test.js index fe19b7db619..8cfac95b848 100644 --- a/ghost/core/test/unit/server/data/schema/fixtures/fixture-manager.test.js +++ b/ghost/core/test/unit/server/data/schema/fixtures/fixture-manager.test.js @@ -411,7 +411,7 @@ describe('Migration Fixture Utils', function () { const rolesAllStub = sinon.stub(models.Role, 'findAll').returns(Promise.resolve(dataMethodStub)); fixtureManager.addFixturesForRelation(fixtures.relations[0]).then(function (result) { - const FIXTURE_COUNT = 139; + const FIXTURE_COUNT = 140; assertExists(result); assert(_.isPlainObject(result)); assert.equal(result.expected, FIXTURE_COUNT); 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 9278b124437..889fddec5c0 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -36,7 +36,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route describe('DB version integrity', function () { // Only these variables should need updating const currentSchemaHash = 'db036dfc567b48b78bdd36cd6604909f'; - const currentFixturesHash = '2f86ab1e3820e86465f9ad738dd0ee93'; + const currentFixturesHash = '64a3110da34bdbaa9a6cc78de9680f7e'; const currentSettingsHash = 'a102b80d2ab0cd92325ed007c94d7da6'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; diff --git a/ghost/core/test/utils/fixtures/fixtures.json b/ghost/core/test/utils/fixtures/fixtures.json index aebdc5ad450..08b74ea454a 100644 --- a/ghost/core/test/utils/fixtures/fixtures.json +++ b/ghost/core/test/utils/fixtures/fixtures.json @@ -774,6 +774,11 @@ "name": "Delete recommendations", "action_type": "destroy", "object_type": "recommendation" + }, + { + "name": "Poll automations", + "action_type": "poll", + "object_type": "automation" } ] }, @@ -1102,7 +1107,8 @@ "member": "browse" }, "Scheduler Integration": { - "post": "publish" + "post": "publish", + "automation": "poll" }, "Ghost Explore Integration": { "explore": "read" From d9f9208019f8884e7bb847962e104e8e4601a4bf Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 23 Apr 2026 09:21:37 -0500 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20appearance=20of=20?= =?UTF-8?q?some=20welcome=20email=20image=20captions=20(#27486)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://linear.app/ghost/issue/NY-1233 ref https://github.com/TryGhost/Ghost/pull/27485 Welcome emails use `
` and `
` for images and their captions, which some email clients don't support. That causes the styling to be messed up in those clients. [Newsletter rendering had already fixed this.][0] This patch fixes it the same way. In the long term, we should unify these renderers. In the short term, let's fix this bug. [0]: https://github.com/TryGhost/Ghost/blob/d26bfdb0b20f029a82709782804164d621848d3f/ghost/core/core/server/services/email-service/email-renderer.js#L514-L544 --- .../services/email-rendering/finalize.js | 43 +++++++++++++++++-- .../automated-emails.test.js.snap | 12 +++--- ...ember-welcome-emails-snapshot.test.js.snap | 40 ++++++++--------- .../member-welcome-email-renderer.test.js | 35 ++++++++++++--- 4 files changed, 94 insertions(+), 36 deletions(-) diff --git a/ghost/core/core/server/services/email-rendering/finalize.js b/ghost/core/core/server/services/email-rendering/finalize.js index 3e01e37e0eb..56f897248ca 100644 --- a/ghost/core/core/server/services/email-rendering/finalize.js +++ b/ghost/core/core/server/services/email-rendering/finalize.js @@ -1,10 +1,47 @@ +const cheerio = require('cheerio'); const juice = require('juice'); const htmlToPlaintext = require('@tryghost/html-to-plaintext'); +/** + * @param {string} html + * @returns {string} + */ +const finalizeHtml = (html) => { + // Add a class to each figcaption so we can style them in the email. + let $ = cheerio.load(html, null, false); + $('figcaption').addClass('kg-card-figcaption'); + html = $.html(); + + const juicedHtml = juice(html, {inlinePseudoElements: true, removeStyleTags: true}); + + // Many email clients, like Outlook and Yahoo, [lack support for
+ // and
][0]. To work around this, change the tags to
s. + // Juice should have properly styled them. + // + // [0]: https://www.caniemail.com/features/html-semantics/ + $ = cheerio.load(juicedHtml, null, false); + $('figure, figcaption').each((_, el) => { + el.tagName = 'div'; + }); + + // Fix characters unsupported in some Outlook versions. + html = $.html(); + html = html.replace(/'/g, '''); + html = html.replace(/→/g, '→'); + html = html.replace(/–/g, '–'); + html = html.replace(/“/g, '“'); + html = html.replace(/”/g, '”'); + + return html; +}; + module.exports = { + /** + * @param {string} html + * @returns {{html: string, plaintext: string}} + */ finalize(html) { - const inlinedHtml = juice(html, {inlinePseudoElements: true, removeStyleTags: true}); - const plaintext = htmlToPlaintext.email(inlinedHtml); - return {html: inlinedHtml, plaintext}; + const resultHtml = finalizeHtml(html); + return {html: resultHtml, plaintext: htmlToPlaintext.email(resultHtml)}; } }; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap index bd5968de448..2e97903904a 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap @@ -1110,7 +1110,7 @@ table.body h2 span { - +
  
@@ -1137,7 +1137,7 @@ table.body h2 span {
-   +   -   +  
@@ -1176,7 +1176,7 @@ table.body h2 span {
@@ -1189,7 +1189,7 @@ table.body h2 span { - + - +
- Ghost © 2025 — Manage your preferences + Ghost © 2025 — Manage your preferences
  
  
@@ -590,7 +590,7 @@ table.body h2 span {
-   +   -   +  
@@ -629,7 +629,7 @@ table.body h2 span {
@@ -642,7 +642,7 @@ table.body h2 span { - + - +
- Test Site © 2020 — Manage your preferences + Test Site © 2020 — Manage your preferences
  
  
@@ -1357,7 +1357,7 @@ table.body h2 span {
-   +   -   +  
@@ -1396,7 +1396,7 @@ table.body h2 span {
@@ -1409,7 +1409,7 @@ table.body h2 span { - + - +
- Test Site © 2020 — Manage your preferences + Test Site © 2020 — Manage your preferences
  
  
@@ -2124,7 +2124,7 @@ table.body h2 span {
-   +   -   +  
@@ -2163,7 +2163,7 @@ table.body h2 span {
@@ -2176,7 +2176,7 @@ table.body h2 span { - + - +
- Test Site © 2020 — Manage your preferences + Test Site © 2020 — Manage your preferences
  
  
@@ -2892,7 +2892,7 @@ table.body h2 span {
-   +   -   +  
@@ -2931,7 +2931,7 @@ table.body h2 span {
@@ -2944,7 +2944,7 @@ table.body h2 span { - +
- Test Site © 2020 — Manage your preferences + Test Site © 2020 — Manage your preferences