Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
36 changes: 36 additions & 0 deletions apps/admin-x-framework/src/api/automated-emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export interface AutomatedEmailsResponseType {
automated_emails: AutomatedEmail[];
}

export interface AutomatedEmailsEditSendersResponseType extends AutomatedEmailsResponseType {
meta?: Meta & {sent_email_verification: string[]};
}

export interface AutomatedEmailsVerifyResponseType extends AutomatedEmailsResponseType {
meta?: Meta & {email_verified: string};
}

const dataType = 'AutomatedEmailsResponseType';

export const useBrowseAutomatedEmails = createQuery<AutomatedEmailsResponseType>({
Expand Down Expand Up @@ -49,6 +57,34 @@ export const useEditAutomatedEmail = createMutation<AutomatedEmailsResponseType,
}
});

type EditAutomatedEmailSendersPayload = {
sender_name?: string | null;
sender_email?: string | null;
sender_reply_to?: string | null;
};

export const useEditAutomatedEmailSenders = createMutation<AutomatedEmailsEditSendersResponseType, EditAutomatedEmailSendersPayload>({
method: 'PUT',
path: () => '/automated_emails/senders/',
body: payload => payload,
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: updateQueryCache('automated_emails')
}
});

export const useVerifyAutomatedEmailSender = createMutation<AutomatedEmailsVerifyResponseType, {token: string}>({
method: 'PUT',
path: () => '/automated_emails/verifications/',
body: ({token}) => ({token}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: updateQueryCache('automated_emails')
}
});

