Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import ErrorBoundary from '../error-boundary';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FetchKoenigLexical = () => Promise<any>

export type NodeType = 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES';
export type NodeType = 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES' | 'EMAIL_NODES';

export interface KoenigEditorBaseProps {
onBlur?: () => void
Expand All @@ -16,6 +16,7 @@ export interface KoenigEditorBaseProps {
darkMode?: boolean
singleParagraph?: boolean
className?: string
inheritFontStyles?: boolean
}

declare global {
Expand Down Expand Up @@ -123,7 +124,8 @@ export const KoenigWrapper: React.FC<KoenigWrapperProps> = ({
const transformers = {
DEFAULT_NODES: koenig.DEFAULT_TRANSFORMERS,
BASIC_NODES: koenig.BASIC_TRANSFORMERS,
MINIMAL_NODES: koenig.MINIMAL_TRANSFORMERS
MINIMAL_NODES: koenig.MINIMAL_TRANSFORMERS,
EMAIL_NODES: koenig.EMAIL_TRANSFORMERS
};

const defaultNodes = nodes || 'DEFAULT_NODES';
Expand Down Expand Up @@ -164,14 +166,16 @@ const KoenigEditorBase: React.FC<KoenigEditorBaseInternalProps> = ({
children,
initialEditorState,
onChange,
inheritFontStyles = true,
...props
}) => {
const {fetchKoenigLexical, darkMode} = useDesignSystem();
const editorResource = useMemo(() => loadKoenig(fetchKoenigLexical), [fetchKoenigLexical]);
const inheritClasses = inheritFontStyles ? '[&_*]:!font-inherit [&_*]:!text-inherit' : '';

return (
<div className={className || 'w-full'}>
<div className="koenig-react-editor w-full [&_*]:!font-inherit [&_*]:!text-inherit">
<div className={`koenig-react-editor w-full ${inheritClasses}`}>
<ErrorBoundary name='editor'>
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
<KoenigWrapper
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const EmailPreview: React.FC<{
const senderName = automatedEmail.sender_name || siteTitle || 'Your Site';

return (
<div className='mb-5 flex items-center justify-between gap-3 rounded-lg border border-grey-100 bg-grey-50 p-5'>
<div className='mb-5 flex items-center justify-between gap-3 rounded-lg border border-grey-100 bg-grey-50 p-5 dark:border-grey-925 dark:bg-grey-950'>
<div className='flex items-start gap-3'>
{icon ?
<div className='size-10 min-h-10 min-w-10 rounded-sm bg-cover bg-center' style={{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {useCallback} from 'react';
import {KoenigEditorBase, type NodeType} from '@tryghost/admin-x-design-system';
import {KoenigEditorBase, type KoenigInstance, type NodeType} from '@tryghost/admin-x-design-system';

export interface MemberEmailsEditorProps {
value?: string;
Expand All @@ -13,7 +13,7 @@ export interface MemberEmailsEditorProps {
const MemberEmailsEditor: React.FC<MemberEmailsEditorProps> = ({
value,
placeholder,
nodes = 'DEFAULT_NODES',
nodes = 'EMAIL_NODES',
singleParagraph = false,
className,
onChange
Expand All @@ -33,13 +33,20 @@ const MemberEmailsEditor: React.FC<MemberEmailsEditorProps> = ({
<KoenigEditorBase
className={className}
emojiPicker={false}
inheritFontStyles={false}
initialEditorState={value}
nodes={nodes}
placeholder={placeholder}
singleParagraph={singleParagraph}
onChange={handleChange}
>
{() => null}
{(koenig: KoenigInstance) => (
<>
<koenig.ReplacementStringsPlugin />
<koenig.ListPlugin />
<koenig.HorizontalRulePlugin />
</>
)}
</KoenigEditorBase>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const TestEmailDropdown: React.FC<TestEmailDropdownProps> = ({
};

return (
<div className='absolute right-0 top-full z-10 mt-2 w-[260px] rounded border border-grey-200 bg-white p-4 shadow-lg'>
<div className='absolute right-0 top-full z-10 mt-2 w-[260px] rounded border border-grey-250 bg-white p-4 shadow-lg dark:border-grey-925 dark:bg-grey-975'>
<div className='mb-3'>
<label className='mb-2 block text-sm font-semibold' htmlFor='test-email-input'>Send test email</label>
<TextField
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,14 @@ const WelcomeEmailModal = NiceModal.create<WelcomeEmailModalProps>(({emailType =
header={false}
testId='welcome-email-modal'
>
<div className='-mx-8 h-[calc(100vh-16vmin)] overflow-y-auto'>
<div className='sticky top-0 z-10 flex flex-col gap-2 border-b border-grey-100 bg-white p-5'>
<div className='-mx-8 h-[calc(100vh-16vmin)] overflow-y-auto dark:!bg-grey-975'>
<div className='sticky top-0 z-10 flex flex-col gap-2 border-b border-grey-100 bg-white p-5 dark:border-grey-900 dark:bg-grey-975'>
<div className='mb-2 flex items-center justify-between'>
<h3 className='font-semibold'>{emailType === 'paid' ? 'Paid' : 'Free'} members welcome email</h3>
<div className='flex items-center gap-2'>
<div ref={dropdownRef} className='relative'>
<Button
className='border border-grey-200 font-semibold hover:border-grey-300 hover:!bg-white'
className='border border-grey-200 font-semibold hover:border-grey-300 hover:!bg-white dark:border-grey-800 dark:hover:border-grey-700 dark:hover:!bg-grey-950'
color="clear"
icon='send'
label="Test"
Expand All @@ -153,13 +153,13 @@ const WelcomeEmailModal = NiceModal.create<WelcomeEmailModalProps>(({emailType =
<div className='w-20 font-semibold'>From:</div>
<div className='flex grow items-center gap-1'>
<span>{automatedEmail?.sender_name || siteTitle}</span>
<span className='text-grey-700'>{`<${senderEmail}>`}</span>
<span className='text-grey-700 dark:text-grey-400'>{`<${senderEmail}>`}</span>
</div>
</div>
{replyToEmail !== senderEmail && (
<div className='flex items-center py-0.5'>
<div className='w-20 font-semibold'>Reply-to:</div>
<div className='grow text-grey-700'>
<div className='grow text-grey-700 dark:text-grey-400'>
{replyToEmail}
</div>
</div>
Expand All @@ -179,11 +179,10 @@ const WelcomeEmailModal = NiceModal.create<WelcomeEmailModalProps>(({emailType =
</div>
</div>
</div>
<div className='bg-grey-50 p-6'>
<div className={`mx-auto max-w-[600px] rounded border bg-white p-8 text-[1.6rem] leading-[1.6] tracking-[-0.01em] shadow-sm [&_a]:text-black [&_a]:underline [&_p]:mb-4 [&_strong]:font-semibold ${errors.lexical ? 'border-red' : 'border-grey-200'}`}>
<div className='bg-grey-50 p-6 dark:bg-grey-975'>
<div className={`mx-auto max-w-[600px] rounded border bg-white p-8 text-[1.6rem] leading-[1.6] tracking-[-0.01em] shadow-sm dark:bg-grey-975 dark:text-white dark:shadow-none dark:selection:bg-[rgba(88,101,116,0.99)] [&_:is(h2,h3)]:dark:text-white [&_p]:mb-4 [&_strong]:font-semibold ${errors.lexical ? 'border-red' : 'border-grey-200 dark:border-grey-925'}`}>
<MemberEmailEditor
key={automatedEmail?.id || 'new'}
nodes='DEFAULT_NODES'
placeholder='Write your welcome email content...'
singleParagraph={false}
value={formState.lexical}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,8 @@ const MESSAGES = {
memberWelcomeEmailInactive: memberStatus => `Member welcome email for "${memberStatus}" members is inactive`
};

// Default welcome email content in Lexical JSON format
// Uses __GHOST_URL__ placeholder which Ghost replaces with the actual site URL
// These match the defaults used in the admin UI (apps/admin-x-settings/src/components/settings/membership/member-emails.tsx)
const DEFAULT_FREE_LEXICAL_CONTENT = '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome! Thanks for subscribing — it\'s great to have you here.","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"You\'ll now receive new posts straight to your inbox. You can also log in any time to read the ","type":"extended-text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"full archive","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":"noreferrer","target":null,"title":null,"url":"__GHOST_URL__/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" or catch up on new posts as they go live.","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"A little housekeeping: If this email landed in spam or promotions, try moving it to your primary inbox and adding this address to your contacts. Small signals like that help your inbox recognize that these messages matter to you.","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Have questions or just want to say hi? Feel free to reply directly to this email or any newsletter in the future.","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}';

const DEFAULT_PAID_LEXICAL_CONTENT = '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome, and thank you for your support — it means a lot.","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"As a paid member, you now have full access to everything: the complete archive, and any paid-only content going forward. New posts will land straight to your inbox, and you can log in any time to ","type":"extended-text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"catch up","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":"noreferrer","target":null,"title":null,"url":"__GHOST_URL__/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" on anything you\'ve missed.","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"A little housekeeping: If this email landed in spam or promotions, try moving it to your primary inbox and adding this address to your contacts. Small signals like that help your inbox recognize that these messages matter to you.","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Have questions or just want to say hi? Feel free to reply directly to this email or any newsletter in the future.","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}';

const DEFAULT_WELCOME_EMAILS = {
free: {
lexical: DEFAULT_FREE_LEXICAL_CONTENT,
subject: 'Welcome to {site_title}',
status: 'active'
},
paid: {
lexical: DEFAULT_PAID_LEXICAL_CONTENT,
subject: 'Welcome to your paid subscription',
status: 'active'
}
};

module.exports = {
MEMBER_WELCOME_EMAIL_LOG_KEY,
MEMBER_WELCOME_EMAIL_SLUGS,
MESSAGES,
DEFAULT_WELCOME_EMAILS
MESSAGES
};
22 changes: 2 additions & 20 deletions ghost/core/core/server/services/member-welcome-emails/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const mail = require('../mail');
// @ts-expect-error type checker has trouble with the dynamic exporting in models
const {AutomatedEmail} = require('../../models');
const MemberWelcomeEmailRenderer = require('./member-welcome-email-renderer');
const {MEMBER_WELCOME_EMAIL_LOG_KEY, MEMBER_WELCOME_EMAIL_SLUGS, MESSAGES, DEFAULT_WELCOME_EMAILS} = require('./constants');
const {MEMBER_WELCOME_EMAIL_LOG_KEY, MEMBER_WELCOME_EMAIL_SLUGS, MESSAGES} = require('./constants');

class MemberWelcomeEmailService {
#mailer;
Expand All @@ -30,32 +30,14 @@ class MemberWelcomeEmailService {
}

async loadMemberWelcomeEmails() {
const useDefaults = Boolean(config.get('memberWelcomeEmailTestInbox'));

for (const [memberStatus, slug] of Object.entries(MEMBER_WELCOME_EMAIL_SLUGS)) {
const row = await AutomatedEmail.findOne({slug});

if (!row) {
// No row - use default template when test inbox is configured
if (useDefaults) {
const defaultEmail = DEFAULT_WELCOME_EMAILS[memberStatus];
this.#memberWelcomeEmails[memberStatus] = {
...defaultEmail,
lexical: urlUtils.transformReadyToAbsolute(defaultEmail.lexical)
};
} else {
this.#memberWelcomeEmails[memberStatus] = null;
}
continue;
}

// Row exists - check if it has content
if (!row.get('lexical')) {
if (!row || !row.get('lexical')) {
this.#memberWelcomeEmails[memberStatus] = null;
continue;
}

// Use DB template (status check happens in send())
this.#memberWelcomeEmails[memberStatus] = {
lexical: row.get('lexical'),
subject: row.get('subject'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,6 @@ module.exports = class MemberRepository {
const freeWelcomeEmail = this._AutomatedEmail ? await this._AutomatedEmail.findOne({slug: MEMBER_WELCOME_EMAIL_SLUGS.free}) : null;
const isFreeWelcomeEmailActive = freeWelcomeEmail && freeWelcomeEmail.get('lexical') && freeWelcomeEmail.get('status') === 'active';
const isFreeSignup = !stripeCustomer;
// Use default template only when no DB row exists (not when inactive - respect explicit user choice)
const useDefaultFreeTemplate = !freeWelcomeEmail && hasTestInbox;

const runMemberCreation = async (transacting) => {
const newMember = await this._Member.add({
Expand All @@ -358,10 +356,10 @@ module.exports = class MemberRepository {
labels
}, {...memberAddOptions, transacting});

// Send the free welcome email if:
// 1. The free welcome email is active OR no DB row exists and test inbox is configured (uses default template)
// Only send the free welcome email if:
// 1. The free welcome email is active
// 2. The member is not signing up for a paid subscription (no stripeCustomer)
if ((isFreeWelcomeEmailActive || useDefaultFreeTemplate) && isFreeSignup) {
if (isFreeWelcomeEmailActive && isFreeSignup) {
const timestamp = eventData.created_at || newMember.get('created_at');

await this._Outbox.add({
Expand All @@ -387,7 +385,7 @@ module.exports = class MemberRepository {
member = await this._Member.transaction(runMemberCreation);
}

if ((isFreeWelcomeEmailActive || useDefaultFreeTemplate) && isFreeSignup) {
if (isFreeWelcomeEmailActive && isFreeSignup) {
this.dispatchEvent(StartOutboxProcessingEvent.create({memberId: member.id}), memberAddOptions);
}
} else {
Expand Down Expand Up @@ -1441,17 +1439,14 @@ module.exports = class MemberRepository {
const source = this._resolveContextSource(context);
const shouldSendPaidWelcomeEmail = config.get('memberWelcomeEmailTestInbox') && WELCOME_EMAIL_SOURCES.includes(source);
let isPaidWelcomeEmailActive = false;
let paidWelcomeEmail = null;
if (shouldSendPaidWelcomeEmail && this._AutomatedEmail) {
paidWelcomeEmail = await this._AutomatedEmail.findOne({slug: MEMBER_WELCOME_EMAIL_SLUGS.paid}, options);
const paidWelcomeEmail = await this._AutomatedEmail.findOne({slug: MEMBER_WELCOME_EMAIL_SLUGS.paid}, options);
isPaidWelcomeEmailActive = paidWelcomeEmail && paidWelcomeEmail.get('lexical') && paidWelcomeEmail.get('status') === 'active';
}
// Use default template only when no DB row exists (not when inactive - respect explicit user choice)
const useDefaultPaidTemplate = !paidWelcomeEmail && shouldSendPaidWelcomeEmail;
// Send paid welcome email if:
// 1. The paid welcome email is active OR no DB row exists and test inbox is configured (uses default template)
// 1. The paid welcome email is active
// 2. The member status changed to 'paid'
if (updatedMember.get('status') === 'paid' && (isPaidWelcomeEmailActive || useDefaultPaidTemplate)) {
if (updatedMember.get('status') === 'paid' && isPaidWelcomeEmailActive) {
await this._Outbox.add({
id: ObjectId().toHexString(),
event_type: MemberCreatedEvent.name,
Expand Down
Loading
Loading