diff --git a/apps/admin-x-framework/src/api/automated-emails.ts b/apps/admin-x-framework/src/api/automated-emails.ts index 65551a94a0e..c8173687c38 100644 --- a/apps/admin-x-framework/src/api/automated-emails.ts +++ b/apps/admin-x-framework/src/api/automated-emails.ts @@ -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({ @@ -49,6 +57,34 @@ export const useEditAutomatedEmail = createMutation({ + method: 'PUT', + path: () => '/automated_emails/senders/', + body: payload => payload, + updateQueries: { + dataType, + emberUpdateType: 'createOrUpdate', + update: updateQueryCache('automated_emails') + } +}); + +export const useVerifyAutomatedEmailSender = createMutation({ + method: 'PUT', + path: () => '/automated_emails/verifications/', + body: ({token}) => ({token}), + updateQueries: { + dataType, + emberUpdateType: 'createOrUpdate', + update: updateQueryCache('automated_emails') + } +}); + export const useSendTestWelcomeEmail = createMutation({ method: 'POST', path: ({id}) => `/automated_emails/${id}/test/`, diff --git a/apps/admin-x-settings/src/components/settings/email-design/email-preview.tsx b/apps/admin-x-settings/src/components/settings/email-design/email-preview.tsx index f3d01fce6d0..f22673d074a 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/email-preview.tsx +++ b/apps/admin-x-settings/src/components/settings/email-design/email-preview.tsx @@ -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; @@ -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 (
- {senderName && ( -
- {senderName} - {senderEmail && <{senderEmail}>} + {senderDisplay && ( +
+ From: {senderDisplay}
)} - {senderEmail && ( + {showRecipientLine && senderEmail && (
To: subscriber@example.com
)} - {subject && ( + {resolvedReplyToEmail && ( +
+ Reply-to: {resolvedReplyToEmail} +
+ )} + {showSubjectLine && subject && (
{subject}
)}
@@ -98,7 +115,7 @@ const Footer: React.FC<{siteTitle?: string; footerLinkText?: string; emailFooter // --- Main component --- -const EmailPreview: React.FC = ({settings, senderName, senderEmail, subject, headerImage, showPublicationTitle = true, showBadge = true, emailFooter, footerLinkText, children}) => { +const EmailPreview: React.FC = ({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(globalSettings, ['title']); const accentColor = siteData.accent_color; @@ -108,7 +125,7 @@ const EmailPreview: React.FC = ({settings, senderName, sender return (
- +
= ({keywords}) => { const hasDesignCustomization = useFeatureFlag('welcomeEmailsDesignCustomization'); const {settings, config} = useGlobalData(); const [siteTitle] = getSettingValues(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}); }; + const submittedTokenRef = useRef(null); + + useEffect(() => { + if (!verifyEmailToken || !window.location.href.includes('memberemails')) { + return; + } + + if (submittedTokenRef.current === verifyEmailToken) { + return; + } + submittedTokenRef.current = verifyEmailToken; + + 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) { @@ -210,8 +250,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; @@ -233,8 +272,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 ? (
@@ -170,62 +204,72 @@ export const DesignTab: React.FC = () => ( interface SidebarProps { generalSettings: GeneralSettings; onGeneralChange: (updates: Partial) => void; - siteTitle: string | undefined; - emailDomain: string; + senderNamePlaceholder: string; + senderEmailPlaceholder: string; + replyToEmailPlaceholder: string; + showSenderEmailInput: boolean; + senderNameError?: string; + senderEmailError?: string; + replyToEmailError?: string; isLoading: boolean; errorMessage?: string; } -const Sidebar: React.FC = ({generalSettings, onGeneralChange, siteTitle, emailDomain, isLoading, errorMessage}) => { - let sidebarState: 'loading' | 'error' | 'content' = 'content'; - - if (isLoading) { - sidebarState = 'loading'; - } else if (errorMessage) { - sidebarState = 'error'; - } - - return ( - - - General - Design - - {sidebarState === 'loading' && ( -
- -
- )} - {sidebarState === 'error' && ( -
- {errorMessage} -
- )} - {sidebarState === 'content' && ( - <> - - - - - - - - )} -
- ); -}; +const Sidebar: React.FC = ({ + generalSettings, + onGeneralChange, + senderNamePlaceholder, + senderEmailPlaceholder, + replyToEmailPlaceholder, + showSenderEmailInput, + senderNameError, + senderEmailError, + replyToEmailError, + isLoading, + errorMessage +}) => ( + + + General + Design + + {isLoading ? ( +
+ +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : ( + <> + + + + + + + + )} +
+); /** * Maps API response fields to the frontend GeneralSettings shape. - * Note: senderName and replyToEmail come from site-level settings, not the design endpoint. + * Note: senderName, senderEmail and replyToEmail are not part of the design endpoint. * * @param {Pick} apiData - Subset of design fields used for general settings - * @param {GeneralSettings} defaults - Carries forward senderName and replyToEmail, which are not part of the design API + * @param {GeneralSettings} defaults - Carries forward sender fields, which are not part of the design API * @returns {GeneralSettings} General settings populated from the API response */ function mapApiToGeneralSettings( @@ -234,6 +278,7 @@ function mapApiToGeneralSettings( ): GeneralSettings { return { senderName: defaults.senderName, + senderEmail: defaults.senderEmail, replyToEmail: defaults.replyToEmail, headerImage: apiData.header_image || '', showPublicationTitle: apiData.show_header_title, @@ -283,25 +328,65 @@ const ErrorState: React.FC<{message: string}> = ({message}) => (
); +const normalizeSenderValue = (value: string | null | undefined) => { + const trimmed = value?.trim() || ''; + return trimmed || null; +}; + const WelcomeEmailCustomizeModal = NiceModal.create(() => { const modal = useModal(); - const {siteData, settings: globalSettings, config} = useGlobalData(); - const [siteTitle, defaultEmailAddress, supportEmailAddress] = getSettingValues(globalSettings, ['title', 'default_email_address', 'support_email_address']); + const {siteData, settings: globalSettings} = useGlobalData(); + const [siteTitle, defaultEmailAddress] = getSettingValues(globalSettings, ['title', 'default_email_address']); const handleError = useHandleError(); const {data: designData, isLoading, isError} = useReadAutomatedEmailDesign(); + const {data: automatedEmailsData} = useBrowseAutomatedEmails(); const {mutateAsync: editDesign} = useEditAutomatedEmailDesign(); + const {mutateAsync: addAutomatedEmail} = useAddAutomatedEmail(); + const {mutateAsync: editAutomatedEmailSenders} = useEditAutomatedEmailSenders(); const [hasSaveError, setHasSaveError] = useState(false); + const [senderInputsHydrated, setSenderInputsHydrated] = useState(false); + const automatedEmails = automatedEmailsData?.automated_emails || []; + + const { + senderNameInput, + senderEmailInput, + replyToEmailInput, + senderNamePlaceholder, + senderEmailPlaceholder, + replyToEmailPlaceholder, + showSenderEmailInput, + senderEmailDomain + } = useWelcomeEmailSenderDetails(automatedEmails); const defaultGeneralSettings = useMemo(() => ({ - senderName: siteTitle || '', - replyToEmail: supportEmailAddress || defaultEmailAddress || '', + senderName: senderNameInput, + senderEmail: senderEmailInput, + replyToEmail: replyToEmailInput, headerImage: '', showPublicationTitle: true, showBadge: true, emailFooter: '' - }), [defaultEmailAddress, siteTitle, supportEmailAddress]); - const {formState, saveState, updateForm, setFormState, handleSave, okProps} = useForm({ + }), [replyToEmailInput, senderEmailInput, senderNameInput]); + + const ensureWelcomeEmailRows = useCallback(async () => { + const existingBySlug = new Map((automatedEmailsData?.automated_emails || []).map(email => [email.slug, email])); + + for (const emailType of ['free', 'paid'] as WelcomeEmailType[]) { + if (existingBySlug.has(WELCOME_EMAIL_SLUGS[emailType])) { + continue; + } + + const defaults = getDefaultWelcomeEmailValues(emailType, siteTitle); + const created = await addAutomatedEmail({...defaults, status: 'inactive'}); + const createdEmail = created.automated_emails?.[0]; + if (createdEmail) { + existingBySlug.set(createdEmail.slug, createdEmail); + } + } + }, [addAutomatedEmail, automatedEmailsData?.automated_emails, siteTitle]); + + const {formState, saveState, updateForm, setFormState, handleSave, okProps, errors} = useForm({ initialState: { designSettings: {...DEFAULT_EMAIL_DESIGN}, generalSettings: defaultGeneralSettings @@ -316,7 +401,22 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { throw new Error('Unable to load email design settings'); } + await ensureWelcomeEmailRows(); + const senderPayload = { + sender_name: normalizeSenderValue(state.generalSettings.senderName), + sender_reply_to: normalizeSenderValue(state.generalSettings.replyToEmail), + ...(showSenderEmailInput ? { + sender_email: normalizeSenderValue(state.generalSettings.senderEmail) + } : {}) + }; + + const {meta: {sent_email_verification: sentEmailVerification = []} = {}} = await editAutomatedEmailSenders(senderPayload); + await editDesign(buildAutomatedEmailDesignPayload(state)); + + if (sentEmailVerification.length > 0) { + toast.info('We\u2019ve sent a confirmation email to the new address.'); + } setHasSaveError(false); toast.dismiss(SAVE_ERROR_TOAST_ID); }, @@ -326,6 +426,25 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { id: SAVE_ERROR_TOAST_ID }); setHasSaveError(true); + }, + onValidate: (state) => { + const validationErrors: Record = {}; + const senderEmail = state.generalSettings.senderEmail?.trim(); + const replyToEmail = state.generalSettings.replyToEmail?.trim(); + + if (showSenderEmailInput && senderEmail) { + if (!validator.isEmail(senderEmail)) { + validationErrors.senderEmail = 'Enter a valid email address'; + } else if (senderEmailDomain && senderEmail.split('@')[1]?.toLowerCase() !== senderEmailDomain.toLowerCase()) { + validationErrors.senderEmail = `Email address must end with @${senderEmailDomain}`; + } + } + + if (replyToEmail && !validator.isEmail(replyToEmail)) { + validationErrors.replyToEmail = 'Enter a valid email address'; + } + + return validationErrors; } }); const [hydratedDesignVersion, setHydratedDesignVersion] = useState(null); @@ -336,13 +455,30 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { // Hydrate local state from API data on initial load only useEffect(() => { if (design && hydratedDesignVersion === null) { - setFormState(() => ({ + setFormState(state => ({ designSettings: mapApiToDesignSettings(design), - generalSettings: mapApiToGeneralSettings(design, defaultGeneralSettings) + generalSettings: mapApiToGeneralSettings(design, state.generalSettings) })); setHydratedDesignVersion(designVersion); } - }, [defaultGeneralSettings, design, designVersion, hydratedDesignVersion, setFormState]); + }, [design, designVersion, hydratedDesignVersion, setFormState]); + + useEffect(() => { + if (senderInputsHydrated || automatedEmailsData === undefined) { + return; + } + + setFormState(state => ({ + ...state, + generalSettings: { + ...state.generalSettings, + senderName: senderNameInput, + senderEmail: senderEmailInput, + replyToEmail: replyToEmailInput + } + })); + setSenderInputsHydrated(true); + }, [automatedEmailsData, replyToEmailInput, senderEmailInput, senderInputsHydrated, senderNameInput, setFormState]); const handleDesignChange = useCallback((updates: Partial) => { setHasSaveError(false); @@ -366,7 +502,6 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { modal.hide(); }, [modal]); - const emailDomain = (config?.emailDomain as string) || defaultEmailAddress?.split('@')[1] || ''; const fetchErrorMessage = 'Unable to load email design settings. Please try again.'; const modalOkProps = hasSaveError ? { ...okProps, @@ -392,23 +527,31 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { emailFooter={generalSettings.emailFooter} footerLinkText="Manage your preferences" headerImage={generalSettings.headerImage} - senderEmail={defaultEmailAddress || `noreply@${emailDomain}`} - senderName={generalSettings.senderName || siteTitle || 'Your site'} + replyToEmail={generalSettings.replyToEmail || replyToEmailPlaceholder || ''} + senderEmail={generalSettings.senderEmail || senderEmailPlaceholder || defaultEmailAddress || ''} + senderName={generalSettings.senderName || senderNamePlaceholder || siteTitle || 'Your site'} settings={designSettings} showBadge={generalSettings.showBadge} showPublicationTitle={generalSettings.showPublicationTitle} - subject={`Welcome to ${generalSettings.senderName || siteTitle || 'our publication'}`} + showRecipientLine={false} + showSubjectLine={false} + subject={`Welcome to ${generalSettings.senderName || senderNamePlaceholder || siteTitle || 'our publication'}`} > )} sidebar={ } diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx index 82cc8aa4585..1c7f96ef719 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx @@ -10,7 +10,7 @@ import {useWelcomeEmailSenderDetails} from '../../../../hooks/use-welcome-email- import TestEmailDropdown from './test-email-dropdown'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useEditAutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; +import {useBrowseAutomatedEmails, useEditAutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; import {useGlobalData} from '../../../../components/providers/global-data-provider'; import {useRouting} from '@tryghost/admin-x-framework/routing'; import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; @@ -114,6 +114,7 @@ const WelcomeEmailModal = NiceModal.create(({emailType = const modal = useModal(); const {updateRoute} = useRouting(); const {mutateAsync: editAutomatedEmail} = useEditAutomatedEmail(); + const {data: automatedEmailsData} = useBrowseAutomatedEmails(); const [showTestDropdown, setShowTestDropdown] = useState(false); const dropdownRef = useRef(null); const normalizedLexical = useRef(automatedEmail?.lexical || ''); @@ -121,7 +122,8 @@ const WelcomeEmailModal = NiceModal.create(({emailType = const handleError = useHandleError(); const {settings} = useGlobalData(); const [siteTitle] = getSettingValues(settings, ['title']); - const {resolvedSenderName, resolvedSenderEmail, resolvedReplyToEmail, hasDistinctReplyTo} = useWelcomeEmailSenderDetails(automatedEmail); + const automatedEmails = automatedEmailsData?.automated_emails || []; + const {resolvedSenderName, resolvedSenderEmail, resolvedReplyToEmail, hasDistinctReplyTo} = useWelcomeEmailSenderDetails(automatedEmails); const emailTypeLabel = emailType === 'paid' ? 'Paid' : 'Free'; const modalTitle = `${emailTypeLabel} members welcome email`; diff --git a/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts b/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts index 96679011410..da44d709f0f 100644 --- a/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts +++ b/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts @@ -1,15 +1,13 @@ import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {renderReplyToEmail, renderSenderEmail} from '../utils/newsletter-emails'; +import {resolveWelcomeEmailSenderDetails} from '../utils/welcome-email-sender-details'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useGlobalData} from '../components/providers/global-data-provider'; import {useMemo} from 'react'; import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; -type AutomatedEmailSenderFields = Pick | null | undefined; +type AutomatedEmailSenderFields = Pick; -const trimValue = (value: string | null | undefined) => value?.trim() || ''; - -export const useWelcomeEmailSenderDetails = (automatedEmail: AutomatedEmailSenderFields) => { +export const useWelcomeEmailSenderDetails = (automatedEmails: AutomatedEmailSenderFields[] = []) => { const {settings, config} = useGlobalData(); const [siteTitle, defaultEmailAddress, supportEmailAddress] = getSettingValues(settings, ['title', 'default_email_address', 'support_email_address']); const {data: newslettersData} = useBrowseNewsletters({ @@ -20,35 +18,12 @@ export const useWelcomeEmailSenderDetails = (automatedEmail: AutomatedEmailSende }); const defaultNewsletter = newslettersData?.newsletters?.[0]; - return useMemo(() => { - const automatedSenderName = trimValue(automatedEmail?.sender_name); - const automatedSenderEmail = trimValue(automatedEmail?.sender_email); - const automatedSenderReplyTo = trimValue(automatedEmail?.sender_reply_to); - - const defaultNewsletterSenderName = trimValue(defaultNewsletter?.sender_name); - const defaultNewsletterSenderEmail = defaultNewsletter ? trimValue(renderSenderEmail(defaultNewsletter, config, defaultEmailAddress)) : ''; - const defaultNewsletterReplyTo = defaultNewsletter ? trimValue(renderReplyToEmail(defaultNewsletter, config, supportEmailAddress, defaultEmailAddress)) : ''; - - const resolvedSenderName = automatedSenderName || defaultNewsletterSenderName || trimValue(siteTitle) || 'Your Site'; - const resolvedSenderEmail = automatedSenderEmail || defaultNewsletterSenderEmail || trimValue(defaultEmailAddress) || ''; - const resolvedReplyToEmail = automatedSenderReplyTo || defaultNewsletterReplyTo || ''; - const hasDistinctReplyTo = resolvedReplyToEmail !== '' && resolvedReplyToEmail !== resolvedSenderEmail; - - return { - resolvedSenderName, - resolvedSenderEmail, - resolvedReplyToEmail, - defaultNewsletterSenderName, - hasDistinctReplyTo - }; - }, [ - automatedEmail?.sender_email, - automatedEmail?.sender_name, - automatedEmail?.sender_reply_to, + return useMemo(() => resolveWelcomeEmailSenderDetails({ + automatedEmails, config, defaultEmailAddress, - defaultNewsletter, + newsletter: defaultNewsletter, siteTitle, supportEmailAddress - ]); + }), [automatedEmails, config, defaultEmailAddress, defaultNewsletter, siteTitle, supportEmailAddress]); }; diff --git a/apps/admin-x-settings/src/utils/newsletter-emails.ts b/apps/admin-x-settings/src/utils/newsletter-emails.ts index 331b07d9c9b..8725cd5d57e 100644 --- a/apps/admin-x-settings/src/utils/newsletter-emails.ts +++ b/apps/admin-x-settings/src/utils/newsletter-emails.ts @@ -1,7 +1,7 @@ import {type Config, hasSendingDomain, isManagedEmail} from '@tryghost/admin-x-framework/api/config'; import {type Newsletter} from '@tryghost/admin-x-framework/api/newsletters'; -export const renderSenderEmail = (newsletter: Newsletter, config: Config, defaultEmailAddress: string|undefined) => { +export const renderSenderEmail = (newsletter: Pick, config: Config, defaultEmailAddress: string|undefined) => { if (isManagedEmail(config) && !hasSendingDomain(config) && defaultEmailAddress) { // Not changeable: sender_email is ignored return defaultEmailAddress; @@ -10,7 +10,7 @@ export const renderSenderEmail = (newsletter: Newsletter, config: Config, defaul return newsletter.sender_email || defaultEmailAddress || ''; }; -export const renderReplyToEmail = (newsletter: Newsletter, config: Config, supportEmailAddress: string|undefined, defaultEmailAddress: string|undefined) => { +export const renderReplyToEmail = (newsletter: Pick, config: Config, supportEmailAddress: string|undefined, defaultEmailAddress: string|undefined) => { if (newsletter.sender_reply_to === 'newsletter') { if (isManagedEmail(config)) { // No reply-to set @@ -26,3 +26,16 @@ export const renderReplyToEmail = (newsletter: Newsletter, config: Config, suppo return newsletter.sender_reply_to; }; + +export const renderReplyToEmailPlaceholder = (newsletter: Pick, config: Config, supportEmailAddress: string|undefined, defaultEmailAddress: string|undefined) => { + const replyTo = renderReplyToEmail(newsletter, config, supportEmailAddress, defaultEmailAddress); + if (replyTo) { + return replyTo; + } + + if (newsletter.sender_reply_to === 'newsletter') { + return renderSenderEmail(newsletter, config, defaultEmailAddress) || supportEmailAddress || defaultEmailAddress || ''; + } + + return supportEmailAddress || defaultEmailAddress || ''; +}; diff --git a/apps/admin-x-settings/src/utils/welcome-email-sender-details.ts b/apps/admin-x-settings/src/utils/welcome-email-sender-details.ts new file mode 100644 index 00000000000..fc4e12bc904 --- /dev/null +++ b/apps/admin-x-settings/src/utils/welcome-email-sender-details.ts @@ -0,0 +1,71 @@ +import {type Config, hasSendingDomain, isManagedEmail, sendingDomain} from '@tryghost/admin-x-framework/api/config'; +import {WELCOME_EMAIL_SLUGS} from '../components/settings/membership/member-emails/default-welcome-email-values'; +import {renderReplyToEmailPlaceholder, renderSenderEmail} from './newsletter-emails'; +import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; +import type {Newsletter} from '@tryghost/admin-x-framework/api/newsletters'; + +type AutomatedEmailSenderFields = Pick; +type NewsletterSenderFields = Pick; + +export interface ResolveWelcomeEmailSenderDetailsInput { + automatedEmails?: AutomatedEmailSenderFields[]; + config: Config; + defaultEmailAddress?: string | null; + newsletter?: NewsletterSenderFields | null; + siteTitle?: string | null; + supportEmailAddress?: string | null; +} + +const trimValue = (value: string | null | undefined) => value?.trim() || ''; + +const firstNonEmpty = (...values: Array) => { + const normalized = values.map(trimValue); + return normalized.find(Boolean) || ''; +}; + +export const resolveWelcomeEmailSenderDetails = ({ + automatedEmails = [], + config, + defaultEmailAddress, + newsletter, + siteTitle, + supportEmailAddress +}: ResolveWelcomeEmailSenderDetailsInput) => { + const freeEmail = automatedEmails.find(email => email.slug === WELCOME_EMAIL_SLUGS.free); + const paidEmail = automatedEmails.find(email => email.slug === WELCOME_EMAIL_SLUGS.paid); + + const senderNameInput = firstNonEmpty(freeEmail?.sender_name, paidEmail?.sender_name); + const senderEmailInput = firstNonEmpty(freeEmail?.sender_email, paidEmail?.sender_email); + const replyToEmailInput = firstNonEmpty(freeEmail?.sender_reply_to, paidEmail?.sender_reply_to); + + const defaultNewsletterSenderName = trimValue(newsletter?.sender_name); + const defaultNewsletterSenderEmail = newsletter ? trimValue(renderSenderEmail(newsletter, config, defaultEmailAddress || undefined)) : ''; + const defaultNewsletterReplyTo = newsletter ? trimValue(renderReplyToEmailPlaceholder(newsletter, config, supportEmailAddress || undefined, defaultEmailAddress || undefined)) : ''; + + const senderNamePlaceholder = defaultNewsletterSenderName || trimValue(siteTitle) || 'Your site name'; + const senderEmailPlaceholder = defaultNewsletterSenderEmail || trimValue(defaultEmailAddress); + const replyToEmailPlaceholder = defaultNewsletterReplyTo || trimValue(supportEmailAddress) || trimValue(defaultEmailAddress); + + const resolvedSenderName = senderNameInput || senderNamePlaceholder || 'Your Site'; + const resolvedSenderEmail = senderEmailInput || senderEmailPlaceholder || ''; + const resolvedReplyToEmail = replyToEmailInput || replyToEmailPlaceholder || ''; + const hasDistinctReplyTo = resolvedReplyToEmail !== '' && resolvedReplyToEmail !== resolvedSenderEmail; + + const managedEmail = isManagedEmail(config); + const hasManagedSendingDomain = hasSendingDomain(config); + + return { + hasDistinctReplyTo, + replyToEmailInput, + replyToEmailPlaceholder, + resolvedReplyToEmail, + resolvedSenderEmail, + resolvedSenderName, + senderEmailDomain: hasManagedSendingDomain ? sendingDomain(config) : null, + senderEmailInput, + senderEmailPlaceholder, + senderNameInput, + senderNamePlaceholder, + showSenderEmailInput: !managedEmail || hasManagedSendingDomain + }; +}; diff --git a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts index 71ae48e5e12..b3507c14911 100644 --- a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts @@ -45,6 +45,56 @@ const configWithTenorEnabled = { } }; +const configWithWelcomeEmailCustomization = { + ...responseFixtures.config, + config: { + ...responseFixtures.config.config, + labs: { + ...responseFixtures.config.config.labs, + welcomeEmailsDesignCustomization: true + } + } +}; + +const managedEmailConfigWithoutSendingDomain = { + ...configWithWelcomeEmailCustomization, + config: { + ...configWithWelcomeEmailCustomization.config, + hostSettings: { + ...configWithWelcomeEmailCustomization.config.hostSettings, + managedEmail: { + enabled: true + } + } + } +}; + +const automatedEmailDesignFixture = { + automated_email_design: [{ + id: 'default-automated-email-design', + slug: 'default-automated-email', + background_color: 'light', + header_background_color: 'transparent', + header_image: null, + show_header_title: true, + footer_content: null, + button_color: null, + button_corners: 'square', + button_style: 'fill', + link_color: null, + link_style: 'accent', + body_font_category: 'sans_serif', + title_font_category: 'sans_serif', + title_font_weight: 'bold', + image_corners: 'square', + divider_color: null, + section_title_color: null, + show_badge: true, + created_at: '2024-01-01T00:00:00.000Z', + updated_at: null + }] +}; + const pasteText = async (page: Page, content: string) => { await page.evaluate((text: string) => { const dataTransfer = new DataTransfer(); @@ -579,6 +629,232 @@ test.describe('Member emails settings', async () => { }); }); + test.describe('Welcome email customize modal sender fields', async () => { + test('uses placeholders when no automated sender overrides exist', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + ...newslettersRequest, + browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailCustomization}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}, + readAutomatedEmailDesign: {method: 'GET', path: '/automated_emails/design/', response: automatedEmailDesignFixture} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByRole('button', {name: 'Customize'}).click(); + + const modal = page.getByTestId('welcome-email-customize-modal'); + await expect(modal).toBeVisible(); + + const senderNameInput = modal.getByLabel('Sender name'); + const senderEmailInput = modal.getByLabel('Sender email'); + const replyToInput = modal.getByLabel('Reply-to email'); + + await expect(senderNameInput).toHaveValue(''); + await expect(senderEmailInput).toHaveValue(''); + await expect(replyToInput).toHaveValue(''); + + await expect(senderNameInput).toHaveAttribute('placeholder', 'Sender'); + await expect(senderEmailInput).toHaveAttribute('placeholder', 'default@example.com'); + await expect(replyToInput).toHaveAttribute('placeholder', 'support@example.com'); + }); + + test('uses sender email placeholder when newsletter reply-to is newsletter', async ({page}) => { + const newsletterReplyToNewsletterResponse = { + newsletters: [{ + ...responseFixtures.newsletters.newsletters[0], + sender_email: 'test@example.com', + sender_reply_to: 'newsletter' + }], + meta: responseFixtures.newsletters.meta + }; + + await mockApi({page, requests: { + ...globalDataRequests, + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: newsletterReplyToNewsletterResponse}, + browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailCustomization}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}, + readAutomatedEmailDesign: {method: 'GET', path: '/automated_emails/design/', response: automatedEmailDesignFixture} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByRole('button', {name: 'Customize'}).click(); + + const modal = page.getByTestId('welcome-email-customize-modal'); + await expect(modal).toBeVisible(); + + const senderEmailInput = modal.getByLabel('Sender email'); + const replyToInput = modal.getByLabel('Reply-to email'); + + await expect(senderEmailInput).toHaveAttribute('placeholder', 'test@example.com'); + await expect(replyToInput).toHaveAttribute('placeholder', 'test@example.com'); + await expect(modal.getByText(/Reply-to:\s*test@example\.com/)).toBeVisible(); + }); + + test('uses explicit newsletter reply-to as placeholder when set', async ({page}) => { + const newsletterCustomReplyToResponse = { + newsletters: [{ + ...responseFixtures.newsletters.newsletters[0], + sender_email: 'test@example.com', + sender_reply_to: 'custom-reply@example.com' + }], + meta: responseFixtures.newsletters.meta + }; + + await mockApi({page, requests: { + ...globalDataRequests, + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: newsletterCustomReplyToResponse}, + browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailCustomization}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}, + readAutomatedEmailDesign: {method: 'GET', path: '/automated_emails/design/', response: automatedEmailDesignFixture} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByRole('button', {name: 'Customize'}).click(); + + const modal = page.getByTestId('welcome-email-customize-modal'); + await expect(modal).toBeVisible(); + + const replyToInput = modal.getByLabel('Reply-to email'); + await expect(replyToInput).toHaveAttribute('placeholder', 'custom-reply@example.com'); + await expect(modal.getByText(/Reply-to:\s*custom-reply@example\.com/)).toBeVisible(); + }); + + test('hides sender email field when managed email has no sending domain', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + ...newslettersRequest, + browseConfig: {method: 'GET', path: '/config/', response: managedEmailConfigWithoutSendingDomain}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}, + readAutomatedEmailDesign: {method: 'GET', path: '/automated_emails/design/', response: automatedEmailDesignFixture} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByRole('button', {name: 'Customize'}).click(); + + const modal = page.getByTestId('welcome-email-customize-modal'); + await expect(modal).toBeVisible(); + await expect(modal.getByLabel('Sender email')).toHaveCount(0); + }); + + test('saves shared sender settings and creates missing welcome-email rows', async ({page}) => { + const addPaidResponse = { + automated_emails: [{ + id: 'paid-welcome-email-id', + status: 'inactive', + name: 'Welcome Email (Paid)', + slug: 'member-welcome-email-paid', + subject: 'Welcome to your paid subscription', + lexical: '{"root":{"children":[]}}', + sender_name: null, + sender_email: null, + sender_reply_to: null, + created_at: '2024-01-01T00:00:00.000Z', + updated_at: null + }] + }; + + const {lastApiRequests} = await mockApi({page, requests: { + ...globalDataRequests, + ...newslettersRequest, + browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailCustomization}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}, + readAutomatedEmailDesign: {method: 'GET', path: '/automated_emails/design/', response: automatedEmailDesignFixture}, + editAutomatedEmailDesign: {method: 'PUT', path: '/automated_emails/design/', response: automatedEmailDesignFixture}, + addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: addPaidResponse}, + editAutomatedEmailSenders: { + method: 'PUT', + path: /^\/automated_emails\/senders\/?$/, + response: { + automated_emails: [ + { + ...automatedEmailsFixture.automated_emails[0], + sender_name: 'Shared sender', + sender_email: 'shared@example.com', + sender_reply_to: 'shared-reply@example.com' + }, + { + ...addPaidResponse.automated_emails[0], + sender_name: 'Shared sender', + sender_email: 'shared@example.com', + sender_reply_to: 'shared-reply@example.com' + } + ] + } + } + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByRole('button', {name: 'Customize'}).click(); + + const modal = page.getByTestId('welcome-email-customize-modal'); + await expect(modal).toBeVisible(); + + await modal.getByLabel('Sender name').fill('Shared sender'); + await modal.getByLabel('Sender email').fill('shared@example.com'); + await modal.getByLabel('Reply-to email').fill('shared-reply@example.com'); + await modal.getByRole('button', {name: 'Save'}).click(); + + await expect.poll(() => lastApiRequests.addAutomatedEmail?.body).toMatchObject({ + automated_emails: [{ + slug: 'member-welcome-email-paid', + status: 'inactive' + }] + }); + await expect.poll(() => lastApiRequests.editAutomatedEmailSenders?.body).toEqual({ + sender_name: 'Shared sender', + sender_email: 'shared@example.com', + sender_reply_to: 'shared-reply@example.com' + }); + }); + }); + + test('shows verification confirmation for memberemails verifyEmail token', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + ...newslettersRequest, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}, + verifyAutomatedEmailSenders: { + method: 'PUT', + path: /^\/automated_emails\/verifications\/?$/, + response: { + automated_emails: automatedEmailsFixture.automated_emails, + meta: { + email_verified: 'sender_reply_to' + } + } + } + }}); + + await page.goto('/#/memberemails?verifyEmail=test-verification-token'); + await page.waitForLoadState('networkidle'); + + const confirmation = page.getByTestId('confirmation-modal'); + await expect(confirmation).toBeVisible(); + await expect(confirmation).toContainText('Reply-to address verified'); + await expect(page).toHaveURL(/#\/memberemails$/); + }); + // NY-842: Tests for editing/viewing welcome emails before activation test.describe('Email preview visibility and edit-before-activation', async () => { test('Email preview card is visible with default subject when no DB row exists', async ({page}) => { diff --git a/apps/admin-x-settings/test/unit/utils/welcome-email-sender-details.test.ts b/apps/admin-x-settings/test/unit/utils/welcome-email-sender-details.test.ts new file mode 100644 index 00000000000..e2f28e70252 --- /dev/null +++ b/apps/admin-x-settings/test/unit/utils/welcome-email-sender-details.test.ts @@ -0,0 +1,125 @@ +import * as assert from 'assert/strict'; +import {resolveWelcomeEmailSenderDetails} from '@src/utils/welcome-email-sender-details'; +import type {Config} from '@tryghost/admin-x-framework/api/config'; + +const config = { + version: '1.0.0', + environment: 'development', + editor: {url: '', version: ''}, + signupForm: {url: '', version: ''}, + enableDeveloperExperiments: false, + database: 'sqlite', + labs: {}, + stripeDirect: false, + mail: '' +} as Config; + +describe('resolveWelcomeEmailSenderDetails', function () { + it('prefills from free welcome email before paid', function () { + const result = resolveWelcomeEmailSenderDetails({ + automatedEmails: [ + { + slug: 'member-welcome-email-paid', + sender_name: 'Paid Sender', + sender_email: 'paid@example.com', + sender_reply_to: 'paid-reply@example.com' + }, + { + slug: 'member-welcome-email-free', + sender_name: 'Free Sender', + sender_email: 'free@example.com', + sender_reply_to: 'free-reply@example.com' + } + ], + config, + defaultEmailAddress: 'default@example.com', + newsletter: { + sender_name: 'Newsletter Sender', + sender_email: null, + sender_reply_to: 'support' + }, + siteTitle: 'My Site', + supportEmailAddress: 'support@example.com' + }); + + assert.equal(result.senderNameInput, 'Free Sender'); + assert.equal(result.senderEmailInput, 'free@example.com'); + assert.equal(result.replyToEmailInput, 'free-reply@example.com'); + }); + + it('uses support address placeholder when newsletter reply-to is support', function () { + const result = resolveWelcomeEmailSenderDetails({ + automatedEmails: [], + config, + defaultEmailAddress: 'default@example.com', + newsletter: { + sender_name: 'Sender', + sender_email: 'test@example.com', + sender_reply_to: 'support' + }, + siteTitle: 'My Site', + supportEmailAddress: 'support@example.com' + }); + + assert.equal(result.replyToEmailPlaceholder, 'support@example.com'); + }); + + it('uses sender-email placeholder when newsletter reply-to is newsletter', function () { + const result = resolveWelcomeEmailSenderDetails({ + automatedEmails: [], + config, + defaultEmailAddress: 'default@example.com', + newsletter: { + sender_name: 'Sender', + sender_email: 'test@example.com', + sender_reply_to: 'newsletter' + }, + siteTitle: 'My Site', + supportEmailAddress: 'noreply@example.com' + }); + + assert.equal(result.senderEmailPlaceholder, 'test@example.com'); + assert.equal(result.replyToEmailPlaceholder, 'test@example.com'); + }); + + it('uses explicit newsletter reply-to placeholder when set', function () { + const result = resolveWelcomeEmailSenderDetails({ + automatedEmails: [], + config, + defaultEmailAddress: 'default@example.com', + newsletter: { + sender_name: 'Sender', + sender_email: 'test@example.com', + sender_reply_to: 'custom-reply@example.com' + }, + siteTitle: 'My Site', + supportEmailAddress: 'support@example.com' + }); + + assert.equal(result.replyToEmailPlaceholder, 'custom-reply@example.com'); + }); + + it('keeps automated reply-to prefill over placeholder', function () { + const result = resolveWelcomeEmailSenderDetails({ + automatedEmails: [{ + slug: 'member-welcome-email-free', + sender_name: null, + sender_email: null, + sender_reply_to: 'prefilled-reply@example.com' + }], + config, + defaultEmailAddress: 'default@example.com', + newsletter: { + sender_name: 'Sender', + sender_email: 'test@example.com', + sender_reply_to: 'support' + }, + siteTitle: 'My Site', + supportEmailAddress: 'support@example.com' + }); + + assert.equal(result.replyToEmailInput, 'prefilled-reply@example.com'); + assert.equal(result.resolvedReplyToEmail, 'prefilled-reply@example.com'); + }); +}); + diff --git a/ghost/core/core/server/api/endpoints/automated-emails.js b/ghost/core/core/server/api/endpoints/automated-emails.js index 81d75fe2372..e058cc6f86a 100644 --- a/ghost/core/core/server/api/endpoints/automated-emails.js +++ b/ghost/core/core/server/api/endpoints/automated-emails.js @@ -92,6 +92,40 @@ const controller = { } }, + editSenders: { + headers: { + cacheInvalidate: false + }, + permissions: { + method: 'edit' + }, + async query(frame) { + memberWelcomeEmailService.init(); + const data = frame.data; + + return memberWelcomeEmailService.api.editSharedSenderOptions({ + sender_name: data.sender_name, + sender_email: data.sender_email, + sender_reply_to: data.sender_reply_to + }); + } + }, + + verifySenderUpdate: { + headers: { + cacheInvalidate: false + }, + permissions: { + method: 'edit' + }, + data: [ + 'token' + ], + async query(frame) { + memberWelcomeEmailService.init(); + return memberWelcomeEmailService.api.verifySenderPropertyUpdate(frame.data.token); + } + }, sendTestEmail: { statusCode: 204, headers: { diff --git a/ghost/core/core/server/api/endpoints/utils/validators/input/automated_emails.js b/ghost/core/core/server/api/endpoints/utils/validators/input/automated_emails.js index 19712654cc9..81bce58b1f7 100644 --- a/ghost/core/core/server/api/endpoints/utils/validators/input/automated_emails.js +++ b/ghost/core/core/server/api/endpoints/utils/validators/input/automated_emails.js @@ -16,7 +16,8 @@ const messages = { invalidName: `Name must be one of: ${ALLOWED_NAMES.join(', ')}`, invalidEmailReceived: 'The server did not receive a valid email', subjectRequired: 'Subject is required', - lexicalRequired: 'Email content is required' + lexicalRequired: 'Email content is required', + tokenRequired: 'Token is required' }; const validateAutomatedEmail = async function (frame) { @@ -61,6 +62,14 @@ const validateAutomatedEmail = async function (frame) { return Promise.resolve(); }; +const validateOptionalStringField = (value, errorMessage) => { + if (value !== undefined && value !== null && typeof value !== 'string') { + throw new ValidationError({ + message: errorMessage + }); + } +}; + module.exports = { async add(apiConfig, frame) { await validateAutomatedEmail(frame); @@ -68,6 +77,22 @@ module.exports = { async edit(apiConfig, frame) { await validateAutomatedEmail(frame); }, + editSenders(apiConfig, frame) { + const senderName = frame.data.sender_name; + const senderEmail = frame.data.sender_email; + const senderReplyTo = frame.data.sender_reply_to; + + validateOptionalStringField(senderName, 'Sender name must be a string'); + validateOptionalStringField(senderEmail, 'Sender email must be a string'); + validateOptionalStringField(senderReplyTo, 'Reply-to email must be a string'); + }, + verifySenderUpdate(apiConfig, frame) { + if (typeof frame.data.token !== 'string' || !frame.data.token.trim()) { + throw new ValidationError({ + message: tpl(messages.tokenRequired) + }); + } + }, sendTestEmail(apiConfig, frame) { const email = frame.data.email; const subject = frame.data.subject; 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 2c0db536095..01620afd28d 100644 --- a/ghost/core/core/server/services/member-welcome-emails/service.js +++ b/ghost/core/core/server/services/member-welcome-emails/service.js @@ -3,6 +3,9 @@ const errors = require('@tryghost/errors'); const labs = require('../../../shared/labs'); const urlUtils = require('../../../shared/url-utils'); const settingsCache = require('../../../shared/settings-cache'); +const verifyEmailTemplate = require('../newsletters/emails/verify-email'); +const MagicLink = require('../lib/magic-link/magic-link'); +const sentry = require('../../../shared/sentry'); const emailAddressService = require('../email-address'); const settingsHelpers = require('../settings-helpers'); const EmailAddressParser = require('../email-address/email-address-parser'); @@ -12,16 +15,67 @@ const {AutomatedEmail, 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'); +const VERIFIED_SENDER_PROPERTIES = ['sender_reply_to']; +const WELCOME_EMAIL_FILTER = `slug:${MEMBER_WELCOME_EMAIL_SLUGS.free},slug:${MEMBER_WELCOME_EMAIL_SLUGS.paid}`; +const SHARED_SENDER_FIELDS = ['sender_name', 'sender_email', 'sender_reply_to']; +const EMAIL_VALIDATION_TYPE_BY_FIELD = { + sender_email: 'from', + sender_reply_to: 'replyTo' +}; + +const trimValue = value => value?.trim() || ''; + class MemberWelcomeEmailService { #mailer; #renderer; + #magicLinkService; #memberWelcomeEmails = {free: null, paid: null}; #defaultNewsletterSenderOptions = null; - constructor({t}) { + constructor({t, singleUseTokenProvider}) { emailAddressService.init(); this.#mailer = new mail.GhostMailer(); this.#renderer = new MemberWelcomeEmailRenderer({t}); + + const getSigninURL = (token) => { + const adminUrl = urlUtils.urlFor('admin', true); + const signinURL = new URL(adminUrl); + signinURL.hash = `/settings/memberemails?verifyEmail=${token}`; + return signinURL.href; + }; + + this.#magicLinkService = new MagicLink({ + transporter: { + sendMail() { + // noop - overridden in `#sendEmailVerificationMagicLink` + } + }, + tokenProvider: singleUseTokenProvider, + getSigninURL, + getText(url, type, email) { + return ` + Hey there, + + Please confirm your email address with this link: + + ${url} + + For your security, the link will expire in 24 hours time. + + --- + + Sent to ${email} + If you did not make this request, you can simply delete this message. This email address will not be used. + `; + }, + getHTML(url, type, email) { + return verifyEmailTemplate({url, email}); + }, + getSubject() { + return 'Verify email address'; + }, + sentry + }); } #getSiteSettings() { @@ -98,6 +152,175 @@ class MemberWelcomeEmailService { return labs.isSet('welcomeEmailsDesignCustomization'); } + async #getEffectiveSenderOptions(automatedSender = {}) { + const defaultOptions = await this.#getSenderOptions(); + const defaultFrom = EmailAddressParser.parse(defaultOptions.from || '') || emailAddressService.service.defaultFromEmail; + const defaultReplyTo = defaultOptions.replyTo ? EmailAddressParser.parse(defaultOptions.replyTo) : undefined; + + const senderName = trimValue(automatedSender.senderName) || defaultFrom?.name || undefined; + const senderEmail = trimValue(automatedSender.senderEmail) || defaultFrom.address; + const senderReplyTo = trimValue(automatedSender.senderReplyTo); + + const addresses = emailAddressService.service.getAddress({ + from: { + address: senderEmail, + ...(senderName ? {name: senderName} : {}) + }, + replyTo: senderReplyTo ? {address: senderReplyTo} : defaultReplyTo + }); + + return { + from: EmailAddressParser.stringify(addresses.from), + ...(addresses.replyTo ? { + replyTo: EmailAddressParser.stringify(addresses.replyTo) + } : {}) + }; + } + + async #loadWelcomeEmailsCollection() { + return AutomatedEmail.findAll({ + filter: WELCOME_EMAIL_FILTER + }); + } + + async #loadWelcomeEmailsMap({requireAll = false} = {}) { + const rows = await this.#loadWelcomeEmailsCollection(); + const bySlug = new Map(rows.models.map(model => [model.get('slug'), model])); + + const free = bySlug.get(MEMBER_WELCOME_EMAIL_SLUGS.free); + const paid = bySlug.get(MEMBER_WELCOME_EMAIL_SLUGS.paid); + + if (requireAll && (!free || !paid)) { + throw new errors.NotFoundError({ + message: MESSAGES.NO_MEMBER_WELCOME_EMAIL + }); + } + + return {free, paid}; + } + + async #loadRequiredWelcomeEmailRows() { + const {free, paid} = await this.#loadWelcomeEmailsMap({requireAll: true}); + return [free, paid]; + } + + #normalizeSharedSenderValue(value) { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; + } + + return value; + } + + #normalizeSharedSenderAttrs(attrs = {}) { + const normalized = {}; + + for (const field of SHARED_SENDER_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(attrs, field)) { + continue; + } + + normalized[field] = this.#normalizeSharedSenderValue(attrs[field]); + } + + return normalized; + } + + #hasSharedSenderFieldChanged(rows, field, value) { + return rows.some((row) => { + const currentValue = row.get(field); + return trimValue(currentValue) !== trimValue(value); + }); + } + + #validateSharedSenderField(field, value) { + const validationType = EMAIL_VALIDATION_TYPE_BY_FIELD[field]; + + if (!validationType || !value) { + return { + requiresVerification: false + }; + } + + const validated = emailAddressService.service.validate(value, validationType); + if (!validated.allowed) { + throw new errors.ValidationError({ + message: `You cannot set ${field} to ${value}` + }); + } + + return { + requiresVerification: validated.verificationEmailRequired + }; + } + + #prepareSharedSenderUpdate(rows, attrs = {}) { + const normalizedAttrs = this.#normalizeSharedSenderAttrs(attrs); + const attrsToPersist = {}; + const emailsToVerify = []; + + for (const [field, value] of Object.entries(normalizedAttrs)) { + if (!this.#hasSharedSenderFieldChanged(rows, field, value)) { + continue; + } + + const {requiresVerification} = this.#validateSharedSenderField(field, value); + if (requiresVerification) { + emailsToVerify.push({property: field, email: value}); + continue; + } + + attrsToPersist[field] = value; + } + + return { + attrsToPersist, + emailsToVerify + }; + } + + async #applySharedSenderAttrs(rows, attrs = {}) { + if (Object.keys(attrs).length === 0) { + return; + } + + await Promise.all(rows.map(row => AutomatedEmail.edit(attrs, {id: row.id}))); + } + + async #sendSharedSenderVerifications(emailsToVerify = []) { + for (const {property, email} of emailsToVerify) { + await this.#sendEmailVerificationMagicLink({property, email}); + } + } + + async #sendEmailVerificationMagicLink({email, property}) { + const fromEmail = emailAddressService.service.defaultFromEmail; + + this.#magicLinkService.transporter = { + sendMail: (message) => { + if (process.env.NODE_ENV !== 'production') { + logging.warn(message.text); + } + + return this.#mailer.send({ + from: fromEmail, + subject: 'Verify email address', + forceTextContent: true, + ...message + }); + } + }; + + return this.#magicLinkService.sendMagicLink({ + email, + tokenData: { + property, + value: email + } + }); + } + async loadMemberWelcomeEmails() { this.#defaultNewsletterSenderOptions = await this.#getDefaultNewsletterSenderOptions(); @@ -166,7 +389,7 @@ class MemberWelcomeEmailService { siteSettings: this.#getSiteSettings() }); - const senderOptions = await this.#getSenderOptions(); + const senderOptions = await this.#getEffectiveSenderOptions(memberWelcomeEmail); await this.#mailer.send({ to: member.email, @@ -230,8 +453,13 @@ class MemberWelcomeEmailService { siteSettings: this.#getSiteSettings() }); - // Test sends should always reflect the latest newsletter sender settings. - const senderOptions = await this.#getDefaultNewsletterSenderOptions(); + // Test sends should always reflect latest newsletter fallback values. + this.#defaultNewsletterSenderOptions = await this.#getDefaultNewsletterSenderOptions(); + const senderOptions = await this.#getEffectiveSenderOptions({ + senderName: automatedEmail.get('sender_name'), + senderEmail: automatedEmail.get('sender_email'), + senderReplyTo: automatedEmail.get('sender_reply_to') + }); await this.#mailer.send({ to: email, @@ -242,6 +470,52 @@ class MemberWelcomeEmailService { ...senderOptions }); } + + async editSharedSenderOptions(attrs = {}) { + const rows = await this.#loadRequiredWelcomeEmailRows(); + const {attrsToPersist, emailsToVerify} = this.#prepareSharedSenderUpdate(rows, attrs); + + await this.#applySharedSenderAttrs(rows, attrsToPersist); + await this.#sendSharedSenderVerifications(emailsToVerify); + + const response = await this.#loadWelcomeEmailsCollection(); + if (emailsToVerify.length > 0) { + response.meta = response.meta || {}; + response.meta.sent_email_verification = emailsToVerify.map(({property}) => property); + } + + return { + data: response.models, + meta: response.meta + }; + } + + async verifySenderPropertyUpdate(token) { + const data = await this.#magicLinkService.getDataFromToken(token); + const {property, value} = data; + + if (!VERIFIED_SENDER_PROPERTIES.includes(property)) { + throw new errors.IncorrectUsageError({ + message: 'Not allowed to update this sender setting via token' + }); + } + + const rows = await this.#loadRequiredWelcomeEmailRows(); + const normalizedValue = this.#normalizeSharedSenderValue(value); + const attrs = { + [property]: normalizedValue + }; + + await this.#applySharedSenderAttrs(rows, attrs); + + const response = await this.#loadWelcomeEmailsCollection(); + response.meta = response.meta || {}; + response.meta.email_verified = property; + return { + data: response.models, + meta: response.meta + }; + } } class MemberWelcomeEmailServiceWrapper { @@ -263,8 +537,19 @@ class MemberWelcomeEmailServiceWrapper { }); } + const SingleUseTokenProvider = require('../members/single-use-token-provider'); + const models = require('../../models'); + this.useDesignCustomization = useDesignCustomization; - this.api = new MemberWelcomeEmailService({t: this.i18n.t}); + this.api = new MemberWelcomeEmailService({ + t: this.i18n.t, + singleUseTokenProvider: new SingleUseTokenProvider({ + SingleUseTokenModel: models.SingleUseToken, + validityPeriod: 24 * 60 * 60 * 1000, + validityPeriodAfterUsage: 10 * 60 * 1000, + maxUsageCount: 7 + }) + }); } } diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 52a56951bf9..dab31db3c18 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -185,6 +185,8 @@ module.exports = function apiRoutes() { // ## Automated Emails router.get('/automated_emails', mw.authAdminApi, http(api.automatedEmails.browse)); router.get('/automated_emails/design', mw.authAdminApi, http(api.automatedEmailDesign.read)); + router.put('/automated_emails/senders', mw.authAdminApi, http(api.automatedEmails.editSenders)); + router.put('/automated_emails/verifications', mw.authAdminApi, http(api.automatedEmails.verifySenderUpdate)); router.get('/automated_emails/:id', mw.authAdminApi, http(api.automatedEmails.read)); router.post('/automated_emails', mw.authAdminApi, http(api.automatedEmails.add)); router.put('/automated_emails/design', mw.authAdminApi, http(api.automatedEmailDesign.edit)); 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 d04562940f4..05cbec410db 100644 --- a/ghost/core/test/e2e-api/admin/automated-emails.test.js +++ b/ghost/core/test/e2e-api/admin/automated-emails.test.js @@ -1,8 +1,11 @@ const {agentProvider, fixtureManager, matchers, dbUtils} = require('../../utils/e2e-framework'); const {anyContentVersion, anyObjectId, anyISODateTime, anyErrorId, anyEtag, anyLocationFor} = matchers; +const assert = require('node:assert/strict'); const sinon = require('sinon'); const logging = require('@tryghost/logging'); const mailService = require('../../../core/server/services/mail'); +const SingleUseTokenProvider = require('../../../core/server/services/members/single-use-token-provider'); +const models = require('../../../core/server/models'); const matchAutomatedEmail = { id: anyObjectId, @@ -411,6 +414,60 @@ describe('Automated Emails API', function () { }); }); + describe('Shared sender settings', function () { + const createSenderVerificationToken = async (property, value) => { + return (new SingleUseTokenProvider({ + SingleUseTokenModel: models.SingleUseToken, + validityPeriod: 24 * 60 * 60 * 1000, + validityPeriodAfterUsage: 10 * 60 * 1000, + maxUsageCount: 1 + })).create({property, value}); + }; + + beforeEach(async function () { + await createAutomatedEmail(); + await createAutomatedEmail({ + name: 'Welcome Email (Paid)', + slug: 'member-welcome-email-paid', + subject: 'Welcome paid member' + }); + }); + + it('Can edit sender settings for free and paid welcome emails', async function () { + await agent + .put('automated_emails/senders/') + .body({ + sender_name: 'Custom Sender', + sender_email: 'sender@example.com', + sender_reply_to: 'reply@example.com' + }) + .expectStatus(200) + .expect(({body}) => { + assert.equal(body.automated_emails.length, 2); + for (const automatedEmail of body.automated_emails) { + assert.equal(automatedEmail.sender_name, 'Custom Sender'); + assert.equal(automatedEmail.sender_email, 'sender@example.com'); + assert.equal(automatedEmail.sender_reply_to, 'reply@example.com'); + } + }); + }); + + it('Can verify pending sender update with token', async function () { + const token = await createSenderVerificationToken('sender_reply_to', 'verified-reply@example.com'); + + await agent + .put('automated_emails/verifications/') + .body({token}) + .expectStatus(200) + .expect(({body}) => { + assert.equal(body.meta.email_verified, 'sender_reply_to'); + assert.equal(body.automated_emails.length, 2); + for (const automatedEmail of body.automated_emails) { + assert.equal(automatedEmail.sender_reply_to, 'verified-reply@example.com'); + } + }); + }); + }); describe('SendTestEmail', function () { let automatedEmailId; 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 4c74323b9da..1e13f561794 100644 --- a/ghost/core/test/integration/services/member-welcome-emails.test.js +++ b/ghost/core/test/integration/services/member-welcome-emails.test.js @@ -410,6 +410,45 @@ describe('Member Welcome Emails Integration', function () { assert.ok(sendCall.args[0].from.includes(senderEmail)); }); + it('uses automated email sender overrides when configured', async function () { + const defaultNewsletter = await models.Newsletter.getDefaultNewsletter(); + + await db.knex('newsletters') + .where('id', defaultNewsletter.id) + .update({ + sender_name: 'Newsletter Sender', + sender_email: 'newsletter@example.com', + sender_reply_to: 'newsletter-reply@example.com' + }); + + await db.knex('automated_emails') + .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) + .update({ + sender_name: 'Automation Sender', + sender_email: 'automation@example.com', + sender_reply_to: 'automation-reply@example.com' + }); + + await models.Outbox.add({ + event_type: 'MemberCreatedEvent', + payload: JSON.stringify({ + memberId: ObjectId().toHexString(), + uuid: '88888888-8888-4888-8888-888888888888', + email: 'automation-sender-test@example.com', + name: 'Automation Sender Test', + status: 'free' + }), + status: OUTBOX_STATUSES.PENDING + }); + + await scheduleInlineJob(); + + sinon.assert.calledOnce(mailService.GhostMailer.prototype.send); + const sendCall = mailService.GhostMailer.prototype.send.firstCall; + assert.ok(sendCall.args[0].from.includes('automation@example.com')); + assert.equal(sendCall.args[0].replyTo, 'automation-reply@example.com'); + }); + it('uses mock member UUID when sending test welcome emails', async function () { const automatedEmail = await db.knex('automated_emails') .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) @@ -445,6 +484,46 @@ describe('Member Welcome Emails Integration', function () { assert(!sendCall.args[0].html.includes('{uuid}')); assert(!sendCall.args[0].html.includes('%7Buuid%7D')); }); + + it('uses automated sender overrides for test welcome emails', async function () { + memberWelcomeEmailService.init(); + + const automatedEmail = await db.knex('automated_emails') + .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) + .first(); + + await db.knex('automated_emails') + .where('id', automatedEmail.id) + .update({ + sender_name: 'Automation Sender', + sender_email: 'automation@example.com', + sender_reply_to: 'automation-reply@example.com' + }); + + await memberWelcomeEmailService.api.sendTestEmail({ + email: 'test-member@example.com', + subject: 'Welcome test', + lexical: JSON.stringify({ + root: { + children: [{ + type: 'paragraph', + children: [{type: 'text', text: 'Hello'}] + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }), + automatedEmailId: automatedEmail.id + }); + + sinon.assert.calledOnce(mailService.GhostMailer.prototype.send); + const sendCall = mailService.GhostMailer.prototype.send.firstCall; + assert.ok(sendCall.args[0].from.includes('automation@example.com')); + assert.equal(sendCall.args[0].replyTo, 'automation-reply@example.com'); + }); }); describe('labs flag on', function () {