Added calculated email address settings (#19115)

fixes GRO-73

We need to avoid duplicating the complex logic for determining the
default email address and the support email address. So these are now
exposed as calculated settings.
This commit is contained in:
Simon Backx 2023-11-23 14:07:15 +01:00 committed by GitHub
parent a8083960d8
commit a037ce3dde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 129 additions and 39 deletions

View File

@ -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) => {

View File

@ -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;
}

View File

@ -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": {

View File

@ -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 = () => {

View File

@ -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<string>(settings, ['members_support_address', 'icon']);
const [icon, defaultEmailAddress, supportEmailAddress] = getSettingValues<string>(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: <span className="text-xs text-grey-700">To customise, set up a <a className="text-green" href="#">custom sending domain</a></span>
}
]}
@ -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<string>(settings, ['members_support_address', 'icon']);
const [supportEmailAddress, defaultEmailAddress] = getSettingValues<string>(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&lsquo;ve sent a confirmation email to <strong>{formState.sender_email}</strong>.
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',

View File

@ -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<string>(settings, ['title', 'icon', 'comments_enabled']);
const [title, icon, commentsEnabled, defaultEmailAddress] = getSettingValues<string>(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 <NewsletterPreviewContent

View File

@ -7,11 +7,11 @@ import {useGlobalData} from '../../../providers/GlobalDataProvider';
const AccountPage: React.FC<{
updateSetting: (key: string, setting: SettingValue) => 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<HTMLInputElement> = (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 <div className='mt-7'><Form>

View File

@ -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&apos;ve sent a confirmation email to <strong>{newEmail}</strong>.
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: '',

View File

@ -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\)/);
});
});

View File

@ -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;
}

View File

@ -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;
},

View File

@ -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",
},
],
}
`;

View File

@ -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 = {};

View File

@ -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
};
}