diff --git a/apps/admin-x-framework/src/api/config.ts b/apps/admin-x-framework/src/api/config.ts index 58b1155c70..2136e69de4 100644 --- a/apps/admin-x-framework/src/api/config.ts +++ b/apps/admin-x-framework/src/api/config.ts @@ -79,9 +79,8 @@ export const isManagedEmail = (config: Config) => { }; export const hasSendingDomain = (config: Config) => { - const isDomain = /[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/; const sendingDomain = config?.hostSettings?.managedEmail?.sendingDomain; - return typeof sendingDomain === 'string' && isDomain.test(sendingDomain); + return typeof sendingDomain === 'string' && sendingDomain.length > 0; }; export const sendingDomain = (config: Config) => { diff --git a/apps/admin-x-framework/src/api/site.ts b/apps/admin-x-framework/src/api/site.ts index 5367e757d9..f51d44890a 100644 --- a/apps/admin-x-framework/src/api/site.ts +++ b/apps/admin-x-framework/src/api/site.ts @@ -1,4 +1,5 @@ import {createQuery} from '../utils/api/hooks'; +import {Config, hasSendingDomain, isManagedEmail, sendingDomain} from './config'; // Types @@ -35,7 +36,11 @@ export function getHomepageUrl(siteData: SiteData): string { return `${url.origin}${subdir}`; } -export function getEmailDomain(siteData: SiteData): string { +export function getEmailDomain(siteData: SiteData, config: Config): string { + if (isManagedEmail(config) && hasSendingDomain(config)) { + return sendingDomain(config) || ''; + } + const domain = new URL(siteData.url).hostname || ''; if (domain.startsWith('www.')) { return domain.replace(/^(www)\.(?=[^/]*\..{2,5})/, ''); @@ -43,7 +48,7 @@ export function getEmailDomain(siteData: SiteData): string { return domain; } -export function fullEmailAddress(value: 'noreply' | string, siteData: SiteData) { - const emailDomain = getEmailDomain(siteData); +export function fullEmailAddress(value: 'noreply' | string, siteData: SiteData, config: Config) { + const emailDomain = getEmailDomain(siteData, config); return value === 'noreply' ? `noreply@${emailDomain}` : value; } diff --git a/apps/admin-x-framework/src/test/responses/settings.json b/apps/admin-x-framework/src/test/responses/settings.json index 27514a352f..ea3e443bc5 100644 --- a/apps/admin-x-framework/src/test/responses/settings.json +++ b/apps/admin-x-framework/src/test/responses/settings.json @@ -315,6 +315,14 @@ { "key": "firstpromoter_account", "value": null + }, + { + "key": "default_email_address", + "value": "default@example.com" + }, + { + "key": "support_email_address", + "value": "support@example.com" } ], "meta": { diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx index 54b934f4a7..ff750cb567 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx @@ -63,6 +63,10 @@ const features = [{ title: 'TK Reminders', description: 'Enables the TK Reminders feature in the editor', flag: 'tkReminders' +},{ + title: 'New email addresses', + description: 'For self hosters, forces the usage of the mail.from config as from address for all outgoing emails', + flag: 'newEmailAddresses' }]; const AlphaFeatures: React.FC = () => { diff --git a/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx b/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx index f3c0493f4c..8d47f79436 100644 --- a/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx @@ -5,27 +5,36 @@ import useFeatureFlag from '../../../../hooks/useFeatureFlag'; import useSettingGroup from '../../../../hooks/useSettingGroup'; import validator from 'validator'; import {Button, ButtonGroup, ColorPickerField, ConfirmationModal, Form, Heading, Hint, HtmlField, Icon, ImageUpload, LimitModal, PreviewModalContent, Select, SelectOption, Separator, SettingGroupContent, Tab, TabView, TextArea, TextField, Toggle, ToggleGroup, showToast} from '@tryghost/admin-x-design-system'; +import {Config, hasSendingDomain, isManagedEmail, sendingDomain} from '@tryghost/admin-x-framework/api/config'; import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter'; import {Newsletter, useBrowseNewsletters, useEditNewsletter} from '@tryghost/admin-x-framework/api/newsletters'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; -import {SiteData} from '@tryghost/admin-x-framework/api/site'; -import {fullEmailAddress} from '@tryghost/admin-x-framework/api/site'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {hasSendingDomain, isManagedEmail, sendingDomain} from '@tryghost/admin-x-framework/api/config'; import {textColorForBackgroundColor} from '@tryghost/color-utils'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; -const renderReplyToEmail = (newsletter: Newsletter, siteData: SiteData, membersSupportAddress?: string) => { +const renderFrom = (newsletter: Newsletter, config: Config, defaultEmailAddress: string|undefined) => { + if (isManagedEmail(config) && defaultEmailAddress) { + if (!hasSendingDomain(config)) { + // Not changeable: sender_email is ignored + return defaultEmailAddress; + } + } + + return newsletter.sender_email || defaultEmailAddress || ''; +}; + +const renderReplyToEmail = (newsletter: Newsletter, config: Config, supportEmailAddress: string|undefined, defaultEmailAddress: string|undefined) => { if (!newsletter.sender_reply_to) { return ''; } if (newsletter.sender_reply_to === 'newsletter') { - return fullEmailAddress(newsletter.sender_email || 'noreply', siteData); + return renderFrom(newsletter, config, defaultEmailAddress); } else if (newsletter.sender_reply_to === 'support') { - return fullEmailAddress(membersSupportAddress || 'noreply', siteData); + return supportEmailAddress || defaultEmailAddress || ''; } else { return newsletter.sender_reply_to; } @@ -42,7 +51,7 @@ const Sidebar: React.FC<{ const {mutateAsync: editNewsletter} = useEditNewsletter(); const limiter = useLimiter(); const {settings, siteData, config} = useGlobalData(); - const [membersSupportAddress, icon] = getSettingValues(settings, ['members_support_address', 'icon']); + const [icon, defaultEmailAddress, supportEmailAddress] = getSettingValues(settings, ['icon', 'default_email_address', 'support_email_address']); const {mutateAsync: uploadImage} = useUploadImage(); const [selectedTab, setSelectedTab] = useState('generalSettings'); const hasEmailCustomization = useFeatureFlag('emailCustomization'); @@ -50,12 +59,11 @@ const Sidebar: React.FC<{ const [siteTitle] = getSettingValues(localSettings, ['title']) as string[]; const handleError = useHandleError(); - const newsletterAddress = fullEmailAddress(newsletter.sender_email || 'noreply', siteData); - const supportAddress = fullEmailAddress(membersSupportAddress || 'noreply', siteData); + let newsletterAddress = renderFrom(newsletter, config, defaultEmailAddress); const replyToEmails = [ {label: `Newsletter address (${newsletterAddress})`, value: 'newsletter'}, - {label: `Support address (${supportAddress})`, value: 'support'} + {label: `Support address (${supportEmailAddress})`, value: 'support'} ]; const fontOptions: SelectOption[] = [ @@ -156,7 +164,7 @@ const Sidebar: React.FC<{ { heading: 'Sender email address', key: 'sender-email-addresss', - value: `${newsletter.sender_email}`, + value: `${defaultEmailAddress}`, hint: To customise, set up a custom sending domain } ]} @@ -206,7 +214,7 @@ const Sidebar: React.FC<{ hint={errors.sender_reply_to} placeholder={newsletterAddress} title="Reply-to email" - value={renderReplyToEmail(newsletter, siteData, membersSupportAddress)} + value={renderReplyToEmail(newsletter, config, supportEmailAddress, defaultEmailAddress)} onBlur={validate} onChange={e => updateNewsletter({sender_reply_to: e.target.value})} onKeyDown={() => clearError('sender_reply_to')} @@ -514,11 +522,11 @@ const Sidebar: React.FC<{ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: boolean;}> = ({newsletter, onlyOne}) => { const modal = useModal(); - const {siteData, settings, config} = useGlobalData(); + const {settings, config} = useGlobalData(); const {mutateAsync: editNewsletter} = useEditNewsletter(); const {updateRoute} = useRouting(); const handleError = useHandleError(); - const [membersSupportAddress] = getSettingValues(settings, ['members_support_address', 'icon']); + const [supportEmailAddress, defaultEmailAddress] = getSettingValues(settings, ['support_email_address', 'default_email_address']); const {formState, saveState, updateForm, setFormState, handleSave, validate, errors, clearError, okProps} = useForm({ initialState: newsletter, @@ -527,13 +535,15 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b const {newsletters, meta} = await editNewsletter(formState); if (meta?.sent_email_verification) { if (meta?.sent_email_verification[0] === 'sender_email') { + const previousFrom = renderFrom(newsletters[0], config, defaultEmailAddress); + NiceModal.show(ConfirmationModal, { title: 'Confirm newsletter email address', prompt: <> We‘ve sent a confirmation email to {formState.sender_email}. Until the address has been verified newsletters will be sent from the {newsletters[0].sender_email ? ' previous' : ' default'} email address - ({fullEmailAddress(newsletters[0].sender_email || 'noreply', siteData)}). + ({previousFrom}). , cancelLabel: '', onOk: (confirmModal) => { @@ -543,7 +553,7 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b } }); } else if (meta?.sent_email_verification[0] === 'sender_reply_to') { - const previousReplyTo = renderReplyToEmail(newsletters[0], siteData, membersSupportAddress); + const previousReplyTo = renderReplyToEmail(newsletters[0], config, supportEmailAddress, defaultEmailAddress); NiceModal.show(ConfirmationModal, { title: 'Confirm reply-to address', diff --git a/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterPreview.tsx b/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterPreview.tsx index d2cd901572..9b3585ded9 100644 --- a/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterPreview.tsx +++ b/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterPreview.tsx @@ -2,16 +2,15 @@ import NewsletterPreviewContent from './NewsletterPreviewContent'; import React from 'react'; import useFeatureFlag from '../../../../hooks/useFeatureFlag'; import {Newsletter} from '@tryghost/admin-x-framework/api/newsletters'; -import {fullEmailAddress} from '@tryghost/admin-x-framework/api/site'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {hasSendingDomain, isManagedEmail, sendingDomain} from '@tryghost/admin-x-framework/api/config'; +import {hasSendingDomain, isManagedEmail} from '@tryghost/admin-x-framework/api/config'; import {textColorForBackgroundColor} from '@tryghost/color-utils'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; const NewsletterPreview: React.FC<{newsletter: Newsletter}> = ({newsletter}) => { const hasEmailCustomization = useFeatureFlag('emailCustomization'); const {currentUser, settings, siteData, config} = useGlobalData(); - const [title, icon, commentsEnabled] = getSettingValues(settings, ['title', 'icon', 'comments_enabled']); + const [title, icon, commentsEnabled, defaultEmailAddress] = getSettingValues(settings, ['title', 'icon', 'comments_enabled', 'default_email_address']); let headerTitle: string | null = null; if (newsletter.show_header_title) { @@ -94,12 +93,13 @@ const NewsletterPreview: React.FC<{newsletter: Newsletter}> = ({newsletter}) => const renderSenderEmail = () => { if (isManagedEmail(config)) { - if (hasSendingDomain(config)) { - return newsletter.sender_email || 'noreply@' + sendingDomain(config); + if (!hasSendingDomain(config)) { + // Sender email is ignored + return defaultEmailAddress || ''; } } - return fullEmailAddress(newsletter.sender_email || 'noreply', siteData); + return newsletter.sender_email || defaultEmailAddress || ''; }; return void }> = ({updateSetting}) => { - const {siteData, settings} = useGlobalData(); + const {siteData, settings, config} = useGlobalData(); const [membersSupportAddress] = getSettingValues(settings, ['members_support_address']); - const emailDomain = getEmailDomain(siteData!); + const emailDomain = getEmailDomain(siteData!, config); - const [value, setValue] = useState(fullEmailAddress(membersSupportAddress?.toString() || '', siteData!)); + const [value, setValue] = useState(fullEmailAddress(membersSupportAddress?.toString() || '', siteData!, config)); const updateSupportAddress: FocusEventHandler = (e) => { let supportAddress = e.target.value; @@ -19,11 +19,11 @@ const AccountPage: React.FC<{ let settingValue = emailDomain && supportAddress === `noreply@${emailDomain}` ? 'noreply' : supportAddress; updateSetting('members_support_address', settingValue); - setValue(fullEmailAddress(settingValue, siteData!)); + setValue(fullEmailAddress(settingValue, siteData!, config)); }; useEffect(() => { - setValue(fullEmailAddress(membersSupportAddress?.toString() || '', siteData!)); + setValue(fullEmailAddress(membersSupportAddress?.toString() || '', siteData!, config)); }, [membersSupportAddress, siteData]); return
diff --git a/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx b/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx index 74052ab475..0d2d339825 100644 --- a/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx @@ -66,7 +66,7 @@ const PortalModal: React.FC = () => { const [selectedPreviewTab, setSelectedPreviewTab] = useState('signup'); const handleError = useHandleError(); - const {settings, siteData} = useGlobalData(); + const {settings, siteData, config} = useGlobalData(); const {mutateAsync: editSettings} = useEditSettings(); const {data: {tiers: allTiers} = {}} = useBrowseTiers(); // const tiers = getPaidActiveTiers(allTiers || []); @@ -141,7 +141,7 @@ const PortalModal: React.FC = () => { title: 'Confirm email address', prompt: <> We've sent a confirmation email to {newEmail}. - Until verified, your support address will remain {fullEmailAddress(currentEmail?.toString() || 'noreply', siteData!)}. + Until verified, your support address will remain {fullEmailAddress(currentEmail?.toString() || 'noreply', siteData!, config)}. , okLabel: 'Close', cancelLabel: '', diff --git a/apps/admin-x-settings/test/acceptance/email/newsletters.test.ts b/apps/admin-x-settings/test/acceptance/email/newsletters.test.ts index 6416c86536..92ac35701b 100644 --- a/apps/admin-x-settings/test/acceptance/email/newsletters.test.ts +++ b/apps/admin-x-settings/test/acceptance/email/newsletters.test.ts @@ -125,7 +125,7 @@ test.describe('Newsletter settings', async () => { await expect(page.getByTestId('confirmation-modal')).toHaveCount(1); await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm newsletter email address/); - await expect(page.getByTestId('confirmation-modal')).toHaveText(/default email address \(noreply@test.com\)/); + await expect(page.getByTestId('confirmation-modal')).toHaveText(/default email address \(default@example.com\)/); }); test('Displays the current email when changing sender address', async ({page}) => { @@ -246,7 +246,7 @@ test.describe('Newsletter settings', async () => { await expect(page.getByTestId('confirmation-modal')).toHaveCount(1); await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm reply-to address/); - await expect(page.getByTestId('confirmation-modal')).toHaveText(/previous reply-to address \(noreply@test.com\)/); + await expect(page.getByTestId('confirmation-modal')).toHaveText(/previous reply-to address \(support@example.com\)/); }); }); diff --git a/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js b/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js index 2063e7c714..bb59ba98c7 100644 --- a/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js +++ b/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js @@ -128,6 +128,10 @@ class SettingsHelpers { * @deprecated Use getDefaultEmail().address (without name) or EmailAddressParser.stringify(this.getDefaultEmail()) (with name) instead */ getNoReplyAddress() { + return this.getDefaultEmailAddress(); + } + + getDefaultEmailAddress() { return this.getDefaultEmail().address; } diff --git a/ghost/core/core/server/services/settings/settings-service.js b/ghost/core/core/server/services/settings/settings-service.js index a610aa719c..c507777d01 100644 --- a/ghost/core/core/server/services/settings/settings-service.js +++ b/ghost/core/core/server/services/settings/settings-service.js @@ -92,6 +92,10 @@ module.exports = { fields.push(new CalculatedField({key: 'firstpromoter_account', type: 'string', group: 'firstpromoter', fn: settingsHelpers.getFirstpromoterId.bind(settingsHelpers), dependents: ['firstpromoter', 'firstpromoter_id']})); fields.push(new CalculatedField({key: 'donations_enabled', type: 'boolean', group: 'donations', fn: settingsHelpers.areDonationsEnabled.bind(settingsHelpers), dependents: ['stripe_secret_key', 'stripe_publishable_key', 'stripe_connect_secret_key', 'stripe_connect_publishable_key']})); + // E-mail addresses + fields.push(new CalculatedField({key: 'default_email_address', type: 'string', group: 'email', fn: settingsHelpers.getDefaultEmailAddress.bind(settingsHelpers), dependents: ['labs']})); + fields.push(new CalculatedField({key: 'support_email_address', type: 'string', group: 'email', fn: settingsHelpers.getMembersSupportAddress.bind(settingsHelpers), dependents: ['labs', 'members_support_address']})); + return fields; }, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap index d4ff609a25..9c0098cccc 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap @@ -340,6 +340,14 @@ Object { "key": "donations_enabled", "value": true, }, + Object { + "key": "default_email_address", + "value": "noreply@127.0.0.1", + }, + Object { + "key": "support_email_address", + "value": "noreply@127.0.0.1", + }, ], } `; @@ -750,6 +758,14 @@ Object { "key": "donations_enabled", "value": true, }, + Object { + "key": "default_email_address", + "value": "noreply@127.0.0.1", + }, + Object { + "key": "support_email_address", + "value": "noreply@127.0.0.1", + }, ], } `; @@ -758,7 +774,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4367", + "content-length": "4487", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1108,6 +1124,14 @@ Object { "key": "donations_enabled", "value": true, }, + Object { + "key": "default_email_address", + "value": "noreply@127.0.0.1", + }, + Object { + "key": "support_email_address", + "value": "noreply@127.0.0.1", + }, ], } `; @@ -1465,6 +1489,14 @@ Object { "key": "donations_enabled", "value": true, }, + Object { + "key": "default_email_address", + "value": "noreply@127.0.0.1", + }, + Object { + "key": "support_email_address", + "value": "support@example.com", + }, ], } `; @@ -1827,6 +1859,14 @@ Object { "key": "donations_enabled", "value": true, }, + Object { + "key": "default_email_address", + "value": "noreply@127.0.0.1", + }, + Object { + "key": "support_email_address", + "value": "noreply@127.0.0.1", + }, ], } `; @@ -2277,6 +2317,14 @@ Object { "key": "donations_enabled", "value": true, }, + Object { + "key": "default_email_address", + "value": "noreply@127.0.0.1", + }, + Object { + "key": "support_email_address", + "value": "noreply@127.0.0.1", + }, ], } `; @@ -2699,6 +2747,14 @@ Object { "key": "donations_enabled", "value": true, }, + Object { + "key": "default_email_address", + "value": "noreply@127.0.0.1", + }, + Object { + "key": "support_email_address", + "value": "support@example.com", + }, ], } `; diff --git a/ghost/core/test/e2e-api/admin/settings.test.js b/ghost/core/test/e2e-api/admin/settings.test.js index b9de1a41d5..4db1d46005 100644 --- a/ghost/core/test/e2e-api/admin/settings.test.js +++ b/ghost/core/test/e2e-api/admin/settings.test.js @@ -9,7 +9,7 @@ const models = require('../../../core/server/models'); const {mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager'); const {anyErrorId} = matchers; -const CURRENT_SETTINGS_COUNT = 84; +const CURRENT_SETTINGS_COUNT = 86; const settingsMatcher = {}; diff --git a/ghost/email-addresses/src/EmailAddressService.ts b/ghost/email-addresses/src/EmailAddressService.ts index 70dd99be41..7457c50140 100644 --- a/ghost/email-addresses/src/EmailAddressService.ts +++ b/ghost/email-addresses/src/EmailAddressService.ts @@ -151,7 +151,7 @@ export class EmailAddressService { // Self hoster or legacy Ghost Pro return { allowed: true, - verificationEmailRequired: type === 'from' && !this.useNewEmailAddresses + verificationEmailRequired: !this.useNewEmailAddresses // Self hosters don't need to verify email addresses }; }