export const useSendTestWelcomeEmail = createMutation<unknown, {id: string; email: string; subject: string; lexical: string}>({
method: 'POST',
path: ({id}) => `/automated_emails/${id}/test/`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ interface EmailPreviewProps {
settings: EmailDesignSettings;
senderName?: string;
senderEmail?: string;
replyToEmail?: string;
subject?: string;
showRecipientLine?: boolean;
showSubjectLine?: boolean;
headerImage?: string;
showPublicationTitle?: boolean;
showBadge?: boolean;
Expand All @@ -21,25 +24,39 @@ interface EmailPreviewProps {

// --- Sub-components ---

const EnvelopeHeader: React.FC<{senderName?: string; senderEmail?: string; subject?: string}> = ({senderName, senderEmail, subject}) => {
if (!senderName && !senderEmail && !subject) {
const EnvelopeHeader: React.FC<{
senderName?: string;
senderEmail?: string;
replyToEmail?: string;
subject?: string;
showRecipientLine?: boolean;
showSubjectLine?: boolean;
}> = ({senderName, senderEmail, replyToEmail, subject, showRecipientLine = true, showSubjectLine = true}) => {
const resolvedReplyToEmail = replyToEmail || senderEmail;
const senderDisplay = senderName && senderEmail ? `${senderName} (${senderEmail})` : (senderName || senderEmail);

if (!senderDisplay && !resolvedReplyToEmail && (!showSubjectLine || !subject)) {
return null;
}

return (
<div className="flex flex-col justify-center gap-1 border-b border-grey-200 bg-white p-6 text-sm text-grey-700">
{senderName && (
<div className="flex gap-2">
<span className="font-semibold text-grey-900">{senderName}</span>
{senderEmail && <span>&lt;{senderEmail}&gt;</span>}
{senderDisplay && (
<div>
<span className="font-semibold text-grey-900">From:</span> {senderDisplay}
</div>
)}
{senderEmail && (
{showRecipientLine && senderEmail && (
<div>
<span className="font-semibold text-grey-900">To:</span> subscriber@example.com
</div>
)}
{subject && (
{resolvedReplyToEmail && (
<div>
<span className="font-semibold text-grey-900">Reply-to:</span> {resolvedReplyToEmail}
</div>
)}
{showSubjectLine && subject && (
<div className="text-base font-medium text-grey-900">{subject}</div>
)}
</div>
Expand Down Expand Up @@ -98,7 +115,7 @@ const Footer: React.FC<{siteTitle?: string; footerLinkText?: string; emailFooter

// --- Main component ---

const EmailPreview: React.FC<EmailPreviewProps> = ({settings, senderName, senderEmail, subject, headerImage, showPublicationTitle = true, showBadge = true, emailFooter, footerLinkText, children}) => {
const EmailPreview: React.FC<EmailPreviewProps> = ({settings, senderName, senderEmail, replyToEmail, subject, showRecipientLine = true, showSubjectLine = true, headerImage, showPublicationTitle = true, showBadge = true, emailFooter, footerLinkText, children}) => {
const {settings: globalSettings, siteData} = useGlobalData();
const [siteTitle] = getSettingValues<string>(globalSettings, ['title']);
const accentColor = siteData.accent_color;
Expand All @@ -108,7 +125,7 @@ const EmailPreview: React.FC<EmailPreviewProps> = ({settings, senderName, sender

return (
<div className="mx-auto flex max-h-full min-h-0 w-full max-w-[700px] flex-col overflow-hidden rounded-[4px] text-black shadow-sm">
<EnvelopeHeader senderEmail={senderEmail} senderName={senderName} subject={subject} />
<EnvelopeHeader replyToEmail={replyToEmail} senderEmail={senderEmail} senderName={senderName} showRecipientLine={showRecipientLine} showSubjectLine={showSubjectLine} subject={subject} />

<div
className="min-h-0 w-full flex-1 overflow-y-auto text-sm"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import React, {useEffect} from 'react';
import TopLevelGroup from '../../top-level-group';
import WelcomeEmailCustomizeModal from './member-emails/welcome-email-customize-modal';
import WelcomeEmailModal from './member-emails/welcome-email-modal';
import useFeatureFlag from '../../../hooks/use-feature-flag';
import {Button, Icon, Table, TableRow, Toggle, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system';
import useQueryParams from '../../../hooks/use-query-params';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {Button, ConfirmationModal, Icon, Table, TableRow, Toggle, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {WELCOME_EMAIL_SLUGS, type WelcomeEmailType, getDefaultWelcomeEmailRecord, getDefaultWelcomeEmailValues} from './member-emails/default-welcome-email-values';
import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails';
import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmail, useVerifyAutomatedEmailSender} from '@tryghost/admin-x-framework/api/automated-emails';
import {useGlobalData} from '../../providers/global-data-provider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails';

// Default welcome email content in Lexical JSON format
// Uses __GHOST_URL__ placeholder which Ghost replaces with the actual site URL
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 EmailPreviewRow: React.FC<{
automatedEmail: AutomatedEmail,
emailType: 'free' | 'paid',
Expand Down Expand Up @@ -139,53 +136,89 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => {
const hasDesignCustomization = useFeatureFlag('welcomeEmailsDesignCustomization');
const {settings, config} = useGlobalData();
const [siteTitle] = getSettingValues<string>(settings, ['title']);
const verifyEmailToken = useQueryParams().getParam('verifyEmail');

const {data: automatedEmailsData, isLoading} = useBrowseAutomatedEmails();
const {mutateAsync: addAutomatedEmail, isLoading: isAddingAutomatedEmail} = useAddAutomatedEmail();
const {mutateAsync: editAutomatedEmail, isLoading: isEditingAutomatedEmail} = useEditAutomatedEmail();
const {mutateAsync: verifySenderUpdate} = useVerifyAutomatedEmailSender();
const handleError = useHandleError();

const automatedEmails = automatedEmailsData?.automated_emails || [];
const isMutating = isAddingAutomatedEmail || isEditingAutomatedEmail;
const isBusy = isLoading || isMutating;

const freeWelcomeEmail = automatedEmails.find(email => email.slug === 'member-welcome-email-free');
const paidWelcomeEmail = automatedEmails.find(email => email.slug === 'member-welcome-email-paid');
const freeWelcomeEmail = automatedEmails.find(email => email.slug === WELCOME_EMAIL_SLUGS.free);
const paidWelcomeEmail = automatedEmails.find(email => email.slug === WELCOME_EMAIL_SLUGS.paid);

const freeWelcomeEmailEnabled = freeWelcomeEmail?.status === 'active';
const paidWelcomeEmailEnabled = paidWelcomeEmail?.status === 'active';

// Helper to get default values for an email type
const getDefaultEmailValues = (emailType: 'free' | 'paid') => ({
name: emailType === 'free' ? 'Welcome Email (Free)' : 'Welcome Email (Paid)',
slug: `member-welcome-email-${emailType}`,
subject: emailType === 'free'
? `Welcome to ${siteTitle || 'our site'}`
: 'Welcome to your paid subscription',
lexical: emailType === 'free' ? DEFAULT_FREE_LEXICAL_CONTENT : DEFAULT_PAID_LEXICAL_CONTENT
});

// Create default email objects for display when no DB row exists
const getDefaultEmail = (emailType: 'free' | 'paid'): AutomatedEmail => ({
id: '',
status: 'inactive',
...getDefaultEmailValues(emailType),
sender_name: null,
sender_email: null,
sender_reply_to: null,
created_at: '',
updated_at: null
});

// Create a new automated email row with the given status
const createAutomatedEmail = async (emailType: 'free' | 'paid', status: 'active' | 'inactive') => {
const defaults = getDefaultEmailValues(emailType);
const createAutomatedEmail = async (emailType: WelcomeEmailType, status: 'active' | 'inactive') => {
const defaults = getDefaultWelcomeEmailValues(emailType, siteTitle);
return addAutomatedEmail({...defaults, status});
};

useEffect(() => {
if (!verifyEmailToken || !window.location.href.includes('memberemails')) {
return;
}

const clearVerifyEmailFromRoute = () => {
const hash = window.location.hash.slice(1);
const url = new URL(hash || '/memberemails', window.location.origin);
url.searchParams.delete('verifyEmail');

const nextHash = url.search ? `#${url.pathname}${url.search}` : `#${url.pathname}`;
window.history.replaceState(null, '', `${window.location.pathname}${nextHash}`);
};

const verify = async () => {
try {
const {meta: {email_verified: emailVerified} = {}} = await verifySenderUpdate({token: verifyEmailToken});
clearVerifyEmailFromRoute();

let title = 'Sender email verified';
let prompt = <>Welcome email sender settings have been updated.</>;

if (emailVerified === 'sender_reply_to') {
title = 'Reply-to address verified';
prompt = <>Welcome email reply-to address has been verified and updated.</>;
}

NiceModal.show(ConfirmationModal, {
title,
prompt,
okLabel: 'Close',
cancelLabel: '',
onOk: confirmModal => confirmModal?.remove()
});
} catch (e) {
let prompt = 'There was an error verifying your email address. Try again later.';

if (e instanceof APIError && e.message === 'Token expired') {
prompt = 'Verification link has expired.';
}

clearVerifyEmailFromRoute();

NiceModal.show(ConfirmationModal, {
title: 'Error verifying email address',
prompt,
okLabel: 'Close',
cancelLabel: '',
onOk: confirmModal => confirmModal?.remove()
});
handleError(e, {withToast: false});
}
};

verify();
}, [handleError, verifyEmailToken, verifySenderUpdate]);

const handleToggle = async (emailType: 'free' | 'paid') => {
const slug = `member-welcome-email-${emailType}`;
const existing = automatedEmails.find(email => email.slug === slug);
const existing = automatedEmails.find(email => email.slug === WELCOME_EMAIL_SLUGS[emailType]);
const label = emailType === 'free' ? 'Free members' : 'Paid members';

if (isBusy) {
Expand All @@ -210,8 +243,7 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => {

// Handle Edit button click - creates inactive row if needed, then opens modal
const handleEditClick = async (emailType: 'free' | 'paid') => {
const slug = `member-welcome-email-${emailType}`;
const existing = automatedEmails.find(email => email.slug === slug);
const existing = automatedEmails.find(email => email.slug === WELCOME_EMAIL_SLUGS[emailType]);

if (isBusy) {
return;
Expand All @@ -233,8 +265,8 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => {
};

// Get email to display (existing or default for preview)
const freeEmailForDisplay = freeWelcomeEmail || getDefaultEmail('free');
const paidEmailForDisplay = paidWelcomeEmail || getDefaultEmail('paid');
const freeEmailForDisplay = freeWelcomeEmail || getDefaultWelcomeEmailRecord('free', siteTitle);
const paidEmailForDisplay = paidWelcomeEmail || getDefaultWelcomeEmailRecord('paid', siteTitle);
const customizeButton = hasDesignCustomization ? (
<Button
className='mt-[-5px]'
Expand Down
Loading
Loading