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..eb86db0c0e8 100644 --- a/ghost/core/core/server/api/endpoints/automated-emails.js +++ b/ghost/core/core/server/api/endpoints/automated-emails.js @@ -1,3 +1,4 @@ +const _ = require('lodash'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const models = require('../../models'); @@ -7,6 +8,32 @@ const messages = { automatedEmailNotFound: 'Automated email not found.' }; +// NOTE: This file is in a transitionary state. The `automated_emails` database table was split into +// `welcome_email_automations` (automation metadata: status, name, slug) and +// `welcome_email_automated_emails` (email content: subject, lexical, sender fields). This controller +// acts as a facade that joins/splits data between those two models while preserving the original +// `automated_emails` API shape externally. +const AUTOMATION_FIELDS = ['status', 'name', 'slug']; +const EMAIL_FIELDS = ['subject', 'lexical', 'sender_name', 'sender_email', 'sender_reply_to', 'email_design_setting_id']; + +function flattenAutomation(automation, email = automation.related('welcomeEmailAutomatedEmail')) { + const result = { + id: automation.id, + status: automation.get('status'), + name: automation.get('name'), + slug: automation.get('slug'), + subject: email.get('subject'), + lexical: email.get('lexical'), + sender_name: email.get('sender_name'), + sender_email: email.get('sender_email'), + sender_reply_to: email.get('sender_reply_to'), + email_design_setting_id: email.get('email_design_setting_id'), + created_at: automation.get('created_at'), + updated_at: automation.get('updated_at') + }; + return result; +} + /** @type {import('@tryghost/api-framework').Controller} */ const controller = { docName: 'automated_emails', @@ -23,8 +50,15 @@ const controller = { 'page' ], permissions: true, - query(frame) { - return models.AutomatedEmail.findPage(frame.options); + async query(frame) { + const result = await models.WelcomeEmailAutomation.findPage({ + ...frame.options, + withRelated: ['welcomeEmailAutomatedEmail'] + }); + return { + ...result, + data: result.data.map(automation => flattenAutomation(automation)) + }; } }, @@ -41,14 +75,17 @@ const controller = { ], permissions: true, async query(frame) { - const model = await models.AutomatedEmail.findOne(frame.data, frame.options); + const model = await models.WelcomeEmailAutomation.findOne(frame.data, { + ...frame.options, + withRelated: ['welcomeEmailAutomatedEmail'] + }); if (!model) { throw new errors.NotFoundError({ message: tpl(messages.automatedEmailNotFound) }); } - return model; + return flattenAutomation(model); } }, @@ -60,7 +97,22 @@ const controller = { permissions: true, async query(frame) { const data = frame.data.automated_emails[0]; - return models.AutomatedEmail.add(data, frame.options); + + const emailData = _.pick(data, EMAIL_FIELDS); + const automationData = _.pick(data, AUTOMATION_FIELDS); + + return models.Base.transaction(async (transacting) => { + const automation = await models.WelcomeEmailAutomation.add(automationData, {...frame.options, transacting}); + const email = await models.WelcomeEmailAutomatedEmail.add( + { + ...emailData, + welcome_email_automation_id: automation.id, + delay_days: 0 + }, + {...frame.options, transacting} + ); + return flattenAutomation(automation, email); + }); } }, @@ -79,19 +131,86 @@ const controller = { } }, permissions: true, + // eslint-disable-next-line ghost/ghost-custom/max-api-complexity async query(frame) { const data = frame.data.automated_emails[0]; - const model = await models.AutomatedEmail.edit(data, frame.options); - if (!model) { - throw new errors.NotFoundError({ - message: tpl(messages.automatedEmailNotFound) + + const emailData = _.pick(data, EMAIL_FIELDS); + const automationData = _.pick(data, AUTOMATION_FIELDS); + + return models.Base.transaction(async (transacting) => { + let automation = await models.WelcomeEmailAutomation.findOne({id: frame.options.id}, { + transacting, + withRelated: ['welcomeEmailAutomatedEmail'] }); - } + if (!automation) { + throw new errors.NotFoundError({ + message: tpl(messages.automatedEmailNotFound) + }); + } + let email = automation.related('welcomeEmailAutomatedEmail'); + + if (Object.keys(emailData).length > 0) { + email = await models.WelcomeEmailAutomatedEmail.edit(emailData, { + ...frame.options, + transacting, + id: email.id + }); + } + + if (Object.keys(automationData).length > 0) { + automation = await models.WelcomeEmailAutomation.edit(automationData, { + ...frame.options, + transacting + }); + } + + return flattenAutomation(automation, email); + }); + } + }, - return model; + editSenders: { + headers: { + cacheInvalidate: false + }, + permissions: { + method: 'edit' + }, + async query(frame) { + memberWelcomeEmailService.init(); + const data = frame.data; + const result = await memberWelcomeEmailService.api.editSharedSenderOptions({ + sender_name: data.sender_name, + sender_email: data.sender_email, + sender_reply_to: data.sender_reply_to + }); + return { + ...result, + data: result.data.map(automation => flattenAutomation(automation)) + }; } }, + verifySenderUpdate: { + headers: { + cacheInvalidate: false + }, + permissions: { + method: 'edit' + }, + data: [ + 'token' + ], + async query(frame) { + memberWelcomeEmailService.init(); + const result = await memberWelcomeEmailService.api.verifySenderPropertyUpdate(frame.data.token); + return { + ...result, + data: result.data.map(automation => flattenAutomation(automation)) + }; + } + }, 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/data/migrations/versions/6.27/2026-04-06-15-55-20-split-automated-emails-into-welcome-email-tables.js b/ghost/core/core/server/data/migrations/versions/6.27/2026-04-06-15-55-20-split-automated-emails-into-welcome-email-tables.js new file mode 100644 index 00000000000..beb03cbf7c0 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.27/2026-04-06-15-55-20-split-automated-emails-into-welcome-email-tables.js @@ -0,0 +1,66 @@ +const logging = require('@tryghost/logging'); +const {createTransactionalMigration} = require('../../utils'); +const ObjectId = require('bson-objectid').default; + +module.exports = createTransactionalMigration( + async function up(knex) { + // The welcome_email_automations and welcome_email_automated_emails tables + // already exist from a prior dormant migration. This migration copies data + // from the old automated_emails table into them. + + const oldTableExists = await knex.schema.hasTable('automated_emails'); + if (!oldTableExists) { + logging.warn('Skipping data migration - automated_emails table does not exist'); + return; + } + + const rows = await knex('automated_emails').select('*'); + logging.info(`Migrating ${rows.length} rows from automated_emails to new tables`); + + // Only 2 rows exist (free + paid welcome emails), so sequential iteration is fine + // eslint-disable-next-line no-restricted-syntax + for (const row of rows) { + // Check if already migrated (idempotency) by looking for a matching slug + const existingAutomation = await knex('welcome_email_automations').where('slug', row.slug).first(); + if (existingAutomation) { + logging.warn(`Skipping row for slug ${row.slug} - already migrated`); + continue; + } + + const automationId = ObjectId().toHexString(); + + // Insert automation first (emails reference automations via FK) + await knex('welcome_email_automations').insert({ + id: automationId, + status: row.status, + name: row.name, + slug: row.slug, + created_at: row.created_at, + updated_at: row.updated_at + }); + + // Reuse the original automated_email id so the existing + // automated_email_recipients rows continue to reference the same id + await knex('welcome_email_automated_emails').insert({ + id: row.id, + welcome_email_automation_id: automationId, + delay_days: 0, + subject: row.subject, + lexical: row.lexical, + sender_name: row.sender_name, + sender_email: row.sender_email, + sender_reply_to: row.sender_reply_to, + email_design_setting_id: row.email_design_setting_id, + created_at: row.created_at, + updated_at: row.updated_at + }); + } + }, + + async function down(knex) { + // Remove migrated data from new tables + logging.info('Removing migrated data from new tables'); + await knex('welcome_email_automated_emails').del(); + await knex('welcome_email_automations').del(); + } +); diff --git a/ghost/core/core/server/data/migrations/versions/6.27/2026-04-07-15-10-32-update-automated-email-recipients-foreign-key.js b/ghost/core/core/server/data/migrations/versions/6.27/2026-04-07-15-10-32-update-automated-email-recipients-foreign-key.js new file mode 100644 index 00000000000..8515b9be367 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.27/2026-04-07-15-10-32-update-automated-email-recipients-foreign-key.js @@ -0,0 +1,79 @@ +const logging = require('@tryghost/logging'); +const {commands} = require('../../../schema'); +const {createTransactionalMigration} = require('../../utils'); + +module.exports = createTransactionalMigration( + async function up(knex) { + const recipientsTableExists = await knex.schema.hasTable('automated_email_recipients'); + if (!recipientsTableExists) { + logging.warn('Skipping foreign key migration - automated_email_recipients table does not exist'); + return; + } + + const oldTableExists = await knex.schema.hasTable('automated_emails'); + if (!oldTableExists) { + logging.warn('Skipping foreign key migration - automated_emails table does not exist'); + return; + } + + const newTableExists = await knex.schema.hasTable('welcome_email_automated_emails'); + if (!newTableExists) { + logging.warn('Skipping foreign key migration - welcome_email_automated_emails table does not exist'); + return; + } + + logging.info('Updating foreign key on automated_email_recipients'); + await commands.dropForeign({ + fromTable: 'automated_email_recipients', + fromColumn: 'automated_email_id', + toTable: 'automated_emails', + toColumn: 'id', + transaction: knex + }); + + await commands.addForeign({ + fromTable: 'automated_email_recipients', + fromColumn: 'automated_email_id', + toTable: 'welcome_email_automated_emails', + toColumn: 'id', + transaction: knex + }); + }, + + async function down(knex) { + const recipientsTableExists = await knex.schema.hasTable('automated_email_recipients'); + if (!recipientsTableExists) { + logging.warn('Skipping foreign key rollback - automated_email_recipients table does not exist'); + return; + } + + const oldTableExists = await knex.schema.hasTable('automated_emails'); + if (!oldTableExists) { + logging.warn('Skipping foreign key rollback - automated_emails table does not exist'); + return; + } + + const newTableExists = await knex.schema.hasTable('welcome_email_automated_emails'); + if (!newTableExists) { + logging.warn('Skipping foreign key rollback - welcome_email_automated_emails table does not exist'); + return; + } + + logging.info('Restoring foreign key on automated_email_recipients'); + await commands.dropForeign({ + fromTable: 'automated_email_recipients', + fromColumn: 'automated_email_id', + toTable: 'welcome_email_automated_emails', + toColumn: 'id', + transaction: knex + }); + + await commands.addForeign({ + fromTable: 'automated_email_recipients', + fromColumn: 'automated_email_id', + toTable: 'automated_emails', + toColumn: 'id', + transaction: knex + }); + } +); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 12623095ad8..52fb1189804 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -1209,7 +1209,7 @@ module.exports = { }, automated_email_recipients: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, - automated_email_id: {type: 'string', maxlength: 24, nullable: false, references: 'automated_emails.id'}, + automated_email_id: {type: 'string', maxlength: 24, nullable: false, references: 'welcome_email_automated_emails.id'}, member_id: {type: 'string', maxlength: 24, nullable: false, index: true}, member_uuid: {type: 'string', maxlength: 36, nullable: false}, member_email: {type: 'string', maxlength: 191, nullable: false}, diff --git a/ghost/core/core/server/models/automated-email-recipient.js b/ghost/core/core/server/models/automated-email-recipient.js index 75484514187..28121c60f6b 100644 --- a/ghost/core/core/server/models/automated-email-recipient.js +++ b/ghost/core/core/server/models/automated-email-recipient.js @@ -5,7 +5,7 @@ const AutomatedEmailRecipient = ghostBookshelf.Model.extend({ hasTimestamps: true, automatedEmail() { - return this.belongsTo('AutomatedEmail', 'automated_email_id'); + return this.belongsTo('WelcomeEmailAutomatedEmail', 'automated_email_id'); }, member() { return this.belongsTo('Member', 'member_id'); diff --git a/ghost/core/core/server/models/automated-email.js b/ghost/core/core/server/models/automated-email.js deleted file mode 100644 index 8a078ab2510..00000000000 --- a/ghost/core/core/server/models/automated-email.js +++ /dev/null @@ -1,101 +0,0 @@ -const ghostBookshelf = require('./base'); -const errors = require('@tryghost/errors'); -const logging = require('@tryghost/logging'); -const urlUtils = require('../../shared/url-utils'); -const lexicalLib = require('../lib/lexical'); -const {MEMBER_WELCOME_EMAIL_SLUGS, DEFAULT_EMAIL_DESIGN_SETTING_SLUG} = require('../services/member-welcome-emails/constants'); - -const MEMBER_WELCOME_EMAIL_SLUG_SET = new Set(Object.values(MEMBER_WELCOME_EMAIL_SLUGS)); - -const AutomatedEmail = ghostBookshelf.Model.extend({ - tableName: 'automated_emails', - - defaults() { - return { - status: 'inactive' - }; - }, - - /** - * @returns {import('bookshelf').Model} - */ - emailDesignSetting() { - return this.belongsTo('EmailDesignSetting', 'email_design_setting_id', 'id'); - }, - - parse() { - const attrs = ghostBookshelf.Model.prototype.parse.apply(this, arguments); - - // transform URLs from __GHOST_URL__ to absolute - if (attrs.lexical) { - attrs.lexical = urlUtils.transformReadyToAbsolute(attrs.lexical); - } - - return attrs; - }, - - async onCreating(model, attrs, options) { - if (!model.get('email_design_setting_id')) { - const emailDesignSetting = await ghostBookshelf.model('EmailDesignSetting').findOne({ - slug: DEFAULT_EMAIL_DESIGN_SETTING_SLUG - }, options); - - if (!emailDesignSetting) { - throw new errors.InternalServerError({ - message: 'Missing default email design setting for automated emails' - }); - } - - model.set('email_design_setting_id', emailDesignSetting.get('id')); - } - - return ghostBookshelf.Model.prototype.onCreating.call(this, model, attrs, options); - }, - - // Alternative to Bookshelf's .format() that is only called when writing to db - formatOnWrite(attrs) { - // Ensure lexical URLs are stored as transform-ready with __GHOST_URL__ representing config.url - if (attrs.lexical) { - attrs.lexical = urlUtils.lexicalToTransformReady(attrs.lexical, { - nodes: lexicalLib.nodes, - transformMap: lexicalLib.urlTransformMap - }); - } - - return attrs; - }, - - onSaved(model) { - if (!model?.id) { - return; - } - - const slug = model.get('slug'); - - if (!MEMBER_WELCOME_EMAIL_SLUG_SET.has(slug)) { - return; - } - - const previousStatus = model.previous('status'); - const currentStatus = model.get('status'); - const isNewModel = previousStatus === undefined; - const isEnableTransition = currentStatus === 'active' && (isNewModel || previousStatus === 'inactive'); - const isDisableTransition = previousStatus === 'active' && currentStatus === 'inactive'; - - if (!isEnableTransition && !isDisableTransition) { - return; - } - - logging.info({ - system: { - event: isEnableTransition ? 'welcome_email.enabled' : 'welcome_email.disabled', - automated_email_id: model.id, - slug - } - }, isEnableTransition ? 'Welcome email enabled' : 'Welcome email disabled'); - } -}); - -module.exports = { - AutomatedEmail: ghostBookshelf.model('AutomatedEmail', AutomatedEmail) -}; diff --git a/ghost/core/core/server/models/welcome-email-automated-email.js b/ghost/core/core/server/models/welcome-email-automated-email.js index 818cf29dd5b..c1d75e88a93 100644 --- a/ghost/core/core/server/models/welcome-email-automated-email.js +++ b/ghost/core/core/server/models/welcome-email-automated-email.js @@ -1,6 +1,8 @@ const ghostBookshelf = require('./base'); +const errors = require('@tryghost/errors'); const urlUtils = require('../../shared/url-utils'); const lexicalLib = require('../lib/lexical'); +const {DEFAULT_EMAIL_DESIGN_SETTING_SLUG} = require('../services/member-welcome-emails/constants'); const WelcomeEmailAutomatedEmail = ghostBookshelf.Model.extend({ tableName: 'welcome_email_automated_emails', @@ -31,6 +33,24 @@ const WelcomeEmailAutomatedEmail = ghostBookshelf.Model.extend({ return attrs; }, + async onCreating(model, attrs, options) { + if (!model.get('email_design_setting_id')) { + const emailDesignSetting = await ghostBookshelf.model('EmailDesignSetting').findOne({ + slug: DEFAULT_EMAIL_DESIGN_SETTING_SLUG + }, options); + + if (!emailDesignSetting) { + throw new errors.InternalServerError({ + message: 'Missing default email design setting for automated emails' + }); + } + + model.set('email_design_setting_id', emailDesignSetting.get('id')); + } + + return ghostBookshelf.Model.prototype.onCreating.call(this, model, attrs, options); + }, + // Alternative to Bookshelf's .format() that is only called when writing to db formatOnWrite(attrs) { // Ensure lexical URLs are stored as transform-ready with __GHOST_URL__ representing config.url diff --git a/ghost/core/core/server/models/welcome-email-automation.js b/ghost/core/core/server/models/welcome-email-automation.js index 1f63bee16cb..00d258f37f4 100644 --- a/ghost/core/core/server/models/welcome-email-automation.js +++ b/ghost/core/core/server/models/welcome-email-automation.js @@ -1,4 +1,8 @@ const ghostBookshelf = require('./base'); +const logging = require('@tryghost/logging'); +const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../services/member-welcome-emails/constants'); + +const MEMBER_WELCOME_EMAIL_SLUG_SET = new Set(Object.values(MEMBER_WELCOME_EMAIL_SLUGS)); const WelcomeEmailAutomation = ghostBookshelf.Model.extend({ tableName: 'welcome_email_automations', @@ -9,8 +13,42 @@ const WelcomeEmailAutomation = ghostBookshelf.Model.extend({ }; }, + welcomeEmailAutomatedEmail() { + return this.hasOne('WelcomeEmailAutomatedEmail', 'welcome_email_automation_id'); + }, + welcomeEmailAutomatedEmails() { return this.hasMany('WelcomeEmailAutomatedEmail', 'welcome_email_automation_id'); + }, + + onSaved(model) { + if (!model?.id) { + return; + } + + const slug = model.get('slug'); + + if (!MEMBER_WELCOME_EMAIL_SLUG_SET.has(slug)) { + return; + } + + const previousStatus = model.previous('status'); + const currentStatus = model.get('status'); + const isNewModel = previousStatus === undefined; + const isEnableTransition = currentStatus === 'active' && (isNewModel || previousStatus === 'inactive'); + const isDisableTransition = previousStatus === 'active' && currentStatus === 'inactive'; + + if (!isEnableTransition && !isDisableTransition) { + return; + } + + logging.info({ + system: { + event: isEnableTransition ? 'welcome_email.enabled' : 'welcome_email.disabled', + automation_id: model.id, + slug + } + }, isEnableTransition ? 'Welcome email automation enabled' : 'Welcome email automation disabled'); } }); diff --git a/ghost/core/core/server/services/member-welcome-emails/service.js b/ghost/core/core/server/services/member-welcome-emails/service.js index 2c0db536095..f9c86b726e0 100644 --- a/ghost/core/core/server/services/member-welcome-emails/service.js +++ b/ghost/core/core/server/services/member-welcome-emails/service.js @@ -3,25 +3,79 @@ 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'); const mail = require('../mail'); // @ts-expect-error type checker has trouble with the dynamic exporting in models -const {AutomatedEmail, Newsletter} = require('../../models'); +const {WelcomeEmailAutomation, WelcomeEmailAutomatedEmail, Newsletter} = require('../../models'); const MemberWelcomeEmailRenderer = require('./member-welcome-email-renderer'); const {MEMBER_WELCOME_EMAIL_LOG_KEY, MEMBER_WELCOME_EMAIL_TAG, MEMBER_WELCOME_EMAIL_SLUGS, MESSAGES} = require('./constants'); +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,29 +152,218 @@ 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 WelcomeEmailAutomation.findAll({ + filter: WELCOME_EMAIL_FILTER, + withRelated: ['welcomeEmailAutomatedEmail'] + }); + } + + 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}); + + if (!free.related('welcomeEmailAutomatedEmail')?.id || !paid.related('welcomeEmailAutomatedEmail')?.id) { + throw new errors.NotFoundError({ + message: MESSAGES.NO_MEMBER_WELCOME_EMAIL + }); + } + + 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.related('welcomeEmailAutomatedEmail')?.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) => { + const email = row.related('welcomeEmailAutomatedEmail'); + return WelcomeEmailAutomatedEmail.edit(attrs, {id: email.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(); for (const [memberStatus, slug] of Object.entries(MEMBER_WELCOME_EMAIL_SLUGS)) { - const row = this.#useDesignCustomization() - ? await AutomatedEmail.findOne({slug}, {withRelated: ['emailDesignSetting']}) - : await AutomatedEmail.findOne({slug}); + const row = await WelcomeEmailAutomation.findOne({slug}, { + withRelated: this.#useDesignCustomization() + ? ['welcomeEmailAutomatedEmail', 'welcomeEmailAutomatedEmail.emailDesignSetting'] + : ['welcomeEmailAutomatedEmail'] + }); - if (!row || !row.get('lexical')) { + if (!row) { this.#memberWelcomeEmails[memberStatus] = null; continue; } - const designSettings = this.#useDesignCustomization() ? row.related('emailDesignSetting') : null; + const email = row.related('welcomeEmailAutomatedEmail'); + + if (!email || !email.get('lexical')) { + this.#memberWelcomeEmails[memberStatus] = null; + continue; + } + + const designSettings = this.#useDesignCustomization() ? email.related('emailDesignSetting') : null; this.#memberWelcomeEmails[memberStatus] = { - lexical: row.get('lexical'), - subject: row.get('subject'), + lexical: email.get('lexical'), + subject: email.get('subject'), status: row.get('status'), designSettings: designSettings?.id ? designSettings.toJSON() : null, - senderName: row.get('sender_name'), - senderEmail: row.get('sender_email'), - senderReplyTo: row.get('sender_reply_to') + senderName: email.get('sender_name'), + senderEmail: email.get('sender_email'), + senderReplyTo: email.get('sender_reply_to') }; } } @@ -166,7 +409,7 @@ class MemberWelcomeEmailService { siteSettings: this.#getSiteSettings() }); - const senderOptions = await this.#getSenderOptions(); + const senderOptions = await this.#getEffectiveSenderOptions(memberWelcomeEmail); await this.#mailer.send({ to: member.email, @@ -186,17 +429,24 @@ class MemberWelcomeEmailService { return false; } - const row = await AutomatedEmail.findOne({slug}); - return Boolean(row && row.get('lexical') && row.get('status') === 'active'); + const row = await WelcomeEmailAutomation.findOne({slug}, {withRelated: ['welcomeEmailAutomatedEmail']}); + if (!row) { + return false; + } + const email = row.related('welcomeEmailAutomatedEmail'); + return Boolean(email && email.get('lexical') && row.get('status') === 'active'); } async sendTestEmail({email, subject, lexical, automatedEmailId}) { // Still validate the automated email exists (for permission purposes) - const automatedEmail = this.#useDesignCustomization() - ? await AutomatedEmail.findOne({id: automatedEmailId}, {withRelated: ['emailDesignSetting']}) - : await AutomatedEmail.findOne({id: automatedEmailId}); + const automation = await WelcomeEmailAutomation.findOne({id: automatedEmailId}, { + withRelated: this.#useDesignCustomization() + ? ['welcomeEmailAutomatedEmail', 'welcomeEmailAutomatedEmail.emailDesignSetting'] + : ['welcomeEmailAutomatedEmail'] + }); + const automatedEmail = automation?.related('welcomeEmailAutomatedEmail'); - if (!automatedEmail) { + if (!automation || !automatedEmail?.id) { throw new errors.NotFoundError({ message: MESSAGES.NO_MEMBER_WELCOME_EMAIL }); @@ -230,8 +480,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 +497,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 +564,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/services/members/api.js b/ghost/core/core/server/services/members/api.js index c76e982f444..1824b777d38 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -241,7 +241,7 @@ function createApiInstance(config) { MemberFeedback: models.MemberFeedback, EmailSpamComplaintEvent: models.EmailSpamComplaintEvent, Outbox: models.Outbox, - AutomatedEmail: models.AutomatedEmail, + WelcomeEmailAutomation: models.WelcomeEmailAutomation, AutomatedEmailRecipient: models.AutomatedEmailRecipient }, stripeAPIService: stripeService.api, diff --git a/ghost/core/core/server/services/members/members-api/members-api.js b/ghost/core/core/server/services/members/members-api/members-api.js index b0d5b0ec545..542a68f53b1 100644 --- a/ghost/core/core/server/services/members/members-api/members-api.js +++ b/ghost/core/core/server/services/members/members-api/members-api.js @@ -65,7 +65,7 @@ module.exports = function MembersAPI({ Comment, MemberFeedback, Outbox, - AutomatedEmail, + WelcomeEmailAutomation, AutomatedEmailRecipient }, tiersService, @@ -101,7 +101,7 @@ module.exports = function MembersAPI({ tokenService, newslettersService, productRepository, - AutomatedEmail, + WelcomeEmailAutomation, Member, MemberNewsletter, MemberCancelEvent, diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index 83207b31458..9fbf8e058a1 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -887,7 +887,7 @@ module.exports = class EventRepository { async getAutomatedEmailSentEvents(options = {}, filter) { options = { ...options, - withRelated: ['member', 'automatedEmail'], + withRelated: ['member', 'automatedEmail.welcomeEmailAutomation'], filter: 'custom:true', useBasicCount: true, mongoTransformer: chainTransformers( @@ -902,7 +902,14 @@ module.exports = class EventRepository { const {data: models, meta} = await this._AutomatedEmailRecipient.findPage(options); const data = models.map((model) => { - const automatedEmail = model.related('automatedEmail').toJSON(); + const automatedEmail = model.related('automatedEmail'); + const automation = automatedEmail.related('welcomeEmailAutomation'); + if (!automation || !automation.id) { + throw new errors.InternalServerError({ + message: `Automated email recipient ${model.id} has no associated welcome email automation` + }); + } + return { type: 'automated_email_sent_event', data: { @@ -912,7 +919,7 @@ module.exports = class EventRepository { member: model.related('member').toJSON(), automatedEmail: { id: automatedEmail.id, - slug: automatedEmail.slug + slug: automation.get('slug') } } }; diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index dcfd4b239a9..451451aebdc 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -64,7 +64,7 @@ module.exports = class MemberRepository { * @param {any} deps.offersAPI * @param {ITokenService} deps.tokenService * @param {any} deps.newslettersService - * @param {any} deps.AutomatedEmail + * @param {any} deps.WelcomeEmailAutomation */ constructor({ Member, @@ -84,7 +84,7 @@ module.exports = class MemberRepository { offersAPI, tokenService, newslettersService, - AutomatedEmail + WelcomeEmailAutomation }) { this._Member = Member; this._MemberNewsletter = MemberNewsletter; @@ -103,7 +103,7 @@ module.exports = class MemberRepository { this._offersAPI = offersAPI; this.tokenService = tokenService; this._newslettersService = newslettersService; - this._AutomatedEmail = AutomatedEmail; + this._WelcomeEmailAutomation = WelcomeEmailAutomation; DomainEvents.subscribe(OfferRedemptionEvent, async function (event) { if (!event.data.offerId) { @@ -359,9 +359,18 @@ module.exports = class MemberRepository { const shouldCheckFreeWelcomeEmail = WELCOME_EMAIL_SOURCES.includes(source) && isFreeSignup; let isFreeWelcomeEmailActive = false; - if (shouldCheckFreeWelcomeEmail) { - const freeWelcomeEmail = this._AutomatedEmail ? await this._AutomatedEmail.findOne({slug: MEMBER_WELCOME_EMAIL_SLUGS.free}) : null; - isFreeWelcomeEmailActive = freeWelcomeEmail && freeWelcomeEmail.get('lexical') && freeWelcomeEmail.get('status') === 'active'; + if (shouldCheckFreeWelcomeEmail && this._WelcomeEmailAutomation) { + const freeWelcomeAutomation = await this._WelcomeEmailAutomation.findOne( + {slug: MEMBER_WELCOME_EMAIL_SLUGS.free}, + {...options, withRelated: ['welcomeEmailAutomatedEmail']} + ); + const freeWelcomeEmail = freeWelcomeAutomation?.related('welcomeEmailAutomatedEmail'); + isFreeWelcomeEmailActive = Boolean( + freeWelcomeAutomation && + freeWelcomeEmail && + freeWelcomeEmail.get('lexical') && + freeWelcomeAutomation.get('status') === 'active' + ); } if (isFreeWelcomeEmailActive && isFreeSignup) { @@ -1468,9 +1477,18 @@ module.exports = class MemberRepository { const source = this._resolveContextSource(context); const shouldSendPaidWelcomeEmail = WELCOME_EMAIL_SOURCES.includes(source); let isPaidWelcomeEmailActive = false; - if (shouldSendPaidWelcomeEmail && this._AutomatedEmail) { - const paidWelcomeEmail = await this._AutomatedEmail.findOne({slug: MEMBER_WELCOME_EMAIL_SLUGS.paid}, options); - isPaidWelcomeEmailActive = paidWelcomeEmail && paidWelcomeEmail.get('lexical') && paidWelcomeEmail.get('status') === 'active'; + if (shouldSendPaidWelcomeEmail && this._WelcomeEmailAutomation) { + const paidWelcomeAutomation = await this._WelcomeEmailAutomation.findOne( + {slug: MEMBER_WELCOME_EMAIL_SLUGS.paid}, + {...options, withRelated: ['welcomeEmailAutomatedEmail']} + ); + const paidWelcomeEmail = paidWelcomeAutomation?.related('welcomeEmailAutomatedEmail'); + isPaidWelcomeEmailActive = Boolean( + paidWelcomeAutomation && + paidWelcomeEmail && + paidWelcomeEmail.get('lexical') && + paidWelcomeAutomation.get('status') === 'active' + ); } // Send paid welcome email if: // 1. The paid welcome email is active diff --git a/ghost/core/core/server/services/outbox/handlers/member-created.js b/ghost/core/core/server/services/outbox/handlers/member-created.js index 1746ead96bd..ad80129d746 100644 --- a/ghost/core/core/server/services/outbox/handlers/member-created.js +++ b/ghost/core/core/server/services/outbox/handlers/member-created.js @@ -1,7 +1,7 @@ const {OUTBOX_LOG_KEY} = require('../jobs/lib/constants'); const memberWelcomeEmailService = require('../../member-welcome-emails/service'); const logging = require('@tryghost/logging'); -const {AutomatedEmail, AutomatedEmailRecipient} = require('../../../models'); +const {WelcomeEmailAutomation, AutomatedEmailRecipient} = require('../../../models'); const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../../member-welcome-emails/constants'); const LOG_KEY = `${OUTBOX_LOG_KEY}[MEMBER-WELCOME-EMAIL]`; @@ -21,8 +21,8 @@ async function handle({payload}) { return; } - const automatedEmail = await AutomatedEmail.findOne({slug}); - if (!automatedEmail) { + const automation = await WelcomeEmailAutomation.findOne({slug}, {withRelated: ['welcomeEmailAutomatedEmail']}); + if (!automation) { logging.warn({ system: { event: 'outbox.member_created.no_automated_email', @@ -32,9 +32,33 @@ async function handle({payload}) { return; } + // NOTE(NY-1190): This naively assumes each drip sequence will have + // just one email. When we change that assumption, this line will need + // to change to something like: + // + // ``` + // SELECT * FROM welcome_email_automated_emails + // WHERE welcome_email_automation_id IS ? + // AND id NOT IN ( + // SELECT next_id FROM welcome_email_automated_emails + // WHERE next_id IS NOT NULL + // AND welcome_email_automation_id IS ? + // ); + // ``` + const email = automation.related('welcomeEmailAutomatedEmail'); + if (!email || !email.id) { + logging.warn({ + system: { + event: 'outbox.member_created.no_automated_email', + slug + } + }, `${LOG_KEY} No automated email content found for slug: ${slug}`); + return; + } + await AutomatedEmailRecipient.add({ member_id: payload.memberId, - automated_email_id: automatedEmail.id, + automated_email_id: email.id, member_uuid: payload.uuid, member_email: payload.email, member_name: payload.name diff --git a/ghost/core/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/package.json b/ghost/core/package.json index e295295163d..4dc618410ec 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -169,7 +169,7 @@ "heic-convert": "2.1.0", "html-to-text": "5.1.1", "html5parser": "2.0.2", - "human-number": "2.0.8", + "human-number": "2.0.9", "iconv-lite": "0.6.3", "image-size": "1.2.1", "intl": "1.2.5", 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..a8b0fa4ef9f 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, @@ -36,7 +39,8 @@ describe('Automated Emails API', function () { beforeEach(async function () { await dbUtils.truncate('brute'); - await dbUtils.truncate('automated_emails'); + await dbUtils.truncate('welcome_email_automated_emails'); + await dbUtils.truncate('welcome_email_automations'); }); describe('Browse', function () { @@ -199,10 +203,10 @@ describe('Automated Emails API', function () { sinon.assert.calledWithMatch(infoStub, { system: { event: 'welcome_email.enabled', - automated_email_id: automatedEmail.id, + automation_id: automatedEmail.id, slug: 'member-welcome-email-free' } - }, 'Welcome email enabled'); + }, 'Welcome email automation enabled'); }); it('Does not log when a welcome email is created as inactive', async function () { @@ -365,10 +369,10 @@ describe('Automated Emails API', function () { sinon.assert.calledWithMatch(infoStub, { system: { event: 'welcome_email.enabled', - automated_email_id: automatedEmail.id, + automation_id: automatedEmail.id, slug: 'member-welcome-email-free' } - }, 'Welcome email enabled'); + }, 'Welcome email automation enabled'); }); it('Logs when a welcome email is disabled', async function () { @@ -385,10 +389,10 @@ describe('Automated Emails API', function () { sinon.assert.calledWithMatch(infoStub, { system: { event: 'welcome_email.disabled', - automated_email_id: automatedEmail.id, + automation_id: automatedEmail.id, slug: 'member-welcome-email-free' } - }, 'Welcome email disabled'); + }, 'Welcome email automation disabled'); }); it('Does not log when status does not change', async function () { @@ -411,6 +415,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/jobs/process-outbox.test.js b/ghost/core/test/integration/jobs/process-outbox.test.js index fb01070ead7..65b44e496bf 100644 --- a/ghost/core/test/integration/jobs/process-outbox.test.js +++ b/ghost/core/test/integration/jobs/process-outbox.test.js @@ -28,7 +28,7 @@ describe('Process Outbox Job', function () { afterEach(async function () { sinon.restore(); await db.knex('outbox').del(); - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); try { await jobService.removeJob(JOB_NAME); } catch (err) { @@ -64,14 +64,21 @@ describe('Process Outbox Job', function () { } }); - await db.knex('automated_emails').insert({ - id: ObjectId().toHexString(), - email_design_setting_id: defaultEmailDesignSettingId, + const automationId = ObjectId().toHexString(); + await db.knex('welcome_email_automations').insert({ + id: automationId, status: 'active', name: 'Free Member Welcome Email', slug: MEMBER_WELCOME_EMAIL_SLUGS.free, + created_at: new Date() + }); + await db.knex('welcome_email_automated_emails').insert({ + id: ObjectId().toHexString(), + welcome_email_automation_id: automationId, + delay_days: 0, subject: 'Welcome to {site_title}', lexical, + email_design_setting_id: defaultEmailDesignSettingId, created_at: new Date() }); }); diff --git a/ghost/core/test/integration/services/member-welcome-emails.test.js b/ghost/core/test/integration/services/member-welcome-emails.test.js index 4c74323b9da..462ea6b547d 100644 --- a/ghost/core/test/integration/services/member-welcome-emails.test.js +++ b/ghost/core/test/integration/services/member-welcome-emails.test.js @@ -66,25 +66,39 @@ describe('Member Welcome Emails Integration', function () { } }); - await db.knex('automated_emails').insert({ - id: ObjectId().toHexString(), - email_design_setting_id: defaultEmailDesignSettingId, + const freeAutomationId = ObjectId().toHexString(); + await db.knex('welcome_email_automations').insert({ + id: freeAutomationId, status: 'active', name: 'Free Member Welcome Email', slug: MEMBER_WELCOME_EMAIL_SLUGS.free, + created_at: new Date() + }); + await db.knex('welcome_email_automated_emails').insert({ + id: ObjectId().toHexString(), + welcome_email_automation_id: freeAutomationId, + delay_days: 0, subject: 'Welcome to {site_title}', lexical, + email_design_setting_id: defaultEmailDesignSettingId, created_at: new Date() }); - await db.knex('automated_emails').insert({ - id: ObjectId().toHexString(), - email_design_setting_id: defaultEmailDesignSettingId, + const paidAutomationId = ObjectId().toHexString(); + await db.knex('welcome_email_automations').insert({ + id: paidAutomationId, status: 'active', name: 'Paid Member Welcome Email', slug: MEMBER_WELCOME_EMAIL_SLUGS.paid, + created_at: new Date() + }); + await db.knex('welcome_email_automated_emails').insert({ + id: ObjectId().toHexString(), + welcome_email_automation_id: paidAutomationId, + delay_days: 0, subject: 'Welcome paid member to {site_title}', lexical, + email_design_setting_id: defaultEmailDesignSettingId, created_at: new Date() }); }); @@ -105,8 +119,8 @@ describe('Member Welcome Emails Integration', function () { await db.knex('automated_email_recipients').del(); await db.knex('outbox').del(); await db.knex('members').del(); - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid).del(); }); describe('Member creation with welcome emails', function () { @@ -215,8 +229,15 @@ describe('Member Welcome Emails Integration', function () { await jobService.awaitCompletion(JOB_NAME); } + async function getAutomatedEmailBySlug(slug) { + return db.knex('welcome_email_automated_emails') + .join('welcome_email_automations', 'welcome_email_automated_emails.welcome_email_automation_id', 'welcome_email_automations.id') + .where('welcome_email_automations.slug', slug) + .first('welcome_email_automated_emails.*'); + } + it('does not send email when template is inactive', async function () { - await db.knex('automated_emails') + await db.knex('welcome_email_automations') .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) .update({status: 'inactive'}); @@ -242,7 +263,7 @@ describe('Member Welcome Emails Integration', function () { }); it('does not send email when no template exists', async function () { - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free).del(); await models.Outbox.add({ event_type: 'MemberCreatedEvent', @@ -266,7 +287,7 @@ describe('Member Welcome Emails Integration', function () { }); it('does not send email when paid template is inactive but entry has status paid', async function () { - await db.knex('automated_emails') + await db.knex('welcome_email_automations') .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid) .update({status: 'inactive'}); @@ -292,7 +313,7 @@ describe('Member Welcome Emails Integration', function () { }); it('does not send email when no paid template exists but entry has status paid', async function () { - await db.knex('automated_emails').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid).del(); + await db.knex('welcome_email_automations').where('slug', MEMBER_WELCOME_EMAIL_SLUGS.paid).del(); await models.Outbox.add({ event_type: 'MemberCreatedEvent', @@ -348,9 +369,10 @@ describe('Member Welcome Emails Integration', function () { assert.equal(record.member_email, memberEmail); assert.equal(record.member_name, memberName); - const automatedEmail = await db.knex('automated_emails') - .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) - .first(); + const automatedEmail = await db.knex('welcome_email_automated_emails') + .join('welcome_email_automations', 'welcome_email_automated_emails.welcome_email_automation_id', 'welcome_email_automations.id') + .where('welcome_email_automations.slug', MEMBER_WELCOME_EMAIL_SLUGS.free) + .first('welcome_email_automated_emails.id'); assert.equal(record.automated_email_id, automatedEmail.id); }); @@ -410,8 +432,49 @@ 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' + }); + + const automatedEmail = await getAutomatedEmailBySlug(MEMBER_WELCOME_EMAIL_SLUGS.free); + + await db.knex('welcome_email_automated_emails') + .where('id', automatedEmail.id) + .update({ + sender_name: 'Automation Sender', + sender_email: 'automation@example.com', + 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') + const automation = await db.knex('welcome_email_automations') .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) .first(); @@ -436,7 +499,7 @@ describe('Member Welcome Emails Integration', function () { email: 'test-member@example.com', subject: 'Welcome test', lexical, - automatedEmailId: automatedEmail.id + automatedEmailId: automation.id }); sinon.assert.calledOnce(mailService.GhostMailer.prototype.send); @@ -445,6 +508,47 @@ 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 automation = await db.knex('welcome_email_automations') + .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free) + .first(); + const automatedEmail = await getAutomatedEmailBySlug(MEMBER_WELCOME_EMAIL_SLUGS.free); + + await db.knex('welcome_email_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: automation.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 () { diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index ee9585f80a4..506e2cf00e3 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '5af830f163cc019b1d29188c68c96990'; + const currentSchemaHash = 'f57e57fd042ecee9dd93410ae87c0454'; const currentFixturesHash = '2f86ab1e3820e86465f9ad738dd0ee93'; const currentSettingsHash = 'a102b80d2ab0cd92325ed007c94d7da6'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; diff --git a/ghost/core/test/unit/server/models/automated-email.test.js b/ghost/core/test/unit/server/models/automated-email.test.js deleted file mode 100644 index d3eff8c474d..00000000000 --- a/ghost/core/test/unit/server/models/automated-email.test.js +++ /dev/null @@ -1,185 +0,0 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const models = require('../../../../core/server/models'); -const config = require('../../../../core/shared/config'); - -describe('Unit: models/automated-email', function () { - before(function () { - models.init(); - }); - - afterEach(function () { - sinon.restore(); - }); - - describe('defaults', function () { - it('sets default status to inactive', function () { - const model = new models.AutomatedEmail(); - const defaults = model.defaults(); - - assert.equal(defaults.status, 'inactive'); - }); - - it('returns expected default values', function () { - const model = new models.AutomatedEmail(); - const defaults = model.defaults(); - - assert.ok(defaults); - assert.equal(Object.keys(defaults).length, 1); - assert.equal(defaults.status, 'inactive'); - }); - }); - - describe('parse', function () { - it('transforms __GHOST_URL__ to absolute URL in lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.parse({ - id: '123', - lexical: '{"root":{"children":[{"type":"paragraph","children":[{"type":"link","url":"__GHOST_URL__/test"}]}]}}' - }); - - assert.ok(result.lexical.includes(`${config.get('url')}/test`)); - assert.ok(!result.lexical.includes('__GHOST_URL__')); - }); - - it('handles null lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.parse({ - id: '123', - lexical: null - }); - - assert.equal(result.lexical, null); - }); - - it('handles undefined lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.parse({ - id: '123' - }); - - assert.equal(result.lexical, undefined); - }); - - it('preserves other fields', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.parse({ - id: '123', - name: 'welcome_email', - subject: 'Welcome!', - status: 'active', - lexical: '{"root":{"children":[]}}' - }); - - assert.equal(result.id, '123'); - assert.equal(result.name, 'welcome_email'); - assert.equal(result.subject, 'Welcome!'); - assert.equal(result.status, 'active'); - }); - }); - - describe('formatOnWrite', function () { - it('transforms absolute URLs to __GHOST_URL__ in lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const siteUrl = config.get('url'); - const result = model.formatOnWrite({ - lexical: `{"root":{"children":[{"type":"paragraph","children":[{"type":"link","url":"${siteUrl}/test"}]}]}}` - }); - - assert.ok(result.lexical.includes('__GHOST_URL__/test')); - assert.ok(!result.lexical.includes(siteUrl)); - }); - - it('handles null lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.formatOnWrite({ - lexical: null - }); - - assert.equal(result.lexical, null); - }); - - it('handles undefined lexical field', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.formatOnWrite({ - name: 'welcome_email' - }); - - assert.equal(result.lexical, undefined); - assert.equal(result.name, 'welcome_email'); - }); - - it('preserves other fields', function () { - const model = models.AutomatedEmail.forge(); - - const result = model.formatOnWrite({ - id: '123', - name: 'welcome_email', - subject: 'Welcome!', - status: 'active', - lexical: '{"root":{"children":[]}}' - }); - - assert.equal(result.id, '123'); - assert.equal(result.name, 'welcome_email'); - assert.equal(result.subject, 'Welcome!'); - assert.equal(result.status, 'active'); - }); - }); - - describe('onCreating', function () { - it('assigns the default email design setting when not provided', async function () { - const model = models.AutomatedEmail.forge(); - const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.AutomatedEmail.prototype), 'onCreating').resolves(); - const findOne = sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); - - await model.onCreating(model, {}, {}); - - sinon.assert.calledOnceWithExactly(findOne, {slug: 'default-automated-email'}, {}); - assert.equal(model.get('email_design_setting_id'), 'default-setting-id'); - sinon.assert.calledOnceWithExactly(baseOnCreating, model, {}, {}); - }); - - it('assigns the default email design setting when null is provided', async function () { - const model = models.AutomatedEmail.forge({email_design_setting_id: null}); - sinon.stub(Object.getPrototypeOf(models.AutomatedEmail.prototype), 'onCreating').resolves(); - sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); - - await model.onCreating(model, {}, {}); - - assert.equal(model.get('email_design_setting_id'), 'default-setting-id'); - }); - - it('keeps the provided email design setting id', async function () { - const model = models.AutomatedEmail.forge({email_design_setting_id: 'custom-setting-id'}); - const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.AutomatedEmail.prototype), 'onCreating').resolves(); - const findOne = sinon.stub(models.EmailDesignSetting, 'findOne'); - - await model.onCreating(model, {}, {}); - - sinon.assert.notCalled(findOne); - assert.equal(model.get('email_design_setting_id'), 'custom-setting-id'); - sinon.assert.calledOnceWithExactly(baseOnCreating, model, {}, {}); - }); - - it('throws when the default email design setting is missing', async function () { - const model = models.AutomatedEmail.forge(); - sinon.stub(Object.getPrototypeOf(models.AutomatedEmail.prototype), 'onCreating').resolves(); - sinon.stub(models.EmailDesignSetting, 'findOne').resolves(null); - - await assert.rejects( - model.onCreating(model, {}, {}), - { - errorType: 'InternalServerError' - } - ); - }); - }); -}); diff --git a/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js b/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js index 2c861a66046..2658ee1cffe 100644 --- a/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js +++ b/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js @@ -107,4 +107,51 @@ describe('Unit: models/welcome-email-automated-email', function () { assert.equal(result.subject, 'Welcome!'); }); }); + + describe('onCreating', function () { + it('assigns the default email design setting when not provided', async function () { + const model = models.WelcomeEmailAutomatedEmail.forge(); + const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + const findOne = sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); + + await model.onCreating(model, {}, {}); + + assert.equal(model.get('email_design_setting_id'), 'default-setting-id'); + sinon.assert.calledOnce(findOne); + sinon.assert.calledOnce(baseOnCreating); + }); + + it('assigns the default email design setting when null is provided', async function () { + const model = models.WelcomeEmailAutomatedEmail.forge({email_design_setting_id: null}); + sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); + + await model.onCreating(model, {}, {}); + + assert.equal(model.get('email_design_setting_id'), 'default-setting-id'); + }); + + it('keeps the provided email design setting id', async function () { + const model = models.WelcomeEmailAutomatedEmail.forge({email_design_setting_id: 'custom-setting-id'}); + const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + const findOne = sinon.stub(models.EmailDesignSetting, 'findOne'); + + await model.onCreating(model, {}, {}); + + assert.equal(model.get('email_design_setting_id'), 'custom-setting-id'); + sinon.assert.notCalled(findOne); + sinon.assert.calledOnce(baseOnCreating); + }); + + it('throws when the default email design setting is missing', async function () { + const model = models.WelcomeEmailAutomatedEmail.forge(); + sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + sinon.stub(models.EmailDesignSetting, 'findOne').resolves(null); + + await assert.rejects( + () => model.onCreating(model, {}, {}), + {message: 'Missing default email design setting for automated emails'} + ); + }); + }); }); diff --git a/ghost/core/test/unit/server/models/welcome-email-automation.test.js b/ghost/core/test/unit/server/models/welcome-email-automation.test.js index 8e5d560bc9d..f1d7bf90b31 100644 --- a/ghost/core/test/unit/server/models/welcome-email-automation.test.js +++ b/ghost/core/test/unit/server/models/welcome-email-automation.test.js @@ -1,6 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); +const logging = require('@tryghost/logging'); describe('Unit: models/welcome-email-automation', function () { before(function () { @@ -28,4 +29,68 @@ describe('Unit: models/welcome-email-automation', function () { assert.equal(defaults.status, 'inactive'); }); }); + + describe('onSaved', function () { + it('logs when a welcome email is enabled', function () { + const infoStub = sinon.stub(logging, 'info'); + const model = models.WelcomeEmailAutomation.forge({ + id: 'test-id', + slug: 'member-welcome-email-free', + status: 'active' + }); + sinon.stub(model, 'previous').withArgs('status').returns('inactive'); + + model.onSaved(model); + + sinon.assert.calledOnce(infoStub); + const logArg = infoStub.firstCall.args[0]; + assert.equal(logArg.system.event, 'welcome_email.enabled'); + assert.equal(logArg.system.slug, 'member-welcome-email-free'); + }); + + it('logs when a welcome email is disabled', function () { + const infoStub = sinon.stub(logging, 'info'); + const model = models.WelcomeEmailAutomation.forge({ + id: 'test-id', + slug: 'member-welcome-email-paid', + status: 'inactive' + }); + sinon.stub(model, 'previous').withArgs('status').returns('active'); + + model.onSaved(model); + + sinon.assert.calledOnce(infoStub); + const logArg = infoStub.firstCall.args[0]; + assert.equal(logArg.system.event, 'welcome_email.disabled'); + assert.equal(logArg.system.slug, 'member-welcome-email-paid'); + }); + + it('does not log for non-welcome-email slugs', function () { + const infoStub = sinon.stub(logging, 'info'); + const model = models.WelcomeEmailAutomation.forge({ + id: 'test-id', + slug: 'some-other-slug', + status: 'active' + }); + sinon.stub(model, 'previous').withArgs('status').returns('inactive'); + + model.onSaved(model); + + sinon.assert.notCalled(infoStub); + }); + + it('does not log when status has not changed', function () { + const infoStub = sinon.stub(logging, 'info'); + const model = models.WelcomeEmailAutomation.forge({ + id: 'test-id', + slug: 'member-welcome-email-free', + status: 'active' + }); + sinon.stub(model, 'previous').withArgs('status').returns('active'); + + model.onSaved(model); + + sinon.assert.notCalled(infoStub); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js index 3835c942734..5ab19764d30 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js @@ -315,10 +315,15 @@ describe('EventRepository', function () { } if (relation === 'automatedEmail') { return { - toJSON: () => ({ - id: 'ae123', - slug: 'member-welcome-email-free' - }) + id: 'ae123', + related: (rel) => { + if (rel === 'welcomeEmailAutomation') { + return { + id: 'auto123', + get: key => (key === 'slug' ? 'member-welcome-email-free' : undefined) + }; + } + } }; } }, @@ -351,7 +356,7 @@ describe('EventRepository', function () { }); sinon.assert.calledOnceWithMatch(fake, { - withRelated: ['member', 'automatedEmail'], + withRelated: ['member', 'automatedEmail.welcomeEmailAutomation'], filter: 'custom:true', order: 'created_at desc, id desc' }); @@ -365,7 +370,7 @@ describe('EventRepository', function () { }); sinon.assert.calledOnceWithMatch(fake, { - withRelated: ['member', 'automatedEmail'], + withRelated: ['member', 'automatedEmail.welcomeEmailAutomation'], filter: 'custom:true', order: 'created_at desc, id desc' }); @@ -380,7 +385,7 @@ describe('EventRepository', function () { }); sinon.assert.calledOnceWithMatch(fake, { - withRelated: ['member', 'automatedEmail'], + withRelated: ['member', 'automatedEmail.welcomeEmailAutomation'], filter: 'custom:true', order: 'created_at desc, id desc' }); diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js index 291c9fbe912..133b2c74389 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js @@ -1454,7 +1454,7 @@ describe('MemberRepository', function () { let MemberStatusEvent; let MemberSubscribeEvent; let newslettersService; - let AutomatedEmail; + let WelcomeEmailAutomation; const oldNodeEnv = process.env.NODE_ENV; beforeEach(function () { @@ -1508,11 +1508,20 @@ describe('MemberRepository', function () { getAll: sinon.stub().resolves([]) }; - AutomatedEmail = { + WelcomeEmailAutomation = { findOne: sinon.stub().resolves({ get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}', status: 'active'}; + const data = {status: 'active'}; return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; }) }) }; @@ -1529,7 +1538,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1553,7 +1562,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1577,7 +1586,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1595,7 +1604,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1606,10 +1615,19 @@ describe('MemberRepository', function () { }); it('does NOT create outbox entry when welcome email is inactive', async function () { - AutomatedEmail.findOne.resolves({ + WelcomeEmailAutomation.findOne.resolves({ get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}', status: 'inactive'}; + const data = {status: 'inactive'}; return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; }) }); @@ -1619,7 +1637,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1638,7 +1656,7 @@ describe('MemberRepository', function () { MemberStatusEvent, MemberSubscribeEventModel: MemberSubscribeEvent, newslettersService, - AutomatedEmail, + WelcomeEmailAutomation, StripeCustomer, OfferRedemption: mockOfferRedemption }); @@ -1667,7 +1685,7 @@ describe('MemberRepository', function () { // The free welcome email should NOT be sent when stripeCustomer is present sinon.assert.notCalled(Outbox.add); - sinon.assert.notCalled(AutomatedEmail.findOne); + sinon.assert.notCalled(WelcomeEmailAutomation.findOne); sinon.assert.notCalled(Member.transaction); }); }); @@ -1681,7 +1699,7 @@ describe('MemberRepository', function () { let MemberStatusEvent; let stripeAPIService; let productRepository; - let AutomatedEmail; + let WelcomeEmailAutomation; let subscriptionData; beforeEach(function () { @@ -1803,11 +1821,20 @@ describe('MemberRepository', function () { update: sinon.stub().resolves({}) }; - AutomatedEmail = { + WelcomeEmailAutomation = { findOne: sinon.stub().resolves({ get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}', status: 'active'}; + const data = {status: 'active'}; return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; }) }) }; @@ -1836,7 +1863,7 @@ describe('MemberRepository', function () { MemberStatusEvent, stripeAPIService, productRepository, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1881,7 +1908,7 @@ describe('MemberRepository', function () { MemberStatusEvent, stripeAPIService, productRepository, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); @@ -1918,10 +1945,19 @@ describe('MemberRepository', function () { }) }); - AutomatedEmail.findOne.resolves({ + WelcomeEmailAutomation.findOne.resolves({ get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}', status: 'inactive'}; + const data = {status: 'inactive'}; return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; }) }); @@ -1934,7 +1970,7 @@ describe('MemberRepository', function () { MemberStatusEvent, stripeAPIService, productRepository, - AutomatedEmail, + WelcomeEmailAutomation, OfferRedemption: mockOfferRedemption }); diff --git a/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js b/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js index 8fa021eb0fe..96425b50bd5 100644 --- a/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js +++ b/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js @@ -6,7 +6,7 @@ const {captureLoggerOutput, findByEvent} = require('../../../../../utils/logging describe('member-created handler', function () { let handler; let memberWelcomeEmailServiceStub; - let AutomatedEmailStub; + let WelcomeEmailAutomationStub; let AutomatedEmailRecipientStub; let logCapture; @@ -19,8 +19,15 @@ describe('member-created handler', function () { } }; - AutomatedEmailStub = { - findOne: sinon.stub().resolves({id: 'ae123'}) + WelcomeEmailAutomationStub = { + findOne: sinon.stub().resolves({ + id: 'automation123', + related: sinon.stub().callsFake((relation) => { + if (relation === 'welcomeEmailAutomatedEmail') { + return {id: 'ae123'}; + } + }) + }) }; AutomatedEmailRecipientStub = { @@ -30,7 +37,7 @@ describe('member-created handler', function () { logCapture = captureLoggerOutput(); handler.__set__('memberWelcomeEmailService', memberWelcomeEmailServiceStub); - handler.__set__('AutomatedEmail', AutomatedEmailStub); + handler.__set__('WelcomeEmailAutomation', WelcomeEmailAutomationStub); handler.__set__('AutomatedEmailRecipient', AutomatedEmailRecipientStub); }); @@ -95,7 +102,7 @@ describe('member-created handler', function () { }); it('logs warning when no automated email found for slug', async function () { - AutomatedEmailStub.findOne.resolves(null); + WelcomeEmailAutomationStub.findOne.resolves(null); await handler.handle({ payload: { diff --git a/yarn.lock b/yarn.lock index 212ba15ca52..a1625b96cde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2519,29 +2519,6 @@ "@elastic/transport" "~8.4.1" tslib "^2.4.0" -"@elastic/elasticsearch@9.3.4": - version "9.3.4" - resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-9.3.4.tgz#b124f33ba8ddd98267798672dba5ad4aee9d6c89" - integrity sha512-Mp14fPEYx+WTfZdcvAaZ9WkLYGHQCbwMx6EP5VCucYdhv4cn/g2sbnMT5HzK+gX3XEpBnnkEK/+WysCKzxuo3A== - dependencies: - "@elastic/transport" "^9.3.5" - apache-arrow "18.x - 21.x" - tslib "^2.4.0" - -"@elastic/transport@^9.3.5": - version "9.3.5" - resolved "https://registry.yarnpkg.com/@elastic/transport/-/transport-9.3.5.tgz#c041807aa366b7bd2f861c458ca4fd427e06e519" - integrity sha512-hIMJbt1guqr3/N2zCN45k9hw9o78qcdsO0xietLe+Bfa+JL0YafHTgkWkM1oT3Ht5sGMJaDcJZiYomSMU6CtTA== - dependencies: - "@opentelemetry/api" "1.x" - "@opentelemetry/core" "2.x" - debug "^4.4.1" - hpagent "^1.2.0" - ms "^2.1.3" - secure-json-parse "^4.0.0" - tslib "^2.8.1" - undici "^7.19.1" - "@elastic/transport@~8.4.1": version "8.4.1" resolved "https://registry.yarnpkg.com/@elastic/transport/-/transport-8.4.1.tgz#f98c5a5e2156bcb3f01170b4aca7e7de4d8b61b8" @@ -4734,11 +4711,6 @@ resolved "https://registry.yarnpkg.com/@kapouer/eslint-plugin-no-return-in-loop/-/eslint-plugin-no-return-in-loop-1.0.0.tgz#9fdbe83deca12156c0b5fcbfae1f387e9f2baff5" integrity sha512-IXQp8N68L2fkk7p7RckBBhT/KwAX04GooIGjwzmY5THQanQvsmJpYgwC7A1Io2XDXBJzlGelQkP/C1SRM/aq8w== -"@keyv/serialize@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a" - integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA== - "@keyvhq/core@^2.0.0": version "2.1.1" resolved "https://registry.npmjs.org/@keyvhq/core/-/core-2.1.1.tgz#438ea23a6a7af183af8fa6a194e1b334512c428d" @@ -5112,11 +5084,6 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@1.x": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" - integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== - "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.4.0", "@opentelemetry/api@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" @@ -5141,13 +5108,6 @@ dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/core@2.x": - version "2.6.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.6.1.tgz#a59d22a9ae3be80bb41b280bbbe1fe9fbdb6c2a5" - integrity sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g== - dependencies: - "@opentelemetry/semantic-conventions" "^1.29.0" - "@opentelemetry/instrumentation-amqplib@0.58.0": version "0.58.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz#e3dc86ebfa7d72fe861a63b1c24a062faeb64a8c" @@ -6499,11 +6459,6 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.2.tgz#cf83e2c56b581bad4614eeb3d2da5b5917ed34ec" integrity sha512-ARz+Bs8kY6FtitYM96PqPEVvPXqEZmPZsSkXvyX19YzDqkCaIlhCieLLMI5hxO9SRZ2XtCtm8wxhy0iJ2jxNfw== -"@sec-ant/readable-stream@^0.4.1": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" - integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== - "@selderee/plugin-htmlparser2@^0.6.0": version "0.6.0" resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.6.0.tgz#27e994afd1c2cb647ceb5406a185a5574188069d" @@ -6907,11 +6862,6 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-5.6.0.tgz#41dd6093d34652cddb5d5bdeee04eafc33826668" integrity sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g== -"@sindresorhus/is@^7.0.1": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-7.2.0.tgz#7c594e1a64336d2008d99d814056d459421504d4" - integrity sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw== - "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.1": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -9064,13 +9014,6 @@ resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@swc/helpers@^0.5.11": - version "0.5.21" - resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.21.tgz#0b1b020317ee1282860ca66f7e9a7c7790f05ae0" - integrity sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg== - dependencies: - tslib "^2.8.0" - "@swc/types@^0.1.24": version "0.1.25" resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.25.tgz#b517b2a60feb37dd933e542d93093719e4cf1078" @@ -9584,7 +9527,7 @@ resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-transaction-events/-/bookshelf-transaction-events-2.0.3.tgz#8f8afe09710e83a54e5cdcd5f3a7501e91ddbaed" integrity sha512-Mw2oJ75Qw7iJc7AfFpZeMpD9xpNm99snINOFNAC+EO2TW/swc7bn3GYqN2nkLtRuqayV3LwwICltXMVvUiShCw== -"@tryghost/bunyan-rotating-filestream@0.0.7", "@tryghost/bunyan-rotating-filestream@^0.0.7": +"@tryghost/bunyan-rotating-filestream@^0.0.7": version "0.0.7" resolved "https://registry.yarnpkg.com/@tryghost/bunyan-rotating-filestream/-/bunyan-rotating-filestream-0.0.7.tgz#3957de91e4e9b58999f0bbe19242080543dcfc4a" integrity sha512-dswM+dxG8J7WpVoSjzAdoWXqqB5Dg0C2T7Zh6eoUvl5hkA8yWWJi/fS4jNXlHF700lWQ0g8/t+leJ7SGSWd+aw== @@ -9663,15 +9606,6 @@ resolved "https://registry.yarnpkg.com/@tryghost/domain-events/-/domain-events-1.0.2.tgz#0d4b134a997802946f3615385a09b82c4904d8c7" integrity sha512-y2sam/cOhCDViWLzLFmGn7ZIwNpz7dTOK1zygxwVrojhwrORWPtbA9MSoVrT6zUF9IplOx53/MAgnE1CYXSfng== -"@tryghost/elasticsearch@^3.0.22": - version "3.0.29" - resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch/-/elasticsearch-3.0.29.tgz#040b8c44075a1e94c5ea5a73de9dd639219987a5" - integrity sha512-WGfoGbPXzX7SYPKeD7naYyoF8Lc7KruQPixFSJGLciRQ3wkrZQANQIguD4nA71upp0dhsT4D4aWP296VfJxuPw== - dependencies: - "@elastic/elasticsearch" "8.13.1" - "@tryghost/debug" "^0.1.36" - split2 "4.2.0" - "@tryghost/elasticsearch@^3.0.24": version "3.0.24" resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch/-/elasticsearch-3.0.24.tgz#291d51d6a6cc607fa860612dfc37098c4ebdedc4" @@ -9681,15 +9615,6 @@ "@tryghost/debug" "^0.1.35" split2 "4.2.0" -"@tryghost/elasticsearch@^5.0.3": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch/-/elasticsearch-5.0.3.tgz#fedd7e1571cad292aa959bc9e1001028356c39cf" - integrity sha512-cZnVZpMqmLFJ0ElX5Y3iDygyIB+sKjx9GCnXaVDfk7GZjFl8dB+5JG1eHUUgiJjZWUxYnUIQ+NqCpFnmMZEvGg== - dependencies: - "@elastic/elasticsearch" "9.3.4" - "@tryghost/debug" "^2.0.3" - split2 "4.2.0" - "@tryghost/email-mock-receiver@0.3.11": version "0.3.11" resolved "https://registry.yarnpkg.com/@tryghost/email-mock-receiver/-/email-mock-receiver-0.3.11.tgz#3ff3e23d0b755480b2c756d1018ee5ece5b1e04e" @@ -9711,36 +9636,7 @@ focus-trap "^6.7.2" postcss-preset-env "^7.3.1" -"@tryghost/errors@1.3.6": - version "1.3.6" - resolved "https://registry.yarnpkg.com/@tryghost/errors/-/errors-1.3.6.tgz#b34993d03122a59f29bf7050a3c0bc90a23a7254" - integrity sha512-qxl6wF5tlhr646Earjmfcz3km6d+B0tzUmocyVu3tY8StI4pH8mLgzHDtkiTAls9ABPichBxZQe6a8PDcVJbFw== - dependencies: - "@stdlib/utils-copy" "^0.2.0" - uuid "^9.0.0" - -"@tryghost/errors@1.3.8": - version "1.3.8" - resolved "https://registry.yarnpkg.com/@tryghost/errors/-/errors-1.3.8.tgz#d4212b52e876360b4fa8e303ae13ef86f3e95846" - integrity sha512-2+4ExAAWM0rFWC7FlzxQMzIvIEQKt/vQLxLyqJqCmAcJa4i2uqUPJHqd/X5BBRuSTuftgca7EAo7adESbHEkZw== - dependencies: - "@stdlib/utils-copy" "^0.2.0" - uuid "^9.0.0" - -"@tryghost/errors@2.2.1": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@tryghost/errors/-/errors-2.2.1.tgz#e6b98aa35d077c240d4cf00eb0a788f3775258c0" - integrity sha512-7zR5Wu9UMalFZbWZs3nN3MzJg6mKek11W51AP8UGz5sN4fQofoeLwXHLI/AuT7nDuWiJZHsJcI1EGJg0iQHV3g== - dependencies: - "@stdlib/utils-copy" "^0.2.0" - uuid "^13.0.0" - -"@tryghost/errors@3.0.3", "@tryghost/errors@^3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@tryghost/errors/-/errors-3.0.3.tgz#73444013e7d516bf3177e2ddeb1b0ead468ab132" - integrity sha512-0hRFMQvsNPbojPS7xDuJEe4eNzWSQ6v2rLFXlTCeY7K7Lh4GXRmsI846SxPXwH8moaWIsrLP4xTnmIp8Tgs/Lw== - -"@tryghost/errors@^1.2.26", "@tryghost/errors@^1.2.3", "@tryghost/errors@^1.3.8", "@tryghost/errors@^1.3.9": +"@tryghost/errors@1.3.6", "@tryghost/errors@1.3.8", "@tryghost/errors@2.2.1", "@tryghost/errors@3.0.3", "@tryghost/errors@^1.2.26", "@tryghost/errors@^1.2.3", "@tryghost/errors@^1.3.7", "@tryghost/errors@^1.3.8", "@tryghost/errors@^1.3.9", "@tryghost/errors@^3.0.3": version "1.3.13" resolved "https://registry.yarnpkg.com/@tryghost/errors/-/errors-1.3.13.tgz#e604160bd5a4b26b6c30cdc82dad22d9ce892dce" integrity sha512-sXFcuU8Nn3mDcVrLBLFThQZImn+T2w7v/jJGJzdFnSKJstqxd1LyTRlMjpCpgCnqAgHFCaP+uFg7x4CX64VBJQ== @@ -9788,14 +9684,6 @@ resolved "https://registry.yarnpkg.com/@tryghost/http-cache-utils/-/http-cache-utils-0.1.20.tgz#5a513593a2d5d944389a88eb56a8614cd0599350" integrity sha512-8+GbDQ/xo97orv5UUYv2x92e3RovVurYoFcBY+e0IJlExgSxP7oOqskqgFfZVs5Hhwi+SpZs6gFw2cv1OlUY9g== -"@tryghost/http-stream@^0.1.34": - version "0.1.42" - resolved "https://registry.yarnpkg.com/@tryghost/http-stream/-/http-stream-0.1.42.tgz#75f6730475156627e2441119551866419d1ce1b0" - integrity sha512-wQBwTMLaMAFmKwi0nQeRPcnRvQYFtgYp9d4dJx0b1IkHLmhRwoRGcOkxYd6ajSEGfv7w0GkaaQH4Qd2s/pzuzw== - dependencies: - "@tryghost/errors" "^1.3.9" - "@tryghost/request" "^1.0.13" - "@tryghost/http-stream@^0.1.37": version "0.1.37" resolved "https://registry.yarnpkg.com/@tryghost/http-stream/-/http-stream-0.1.37.tgz#319f0c8691b75fc5949a1a9c866bd410f6c52f05" @@ -9804,14 +9692,6 @@ "@tryghost/errors" "^1.3.8" "@tryghost/request" "^1.0.12" -"@tryghost/http-stream@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@tryghost/http-stream/-/http-stream-2.0.3.tgz#c723d3c6cca6578abbc4507dc6ef0244c83d912e" - integrity sha512-FBsGfqYA7wt+BP+y1MOOS1safTMpNp5nuN/WigE7+bkgwEbHrxDmqQDF+k7ZkOpjwJvsKC+msspijsNXFmAvKQ== - dependencies: - "@tryghost/errors" "^3.0.3" - "@tryghost/request" "^3.0.3" - "@tryghost/image-transform@1.4.13": version "1.4.13" resolved "https://registry.yarnpkg.com/@tryghost/image-transform/-/image-transform-1.4.13.tgz#5c08ee3e6f61a2e2bd7786c0c73bc474079e9d45" @@ -10050,41 +9930,7 @@ lodash "^4.17.21" luxon "^1.26.0" -"@tryghost/logging@2.4.19": - version "2.4.19" - resolved "https://registry.yarnpkg.com/@tryghost/logging/-/logging-2.4.19.tgz#8aab372486268b6fc8e31615b6e79d59b299a44f" - integrity sha512-NCCElue4AqvfhLnJLjDDR1uXBXQwfDOuGZTSI9/relqj+cfxwezQuPGG66EkPEB29ZAdtnHLOqMJTF2k6/s/hw== - dependencies: - "@tryghost/bunyan-rotating-filestream" "^0.0.7" - "@tryghost/elasticsearch" "^3.0.22" - "@tryghost/http-stream" "^0.1.34" - "@tryghost/pretty-stream" "^0.1.27" - "@tryghost/root-utils" "^0.3.31" - bunyan "^1.8.15" - bunyan-loggly "^1.4.2" - fs-extra "^11.0.0" - gelf-stream "^1.1.1" - json-stringify-safe "^5.0.1" - lodash "^4.17.21" - -"@tryghost/logging@2.4.23": - version "2.4.23" - resolved "https://registry.yarnpkg.com/@tryghost/logging/-/logging-2.4.23.tgz#a2351b55c0d413e3f06ee41cbcfe7bbdff785972" - integrity sha512-xmindrXwW0zKvuxdTNkyK3TM1exPvgkqqSRXOdf8G1SDZpuvemWGlrllAFQuj2J/K4dy9CWcYl9GpM1wYaG1CQ== - dependencies: - "@tryghost/bunyan-rotating-filestream" "^0.0.7" - "@tryghost/elasticsearch" "^3.0.24" - "@tryghost/http-stream" "^0.1.37" - "@tryghost/pretty-stream" "^0.2.0" - "@tryghost/root-utils" "^0.3.33" - bunyan "^1.8.15" - bunyan-loggly "^1.4.2" - fs-extra "^11.0.0" - gelf-stream "^1.1.1" - json-stringify-safe "^5.0.1" - lodash "^4.17.21" - -"@tryghost/logging@2.5.0", "@tryghost/logging@^2.4.23": +"@tryghost/logging@2.4.19", "@tryghost/logging@2.4.23", "@tryghost/logging@2.5.0", "@tryghost/logging@4.0.3", "@tryghost/logging@^2.4.23", "@tryghost/logging@^4.0.3": version "2.5.0" resolved "https://registry.yarnpkg.com/@tryghost/logging/-/logging-2.5.0.tgz#239fc3518cba48ff18794dfa24be44c5d2ba7a45" integrity sha512-fLPWNbghbdsUIH/7+CPmhT8l1MhZPI6za1Zm65rcwjGpaX+1Cr1ehcj22rMm91sl4A8AVe8vpYofRKM3UL8GIQ== @@ -10101,23 +9947,6 @@ json-stringify-safe "^5.0.1" lodash "^4.17.21" -"@tryghost/logging@4.0.3", "@tryghost/logging@^4.0.3": - version "4.0.3" - resolved "https://registry.yarnpkg.com/@tryghost/logging/-/logging-4.0.3.tgz#6a8394b7fbba06068d4c27da5864c6f5e439934b" - integrity sha512-LfuThGHqLQ6Y5aijh2s4YH68wPZhwgWUIxQMItPatY5NYaHn+aiTnpCi4oMZSBSMgYIPpEgjL1maoDf35sEhoQ== - dependencies: - "@tryghost/bunyan-rotating-filestream" "0.0.7" - "@tryghost/elasticsearch" "^5.0.3" - "@tryghost/http-stream" "^2.0.3" - "@tryghost/pretty-stream" "^2.0.3" - "@tryghost/root-utils" "^2.0.3" - bunyan "1.8.15" - bunyan-loggly "2.0.1" - fs-extra "11.3.4" - gelf-stream "1.1.1" - json-stringify-safe "5.0.1" - lodash "4.17.23" - "@tryghost/members-csv@2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@tryghost/members-csv/-/members-csv-2.0.3.tgz#6affa02dd158ff5c01fd420774ddd59b69c51824" @@ -10223,15 +10052,6 @@ chalk "5.6.2" sywac "1.3.0" -"@tryghost/pretty-stream@^0.1.27": - version "0.1.29" - resolved "https://registry.yarnpkg.com/@tryghost/pretty-stream/-/pretty-stream-0.1.29.tgz#e0bbab7333a6cac38fdfa19d3da86aeaaded7f02" - integrity sha512-HByPoCd5R63bRg34wZ4D7bfCeBsLTP3gLCi5xVsOnETxB4GiHHo31/vm+kI8pZd7mnWMre3nnDdVR0Sf72U0eQ== - dependencies: - date-format "^4.0.14" - lodash "^4.17.21" - prettyjson "^1.2.5" - "@tryghost/pretty-stream@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@tryghost/pretty-stream/-/pretty-stream-0.2.0.tgz#622e6c37a6a8f3f724e10e2980f51ec8efae96bc" @@ -10241,15 +10061,6 @@ lodash "^4.17.21" prettyjson "^1.2.5" -"@tryghost/pretty-stream@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@tryghost/pretty-stream/-/pretty-stream-2.0.3.tgz#b9b2d1ef46ec76ebdac616f1df63958ec0de051b" - integrity sha512-9ZMHeR8BIDZcvHuliNE/kA+iJPjIzQldLops37vlqLFANGXdQlybatZxGERdI11Qv47J//AoBMCexyY4Znwmwg== - dependencies: - date-format "4.0.14" - lodash "4.17.23" - prettyjson "1.2.5" - "@tryghost/prometheus-metrics@1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@tryghost/prometheus-metrics/-/prometheus-metrics-1.0.2.tgz#e4ee48a846dcf3d5883848842ea24519d0372dbf" @@ -10292,30 +10103,6 @@ got "13.0.0" lodash "^4.17.21" -"@tryghost/request@^1.0.13": - version "1.0.17" - resolved "https://registry.yarnpkg.com/@tryghost/request/-/request-1.0.17.tgz#b31f43644b2d8106236edf4efbaa4195efe3f5f6" - integrity sha512-YbxolgTuIORAUUSjfQWJfG1U2sSlZMhXjl+M7/NLvIgCc+Uzx8XjpIrYW6NDAn+lTt9/WAx/36EWr3nzVnuTHg== - dependencies: - "@tryghost/errors" "^1.3.9" - "@tryghost/validator" "^0.2.18" - "@tryghost/version" "^0.1.34" - cacheable-lookup "7.0.0" - got "14.6.6" - lodash "^4.17.21" - -"@tryghost/request@^3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@tryghost/request/-/request-3.0.3.tgz#fff077cb21cbd0ca5021a9c5f51db49372efa3db" - integrity sha512-b70ZhGOyIrsFhRAls9Fz0nN8fpa1NaUEUX/DfCLWAsoQn9S8YRe+HemCGsNbLMnzxAhWeuuUm0emtm+paoTePw== - dependencies: - "@tryghost/errors" "^3.0.3" - "@tryghost/validator" "^2.0.3" - "@tryghost/version" "^2.0.3" - cacheable-lookup "7.0.0" - got "14.6.6" - lodash "4.17.23" - "@tryghost/root-utils@0.3.33": version "0.3.33" resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.33.tgz#208e3d15520131c2d4157c7e62fe74771a7a110f" @@ -10324,14 +10111,6 @@ caller "^1.0.1" find-root "^1.1.0" -"@tryghost/root-utils@^0.3.31": - version "0.3.38" - resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.38.tgz#7f9e52782858f82bb1799adaa7c5a8ced74e1ffe" - integrity sha512-ARn8wC6qv867lCr7BZ+IS8S/88K5go0j6HgQFhP27rKje4b40PsxH/P3rO4Ez2NzF/Do8ywFrTWHoLCSsCazXQ== - dependencies: - caller "^1.0.1" - find-root "^1.1.0" - "@tryghost/root-utils@^0.3.33", "@tryghost/root-utils@^0.3.34": version "0.3.34" resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.34.tgz#db131568cf04069929a27fb8ed519b16a2226a5d" @@ -10449,17 +10228,6 @@ moment-timezone "^0.5.23" validator "7.2.0" -"@tryghost/validator@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-2.0.3.tgz#b2b7118c62f335c6febd9db9b2e6e7ae23c66f56" - integrity sha512-k8XZIaeSjqL+zzYzYkUO491tqvduXZGAkDkimsDABPeVE5SNSFXQGaHcMb97YQa0Br1lRDKQl08KY2UboTv0zA== - dependencies: - "@tryghost/errors" "^3.0.3" - "@tryghost/tpl" "^2.0.3" - lodash "4.17.23" - moment-timezone "^0.5.23" - validator "7.2.0" - "@tryghost/version@0.1.33", "@tryghost/version@^0.1.33": version "0.1.33" resolved "https://registry.yarnpkg.com/@tryghost/version/-/version-0.1.33.tgz#1476e35c48e4555d23bbd322d737ba57b21cfa7b" @@ -10468,22 +10236,6 @@ "@tryghost/root-utils" "^0.3.33" semver "^7.3.5" -"@tryghost/version@^0.1.34": - version "0.1.38" - resolved "https://registry.yarnpkg.com/@tryghost/version/-/version-0.1.38.tgz#dbe7da7750cd384e5db6b6e4da22e8c107c1f38d" - integrity sha512-kVaM7eijFRrBLZLDbmiFxHQ/shmAg5UQ9yM4s9GZN4M3OWY34UHNmjoSOniOHnA5E8fYNA8A3IFmS3FW910xYg== - dependencies: - "@tryghost/root-utils" "^0.3.34" - semver "^7.3.5" - -"@tryghost/version@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@tryghost/version/-/version-2.0.3.tgz#b50c10ee773b1235c88b57404a6715988e2d9c47" - integrity sha512-HnDk3IaoQ2L0okymdesmN6l+92/O33WawtrIsQWcXLSoSJroHtbgUaK6G2iMhGQnsTtuwGZ1Fe2iJcFXeySS0A== - dependencies: - "@tryghost/root-utils" "^2.0.3" - semver "7.7.4" - "@tryghost/webhook-mock-receiver@0.2.17": version "0.2.17" resolved "https://registry.yarnpkg.com/@tryghost/webhook-mock-receiver/-/webhook-mock-receiver-0.2.17.tgz#4c72be56ca5471e8e599f16db3163955718a90a7" @@ -10669,16 +10421,6 @@ dependencies: "@types/color-convert" "*" -"@types/command-line-args@^5.2.3": - version "5.2.3" - resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.3.tgz#553ce2fd5acf160b448d307649b38ffc60d39639" - integrity sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw== - -"@types/command-line-usage@^5.0.4": - version "5.0.4" - resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.4.tgz#374e4c62d78fbc5a670a0f36da10235af879a0d5" - integrity sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg== - "@types/common-tags@1.8.4": version "1.8.4" resolved "https://registry.yarnpkg.com/@types/common-tags/-/common-tags-1.8.4.tgz#3b31fcb5952cd326a55cabe9dbe6c5be3c1671a0" @@ -10891,11 +10633,6 @@ resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz#abe102d06ccda1efdf0ed98c10ccf7f36a785a41" integrity sha512-FD+nQWA2zJjh4L9+pFXqWOi0Hs1ryBCfI+985NjluQ1p8EYtoLvjLOKidXBtZ4/IcxDX4o8/E8qDS3540tNliw== -"@types/http-cache-semantics@^4.0.4": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#f6a7788f438cbfde15f29acad46512b4c01913b3" - integrity sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q== - "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -11056,13 +10793,6 @@ dependencies: undici-types "~5.26.4" -"@types/node@^24.0.3": - version "24.12.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-24.12.2.tgz#353cb161dbf1785ea25e8829ba7ec574c5c629ac" - integrity sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g== - dependencies: - undici-types "~7.16.0" - "@types/node@^9.6.0": version "9.6.61" resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.61.tgz#29f124eddd41c4c74281bd0b455d689109fc2a2d" @@ -12468,21 +12198,6 @@ anymatch@^3.0.3, anymatch@^3.1.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -"apache-arrow@18.x - 21.x": - version "21.1.0" - resolved "https://registry.yarnpkg.com/apache-arrow/-/apache-arrow-21.1.0.tgz#0b8d0a844d7a86cc29902f3561bf8be7844b7b36" - integrity sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA== - dependencies: - "@swc/helpers" "^0.5.11" - "@types/command-line-args" "^5.2.3" - "@types/command-line-usage" "^5.0.4" - "@types/node" "^24.0.3" - command-line-args "^6.0.1" - command-line-usage "^7.0.1" - flatbuffers "^25.1.24" - json-bignum "^0.0.3" - tslib "^2.6.2" - app-root-dir@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/app-root-dir/-/app-root-dir-1.0.2.tgz#38187ec2dea7577fff033ffcb12172692ff6e118" @@ -12642,11 +12357,6 @@ arr-union@^3.1.0: resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q== -array-back@^6.2.2, array-back@^6.2.3: - version "6.2.3" - resolved "https://registry.yarnpkg.com/array-back/-/array-back-6.2.3.tgz#d41e67598805e614f23b319b9e5960dfbcd72ae2" - integrity sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw== - array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" @@ -15121,14 +14831,6 @@ bundle-name@^4.1.0: dependencies: run-applescript "^7.0.0" -bunyan-loggly@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/bunyan-loggly/-/bunyan-loggly-2.0.1.tgz#d65ea3ad0647ed8a2095d0a802a4306fe4a3e174" - integrity sha512-9F+BYVqqtrYHmWfDE2JLoQmQmZod7AQQuRtAS41qaXa5wjiOviFNnMlY56gZmv5SZ2czbeuWeX+xSAEFcktsXw== - dependencies: - json-stringify-safe "^5.0.1" - node-loggly-bulk "^3.0.1" - bunyan-loggly@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/bunyan-loggly/-/bunyan-loggly-1.4.2.tgz#dda0fb18f487fa150a79728e906d83e871d235e9" @@ -15137,7 +14839,7 @@ bunyan-loggly@^1.4.2: json-stringify-safe "^5.0.1" node-loggly-bulk "^2.2.4" -bunyan@1.8.15, bunyan@^1.8.15: +bunyan@^1.8.15: version "1.8.15" resolved "https://registry.yarnpkg.com/bunyan/-/bunyan-1.8.15.tgz#8ce34ca908a17d0776576ca1b2f6cbd916e93b46" integrity sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig== @@ -15154,11 +14856,6 @@ busboy@^1.6.0: dependencies: streamsearch "^1.1.0" -byte-counter@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/byte-counter/-/byte-counter-0.1.0.tgz#c49760b5790e50e942a0d57a57b3fc0e94488dcc" - integrity sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ== - bytes@1: version "1.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" @@ -15308,19 +15005,6 @@ cacheable-request@^10.2.8: normalize-url "^8.0.0" responselike "^3.0.0" -cacheable-request@^13.0.12: - version "13.0.18" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-13.0.18.tgz#b256c499df6c266d3c2ed075009f54e6d08fe431" - integrity sha512-rFWadDRKJs3s2eYdXlGggnBZKG7MTblkFBB0YllFds+UYnfogDp2wcR6JN97FhRkHTvq59n2vhNoHNZn29dh/Q== - dependencies: - "@types/http-cache-semantics" "^4.0.4" - get-stream "^9.0.1" - http-cache-semantics "^4.2.0" - keyv "^5.5.5" - mimic-response "^4.0.0" - normalize-url "^8.1.1" - responselike "^4.0.2" - cacheable-request@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27" @@ -15510,13 +15194,6 @@ chai@^6.0.1: resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.0.tgz#181bca6a219cddb99c3eeefb82483800ffa550ce" integrity sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA== -chalk-template@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/chalk-template/-/chalk-template-0.4.0.tgz#692c034d0ed62436b9062c1707fadcd0f753204b" - integrity sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg== - dependencies: - chalk "^4.1.2" - chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -16150,26 +15827,6 @@ combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -command-line-args@^6.0.1: - version "6.0.2" - resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-6.0.2.tgz#a5b8a87d3f12aa1707c122c5ed38bc64535514a0" - integrity sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ== - dependencies: - array-back "^6.2.3" - find-replace "^5.0.2" - lodash.camelcase "^4.3.0" - typical "^7.3.0" - -command-line-usage@^7.0.1: - version "7.0.4" - resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-7.0.4.tgz#759449bac39c5410e23513f1f78551b669df1514" - integrity sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg== - dependencies: - array-back "^6.2.2" - chalk-template "^0.4.0" - table-layout "^4.1.1" - typical "^7.3.0" - commander@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/commander/-/commander-0.6.1.tgz#fa68a14f6a945d54dbbe50d8cdb3320e9e3b1a06" @@ -17319,7 +16976,7 @@ date-fns@2.30.0, date-fns@^2.28.0, date-fns@^2.30.0: dependencies: "@babel/runtime" "^7.21.0" -date-format@4.0.14, date-format@^4.0.14: +date-format@^4.0.14: version "4.0.14" resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.14.tgz#7a8e584434fb169a521c8b7aa481f355810d9400" integrity sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg== @@ -17442,13 +17099,6 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og== -decompress-response@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-10.0.0.tgz#d8abd2a4c136c3b99b49a08d1f9a709fe35675a4" - integrity sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q== - dependencies: - mimic-response "^4.0.0" - decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -21368,11 +21018,6 @@ find-index@^1.1.0: resolved "https://registry.yarnpkg.com/find-index/-/find-index-1.1.1.tgz#4b221f8d46b7f8bea33d8faed953f3ca7a081cbc" integrity sha512-XYKutXMrIK99YMUPf91KX5QVJoG31/OsgftD6YoTPAObfQIxM4ziA9f0J1AsqKhJmo+IeaIPP0CFopTD4bdUBw== -find-replace@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-5.0.2.tgz#fe27ff0be05975aef6fc679c1139bbabea564e26" - integrity sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q== - find-root@1.1.0, find-root@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" @@ -21540,11 +21185,6 @@ flat@^5.0.2: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== -flatbuffers@^25.1.24: - version "25.9.23" - resolved "https://registry.yarnpkg.com/flatbuffers/-/flatbuffers-25.9.23.tgz#346811557fe9312ab5647535e793c761e9c81eb1" - integrity sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ== - flatted@^3.2.9, flatted@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" @@ -21627,11 +21267,6 @@ form-data-encoder@^2.1.2: resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== -form-data-encoder@^4.0.2: - version "4.1.0" - resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-4.1.0.tgz#497cedc94810bd5d53b99b5d4f6c152d5cbc9db2" - integrity sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw== - form-data@4.0.5, form-data@^4.0.0, form-data@^4.0.2, form-data@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" @@ -21989,7 +21624,7 @@ gauge@~2.7.3: strip-ansi "^3.0.1" wide-align "^1.1.0" -gelf-stream@1.1.1, gelf-stream@^1.1.1: +gelf-stream@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/gelf-stream/-/gelf-stream-1.1.1.tgz#9cea9b6386ac301c741838ca3cb91e66dbfbf669" integrity sha512-kCzCfI6DJ8+aaDhwMcsNm2l6CsBj6y4Is6CCxH2W9sYnZGcXg9WmJ/iZMoJVO6uTwTRL7dbIioAS8lCuGUXSFA== @@ -22096,14 +21731,6 @@ get-stream@^8.0.1: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-8.0.1.tgz#def9dfd71742cd7754a7761ed43749a27d02eca2" integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== -get-stream@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-9.0.1.tgz#95157d21df8eb90d1647102b63039b1df60ebd27" - integrity sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA== - dependencies: - "@sec-ant/readable-stream" "^0.4.1" - is-stream "^4.0.1" - get-symbol-description@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" @@ -22443,24 +22070,6 @@ got@13.0.0: p-cancelable "^3.0.0" responselike "^3.0.0" -got@14.6.6: - version "14.6.6" - resolved "https://registry.yarnpkg.com/got/-/got-14.6.6.tgz#5adf7f576f89de8e291fd782e8b277f9bcc8e6c0" - integrity sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg== - dependencies: - "@sindresorhus/is" "^7.0.1" - byte-counter "^0.1.0" - cacheable-lookup "^7.0.0" - cacheable-request "^13.0.12" - decompress-response "^10.0.0" - form-data-encoder "^4.0.2" - http2-wrapper "^2.2.1" - keyv "^5.5.3" - lowercase-keys "^3.0.0" - p-cancelable "^4.0.1" - responselike "^4.0.2" - type-fest "^4.26.1" - got@~11.8.0: version "11.8.6" resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a" @@ -22830,11 +22439,6 @@ hpagent@^1.0.0: resolved "https://registry.yarnpkg.com/hpagent/-/hpagent-1.1.0.tgz#9ff305d6716652bbfe9e300d1865d2c51f284f47" integrity sha512-bgJcBmNTZaJO03xtXOTNfoFEf/3VwoZ/gJ2O4ekTCZu4LSFtfzQFrJ0kjq8ZSS0+IdghXqQIiDUnpp0eUR9IJg== -hpagent@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/hpagent/-/hpagent-1.2.0.tgz#0ae417895430eb3770c03443456b8d90ca464903" - integrity sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA== - hsl-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" @@ -22986,11 +22590,6 @@ http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0, http-cache-semantics@^ resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== -http-cache-semantics@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" - integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ== - http-errors@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" @@ -23087,14 +22686,6 @@ http2-wrapper@^2.1.10: quick-lru "^5.1.1" resolve-alpn "^1.2.0" -http2-wrapper@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-2.2.1.tgz#310968153dcdedb160d8b72114363ef5fce1f64a" - integrity sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ== - dependencies: - quick-lru "^5.1.1" - resolve-alpn "^1.2.0" - httpntlm@1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/httpntlm/-/httpntlm-1.6.1.tgz#ad01527143a2e8773cfae6a96f58656bb52a34b2" @@ -23141,12 +22732,10 @@ human-interval@^2.0.0: dependencies: numbered "^1.1.0" -human-number@2.0.8: - version "2.0.8" - resolved "https://registry.yarnpkg.com/human-number/-/human-number-2.0.8.tgz#b19658cde210226862d1a7dc3bcec805b32b9eaf" - integrity sha512-0OikVKOKQR86Plj4Oyszl4TNYu4a9dsKeZKaZw6uHw+7zCDXZ3E3ldG/3ZRddwRajYljdZsMKFcvrNNXHR6wVg== - dependencies: - round-to "~5.0.0" +human-number@2.0.9: + version "2.0.9" + resolved "https://registry.yarnpkg.com/human-number/-/human-number-2.0.9.tgz#afda90d34bc0165698270878417888aef4aa46d5" + integrity sha512-6qnNEP4hVdM0b/e3c95AZK0z+3kjrcZbi4zlHK3P9BULiRkX1WLBnfjhTG4NgP4e8vTiPTl3ltDJ4kRHA+PKPA== human-signals@^1.1.1: version "1.1.1" @@ -24089,11 +23678,6 @@ is-stream@^3.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== -is-stream@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-4.0.1.tgz#375cf891e16d2e4baec250b85926cffc14720d9b" - integrity sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A== - is-string-and-not-blank@^0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/is-string-and-not-blank/-/is-string-and-not-blank-0.0.2.tgz#cd19eded2ca4a514f79ca528915f1fb28e5dd38a" @@ -24399,10 +23983,10 @@ iterator.prototype@^1.1.4: has-symbols "^1.1.0" set-function-name "^2.0.2" -jackspeak@^3.1.2: - version "3.4.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" - integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== +jackspeak@2.3.6, jackspeak@^3.1.2: + version "2.3.6" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" + integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== dependencies: "@isaacs/cliui" "^8.0.2" optionalDependencies: @@ -25140,11 +24724,6 @@ jsesc@~0.5.0: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== -json-bignum@^0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/json-bignum/-/json-bignum-0.0.3.tgz#41163b50436c773d82424dbc20ed70db7604b8d7" - integrity sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg== - json-buffer@3.0.1, json-buffer@~3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" @@ -25191,7 +24770,7 @@ json-stable-stringify@1.3.0, json-stable-stringify@^1.0.0, json-stable-stringify jsonify "^0.0.1" object-keys "^1.1.1" -json-stringify-safe@5.0.1, json-stringify-safe@5.0.x, json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: +json-stringify-safe@5.0.x, json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== @@ -25388,13 +24967,6 @@ keyv@*, keyv@^4.0.0, keyv@^4.5.3, keyv@^4.5.4: dependencies: json-buffer "3.0.1" -keyv@^5.5.3, keyv@^5.5.5: - version "5.6.0" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-5.6.0.tgz#03044074c6b4d072d0a62c7b9fa649537baf0105" - integrity sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw== - dependencies: - "@keyv/serialize" "^1.1.1" - kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -27591,35 +27163,18 @@ module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== -moment-timezone@0.5.45, moment-timezone@^0.5.23, moment-timezone@^0.5.31, moment-timezone@^0.5.33: +moment-timezone@0.5.45, moment-timezone@^0.5.23, moment-timezone@^0.5.31, moment-timezone@^0.5.33, moment-timezone@^0.5.48: version "0.5.45" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c" integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ== dependencies: moment "^2.29.4" -moment-timezone@^0.5.48: - version "0.5.48" - resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.48.tgz#111727bb274734a518ae154b5ca589283f058967" - integrity sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw== - dependencies: - moment "^2.29.4" - -moment@2.24.0, moment@^2.10.2, moment@^2.18.1, moment@^2.19.3: +moment@2.24.0, moment@2.29.4, moment@2.30.1, moment@^2.10.2, moment@^2.18.1, moment@^2.19.3, moment@^2.27.0, moment@^2.29.4: version "2.24.0" resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== -moment@2.29.4: - version "2.29.4" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" - integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== - -moment@2.30.1, moment@^2.27.0, moment@^2.29.4: - version "2.30.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" - integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== - moo@^0.5.0, moo@^0.5.1: version "0.5.2" resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c" @@ -28091,15 +27646,6 @@ node-loggly-bulk@^2.2.4: moment "^2.18.1" request ">=2.76.0 <3.0.0" -node-loggly-bulk@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/node-loggly-bulk/-/node-loggly-bulk-3.0.1.tgz#5aba3b032b366b9bb892b22095103637c8919512" - integrity sha512-KrqNHqseUnGNGz4q1ZsnfD10W/KQkwFLpZjTHp98TPUXEqZ8zytEAugfFeiPMz/YMzgY4hqGI+6hsMIPhzaE4A== - dependencies: - json-stringify-safe "5.0.x" - moment "^2.18.1" - request ">=2.88.2 <3.0.0" - node-machine-id@1.1.12: version "1.1.12" resolved "https://registry.yarnpkg.com/node-machine-id/-/node-machine-id-1.1.12.tgz#37904eee1e59b320bb9c5d6c0a59f3b469cb6267" @@ -28262,11 +27808,6 @@ normalize-url@^8.0.0: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-8.0.0.tgz#593dbd284f743e8dcf6a5ddf8fadff149c82701a" integrity sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw== -normalize-url@^8.1.1: - version "8.1.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-8.1.1.tgz#751a20c8520e5725404c06015fea21d7567f25ef" - integrity sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ== - normalize.css@3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-3.0.3.tgz#acc00262e235a2caa91363a2e5e3bfa4f8ad05c6" @@ -28377,7 +27918,7 @@ numbered@^1.1.0: resolved "https://registry.yarnpkg.com/numbered/-/numbered-1.1.0.tgz#9fcd79564c73a84b9574e8370c3d8e58fe3c133c" integrity sha512-pv/ue2Odr7IfYOO0byC1KgBI10wo5YDauLhxY6/saNzAdAs0r1SotGCPzzCLNPL0xtrAwWRialLu23AAu9xO1g== -nwsapi@^2.2.0, nwsapi@^2.2.12: +nwsapi@2.2.12, nwsapi@^2.2.0, nwsapi@^2.2.12: version "2.2.12" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.12.tgz#fb6af5c0ec35b27b4581eb3bbad34ec9e5c696f8" integrity sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w== @@ -28776,11 +28317,6 @@ p-cancelable@^3.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-3.0.0.tgz#63826694b54d61ca1c20ebcb6d3ecf5e14cd8050" integrity sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw== -p-cancelable@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-4.0.1.tgz#2d1edf1ab8616b72c73db41c4bc9ecdd10af640e" - integrity sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg== - p-defer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-3.0.0.tgz#d1dceb4ee9b2b604b1d94ffec83760175d4e6f83" @@ -30493,7 +30029,7 @@ pretty-ms@^3.1.0: dependencies: parse-ms "^1.0.0" -prettyjson@1.2.5, prettyjson@^1.2.5: +prettyjson@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/prettyjson/-/prettyjson-1.2.5.tgz#ef3cfffcc70505c032abc59785884b4027031835" integrity sha512-rksPWtoZb2ZpT5OVgtmy0KHVM+Dca3iVwWY9ifwhcexfjebtgjg3wmrUt9PvJ59XIYBcknQeYHD8IAnVlh9lAw== @@ -31813,7 +31349,7 @@ reqresnext@^1.7.0: lodash "^4.17.21" setprototypeof "^1.2.0" -"request@>=2.76.0 <3.0.0", "request@>=2.88.2 <3.0.0": +"request@>=2.76.0 <3.0.0": version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== @@ -32035,13 +31571,6 @@ responselike@^3.0.0: dependencies: lowercase-keys "^3.0.0" -responselike@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-4.0.2.tgz#d99a1105aeca5909c1e93156a839c7f3173e26c2" - integrity sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA== - dependencies: - lowercase-keys "^3.0.0" - restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -32230,11 +31759,6 @@ rope-sequence@^1.3.0: resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425" integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ== -round-to@~5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/round-to/-/round-to-5.0.0.tgz#a66292701a93b194f630a0d57f04c08821b6eeed" - integrity sha512-i4+Ntwmo5kY7UWWFSDEVN3RjT2PX1FqkZ9iCcAO3sKML3Ady9NgsjM/HLdYKUAnrxK4IlSvXzpBMDvMHZQALRQ== - route-recognizer@^0.3.3: version "0.3.4" resolved "https://registry.yarnpkg.com/route-recognizer/-/route-recognizer-0.3.4.tgz#39ab1ffbce1c59e6d2bdca416f0932611e4f3ca3" @@ -32531,11 +32055,6 @@ secure-json-parse@^2.4.0: resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.5.0.tgz#f929829df2adc7ccfb53703569894d051493a6ac" integrity sha512-ZQruFgZnIWH+WyO9t5rWt4ZEGqCKPwhiw+YbzTwpmT9elgLrLcfuyUiSnwwjUiVy9r4VM3urtbNF1xmEh9IL2w== -secure-json-parse@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c" - integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA== - secure-keys@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/secure-keys/-/secure-keys-1.0.0.tgz#f0c82d98a3b139a8776a8808050b824431087fca" @@ -34196,14 +33715,6 @@ tabbable@^5.3.3: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== -table-layout@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-4.1.1.tgz#0f72965de1a5c0c1419c9ba21cae4e73a2f73a42" - integrity sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA== - dependencies: - array-back "^6.2.2" - wordwrapjs "^5.1.0" - table@^6.8.1: version "6.8.1" resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf" @@ -34976,7 +34487,7 @@ tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.6.2, tslib@^2.8.0, tslib@^2.8.1: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.6.2, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -35184,11 +34695,6 @@ typescript@5.9.3, typescript@^5.0.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== -typical@^7.3.0: - version "7.3.0" - resolved "https://registry.yarnpkg.com/typical/-/typical-7.3.0.tgz#930376be344228709f134613911fa22aa09617a4" - integrity sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw== - ua-parser-js@1.0.41: version "1.0.41" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.41.tgz#bd04dc9ec830fcf9e4fad35cf22dcedd2e3b4e9c" @@ -35279,11 +34785,6 @@ undici-types@~7.10.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.10.0.tgz#4ac2e058ce56b462b056e629cc6a02393d3ff350" integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag== -undici-types@~7.16.0: - version "7.16.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" - integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== - undici@^5.22.1: version "5.22.1" resolved "https://registry.yarnpkg.com/undici/-/undici-5.22.1.tgz#877d512effef2ac8be65e695f3586922e1a57d7b" @@ -35291,11 +34792,6 @@ undici@^5.22.1: dependencies: busboy "^1.6.0" -undici@^7.19.1: - version "7.24.7" - resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.7.tgz#af9535341bbe80625ca403a02418477a5c6a8760" - integrity sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ== - undici@^7.21.0, undici@^7.24.3: version "7.24.4" resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.4.tgz#873bce680d7c6354c941399fd4e8ea4563de4ea7" @@ -35684,11 +35180,6 @@ uuid@^10.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== -uuid@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8" - integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w== - uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" @@ -36495,11 +35986,6 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -wordwrapjs@^5.1.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-5.1.1.tgz#bfd1eb426f0f7eec73b7df32cf7df1f618bfb3a9" - integrity sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg== - worker-farm@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8"