[Email] Migrate to typescript (#18136)

Co-authored-by: Josh <37798644+joshuaellis@users.noreply.github.com>
This commit is contained in:
Jamie Howard 2023-10-12 17:10:45 +01:00 committed by GitHub
parent 0b13b1b50a
commit a9552a70bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 752 additions and 532 deletions

View File

@ -12,7 +12,10 @@ const LazyCompo = ({ loadComponent }) => {
try {
const loadedCompo = await loadComponent();
setCompo(() => loadedCompo.default);
// TODO the loaded component provided can currently come from a default or named export
// We will move the entire codebase to use named exports only
// Until then we support both cases with priority given to the existing default exports
setCompo(() => loadedCompo?.default ?? loadedCompo);
} catch (err) {
// TODO return the error component
console.log(err);

View File

@ -2,6 +2,16 @@ root = true
[*]
end_of_line = lf
insert_final_newline = false
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[{package.json,*.yml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

View File

@ -1,3 +1,4 @@
node_modules/
.eslintrc.js
index.d.ts
dist/

View File

@ -7,7 +7,7 @@ module.exports = {
},
{
files: ['**/*'],
excludedFiles: ['admin/**/*'],
excludedFiles: ['admin/**/*', 'server/**/*'],
extends: ['custom/back'],
},
],

View File

@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ['custom/front/typescript'],
};

View File

@ -9,8 +9,12 @@ import { prefixPluginTranslations } from '@strapi/helper-plugin';
import { PERMISSIONS } from './constants';
export default {
register(app) {
import type { Plugin } from '@strapi/types';
const admin: Plugin.Config.AdminInput = {
// TODO typing app in strapi/types as every plugin needs it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
register(app: any) {
// Create the email settings section
app.createSettingSection(
{
@ -26,11 +30,11 @@ export default {
id: 'settings',
to: `/settings/email`,
async Component() {
const component = await import(
const { ProtectedSettingsPage } = await import(
/* webpackChunkName: "email-settings-page" */ './pages/Settings'
);
return component;
return ProtectedSettingsPage;
},
permissions: PERMISSIONS.settings,
},
@ -41,8 +45,9 @@ export default {
name: 'email',
});
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
bootstrap() {},
async registerTrads({ locales }) {
async registerTrads({ locales }: { locales: string[] }) {
const importedTrads = await Promise.all(
locales.map((locale) => {
return import(
@ -66,3 +71,6 @@ export default {
return Promise.resolve(importedTrads);
},
};
// eslint-disable-next-line import/no-default-export
export default admin;

View File

@ -0,0 +1,324 @@
import * as React from 'react';
import {
Box,
Button,
ContentLayout,
Flex,
Grid,
GridItem,
HeaderLayout,
Main,
Option,
Select,
TextInput,
Typography,
} from '@strapi/design-system';
import {
CheckPagePermissions,
getYupInnerErrors,
LoadingIndicatorPage,
SettingsPageTitle,
useFetchClient,
useFocusWhenNavigate,
useNotification,
useOverlayBlocker,
} from '@strapi/helper-plugin';
import { Envelop } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { useQuery, useMutation } from 'react-query';
import styled from 'styled-components';
import { ValidationError } from 'yup';
import { PERMISSIONS } from '../constants';
import { schema } from '../utils/schema';
import type { EmailSettings } from '../../../shared/types';
const DocumentationLink = styled.a`
color: ${({ theme }) => theme.colors.primary600};
`;
interface MutationBody {
to: string;
}
export const ProtectedSettingsPage = () => (
<CheckPagePermissions permissions={PERMISSIONS.settings}>
<SettingsPage />
</CheckPagePermissions>
);
const SettingsPage = () => {
const toggleNotification = useNotification();
const { formatMessage } = useIntl();
const { lockApp, unlockApp } = useOverlayBlocker();
const { get, post } = useFetchClient();
const [testAddress, setTestAddress] = React.useState('');
const [isTestAddressValid, setIsTestAddressValid] = React.useState(false);
// TODO: I'm not sure how to type this. I think it should be Record<string, TranslationMessage> but that type is defined in the helper-plugin
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [formErrors, setFormErrors] = React.useState<Record<string, any>>({});
const { data, isLoading } = useQuery(['email', 'settings'], async () => {
const res = await get<EmailSettings>('/email/settings');
const {
data: { config },
} = res;
return config;
});
const mutation = useMutation<void, Error, MutationBody>(
async (body) => {
await post('/email/test', body);
},
{
onError() {
toggleNotification!({
type: 'warning',
message: formatMessage(
{
id: 'email.Settings.email.plugin.notification.test.error',
defaultMessage: 'Failed to send a test mail to {to}',
},
{ to: testAddress }
),
});
},
onSuccess() {
toggleNotification!({
type: 'success',
message: formatMessage(
{
id: 'email.Settings.email.plugin.notification.test.success',
defaultMessage: 'Email test succeeded, check the {to} mailbox',
},
{ to: testAddress }
),
});
},
retry: false,
}
);
useFocusWhenNavigate();
React.useEffect(() => {
schema
.validate({ email: testAddress }, { abortEarly: false })
.then(() => setIsTestAddressValid(true))
.catch(() => setIsTestAddressValid(false));
}, [testAddress]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTestAddress(() => event.target.value);
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await schema.validate({ email: testAddress }, { abortEarly: false });
} catch (error) {
if (error instanceof ValidationError) {
setFormErrors(getYupInnerErrors(error));
}
}
lockApp!();
mutation.mutate({ to: testAddress });
unlockApp!();
};
return (
<Main labelledBy="title" aria-busy={isLoading || mutation.isLoading}>
<SettingsPageTitle
name={formatMessage({
id: 'email.Settings.email.plugin.title',
defaultMessage: 'Configuration',
})}
/>
<HeaderLayout
id="title"
title={formatMessage({
id: 'email.Settings.email.plugin.title',
defaultMessage: 'Configuration',
})}
subtitle={formatMessage({
id: 'email.Settings.email.plugin.subTitle',
defaultMessage: 'Test the settings for the Email plugin',
})}
/>
<ContentLayout>
{isLoading ? (
<LoadingIndicatorPage />
) : (
data && (
<form onSubmit={handleSubmit}>
<Flex direction="column" alignItems="stretch" gap={7}>
<Box
background="neutral0"
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Flex direction="column" alignItems="stretch" gap={4}>
<Flex direction="column" alignItems="stretch" gap={1}>
<Typography variant="delta" as="h2">
{formatMessage({
id: 'email.Settings.email.plugin.title.config',
defaultMessage: 'Configuration',
})}
</Typography>
<Typography>
{formatMessage(
{
id: 'email.Settings.email.plugin.text.configuration',
defaultMessage:
'The plugin is configured through the {file} file, checkout this {link} for the documentation.',
},
{
file: './config/plugins.js',
link: (
<DocumentationLink
href="https://docs.strapi.io/developer-docs/latest/plugins/email.html"
target="_blank"
rel="noopener noreferrer"
>
{formatMessage({
id: 'email.link',
defaultMessage: 'Link',
})}
</DocumentationLink>
),
}
)}
</Typography>
</Flex>
<Grid gap={5}>
<GridItem col={6} s={12}>
<TextInput
name="shipper-email"
label={formatMessage({
id: 'email.Settings.email.plugin.label.defaultFrom',
defaultMessage: 'Default sender email',
})}
placeholder={formatMessage({
id: 'email.Settings.email.plugin.placeholder.defaultFrom',
defaultMessage: "ex: Strapi No-Reply '<'no-reply@strapi.io'>'",
})}
disabled
value={data.settings.defaultFrom}
/>
</GridItem>
<GridItem col={6} s={12}>
<TextInput
name="response-email"
label={formatMessage({
id: 'email.Settings.email.plugin.label.defaultReplyTo',
defaultMessage: 'Default response email',
})}
placeholder={formatMessage({
id: 'email.Settings.email.plugin.placeholder.defaultReplyTo',
defaultMessage: `ex: Strapi '<'example@strapi.io'>'`,
})}
disabled
value={data.settings.defaultReplyTo}
/>
</GridItem>
<GridItem col={6} s={12}>
<Select
name="email-provider"
label={formatMessage({
id: 'email.Settings.email.plugin.label.provider',
defaultMessage: 'Email provider',
})}
disabled
value={data.provider}
>
<Option value={data.provider}>{data.provider}</Option>
</Select>
</GridItem>
</Grid>
</Flex>
</Box>
<Flex
alignItems="stretch"
background="neutral0"
direction="column"
gap={4}
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Typography variant="delta" as="h2">
{formatMessage({
id: 'email.Settings.email.plugin.title.test',
defaultMessage: 'Test email delivery',
})}
</Typography>
<Grid gap={5}>
<GridItem col={6} s={12}>
<TextInput
id="test-address-input"
name="test-address"
onChange={handleChange}
label={formatMessage({
id: 'email.Settings.email.plugin.label.testAddress',
defaultMessage: 'Recipient email',
})}
value={testAddress}
error={
formErrors.email?.id &&
formatMessage({
id: `email.${formErrors.email?.id}`,
defaultMessage: 'This is an invalid email',
})
}
placeholder={formatMessage({
id: 'email.Settings.email.plugin.placeholder.testAddress',
defaultMessage: 'ex: developer@example.com',
})}
/>
</GridItem>
<GridItem col={7} s={12}>
<Button
loading={mutation.isLoading}
disabled={!isTestAddressValid}
type="submit"
startIcon={<Envelop />}
>
{formatMessage({
id: 'email.Settings.email.plugin.button.test-email',
defaultMessage: 'Send test email',
})}
</Button>
</GridItem>
</Grid>
</Flex>
</Flex>
</form>
)
)}
</ContentLayout>
</Main>
);
};

View File

@ -1,314 +0,0 @@
import * as React from 'react';
import {
Box,
Button,
ContentLayout,
Flex,
Grid,
GridItem,
HeaderLayout,
Main,
Option,
Select,
TextInput,
Typography,
} from '@strapi/design-system';
import {
CheckPagePermissions,
getYupInnerErrors,
LoadingIndicatorPage,
SettingsPageTitle,
useFetchClient,
useFocusWhenNavigate,
useNotification,
useOverlayBlocker,
} from '@strapi/helper-plugin';
import { Envelop } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { useQuery, useMutation } from 'react-query';
import styled from 'styled-components';
import { PERMISSIONS } from '../../constants';
import schema from '../../utils/schema';
const DocumentationLink = styled.a`
color: ${({ theme }) => theme.colors.primary600};
`;
const ProtectedSettingsPage = () => (
<CheckPagePermissions permissions={PERMISSIONS.settings}>
<SettingsPage />
</CheckPagePermissions>
);
const SettingsPage = () => {
const toggleNotification = useNotification();
const { formatMessage } = useIntl();
const { lockApp, unlockApp } = useOverlayBlocker();
const { get, post } = useFetchClient();
const { data, isLoading } = useQuery(['email', 'settings'], async () => {
const {
data: { config },
} = await get('/email/settings');
return config;
});
const mutation = useMutation(
(body) => post('/email/test', body),
{
onError() {
toggleNotification({
type: 'warning',
message: formatMessage(
{
id: 'email.Settings.email.plugin.notification.test.error',
defaultMessage: 'Failed to send a test mail to {to}',
},
{ to: testAddress }
),
});
},
onSuccess() {
toggleNotification({
type: 'success',
message: formatMessage(
{
id: 'email.Settings.email.plugin.notification.test.success',
defaultMessage: 'Email test succeeded, check the {to} mailbox',
},
{ to: testAddress }
),
});
},
},
{
retry: false,
}
);
useFocusWhenNavigate();
const [formErrors, setFormErrors] = React.useState({});
const [testAddress, setTestAddress] = React.useState('');
const [isTestAddressValid, setIsTestAddressValid] = React.useState(false);
React.useEffect(() => {
schema
.validate({ email: testAddress }, { abortEarly: false })
.then(() => setIsTestAddressValid(true))
.catch(() => setIsTestAddressValid(false));
}, [testAddress]);
const handleChange = (e) => {
setTestAddress(() => e.target.value);
};
const handleSubmit = async (event) => {
event.preventDefault();
try {
await schema.validate({ email: testAddress }, { abortEarly: false });
} catch (error) {
setFormErrors(getYupInnerErrors(error));
}
lockApp();
mutation.mutate({ to: testAddress });
unlockApp();
};
return (
<Main labelledBy="title" aria-busy={isLoading || mutation.isLoading}>
<SettingsPageTitle
name={formatMessage({
id: 'email.Settings.email.plugin.title',
defaultMessage: 'Configuration',
})}
/>
<HeaderLayout
id="title"
title={formatMessage({
id: 'email.Settings.email.plugin.title',
defaultMessage: 'Configuration',
})}
subtitle={formatMessage({
id: 'email.Settings.email.plugin.subTitle',
defaultMessage: 'Test the settings for the Email plugin',
})}
/>
<ContentLayout>
{isLoading ? (
<LoadingIndicatorPage />
) : (
<form onSubmit={handleSubmit}>
<Flex direction="column" alignItems="stretch" gap={7}>
<Box
background="neutral0"
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Flex direction="column" alignItems="stretch" gap={4}>
<Flex direction="column" alignItems="stretch" gap={1}>
<Typography variant="delta" as="h2">
{formatMessage({
id: 'email.Settings.email.plugin.title.config',
defaultMessage: 'Configuration',
})}
</Typography>
<Typography>
{formatMessage(
{
id: 'email.Settings.email.plugin.text.configuration',
defaultMessage:
'The plugin is configured through the {file} file, checkout this {link} for the documentation.',
},
{
file: './config/plugins.js',
link: (
<DocumentationLink
href="https://docs.strapi.io/developer-docs/latest/plugins/email.html"
target="_blank"
rel="noopener noreferrer"
>
{formatMessage({
id: 'email.link',
defaultMessage: 'Link',
})}
</DocumentationLink>
),
}
)}
</Typography>
</Flex>
<Grid gap={5}>
<GridItem col={6} s={12}>
<TextInput
name="shipper-email"
label={formatMessage({
id: 'email.Settings.email.plugin.label.defaultFrom',
defaultMessage: 'Default sender email',
})}
placeholder={formatMessage({
id: 'email.Settings.email.plugin.placeholder.defaultFrom',
defaultMessage: "ex: Strapi No-Reply '<'no-reply@strapi.io'>'",
})}
disabled
onChange={() => {}}
value={data.settings.defaultFrom}
/>
</GridItem>
<GridItem col={6} s={12}>
<TextInput
name="response-email"
label={formatMessage({
id: 'email.Settings.email.plugin.label.defaultReplyTo',
defaultMessage: 'Default response email',
})}
placeholder={formatMessage({
id: 'email.Settings.email.plugin.placeholder.defaultReplyTo',
defaultMessage: `ex: Strapi '<'example@strapi.io'>'`,
})}
disabled
onChange={() => {}}
value={data.settings.defaultReplyTo}
/>
</GridItem>
<GridItem col={6} s={12}>
<Select
name="email-provider"
label={formatMessage({
id: 'email.Settings.email.plugin.label.provider',
defaultMessage: 'Email provider',
})}
disabled
onChange={() => {}}
value={data.provider}
>
<Option value={data.provider}>{data.provider}</Option>
</Select>
</GridItem>
</Grid>
</Flex>
</Box>
<Flex
alignItems="stretch"
background="neutral0"
direction="column"
gap={4}
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Typography variant="delta" as="h2">
{formatMessage({
id: 'email.Settings.email.plugin.title.test',
defaultMessage: 'Test email delivery',
})}
</Typography>
<Grid gap={5} alignItems="end">
<GridItem col={6} s={12}>
<TextInput
id="test-address-input"
name="test-address"
onChange={handleChange}
label={formatMessage({
id: 'email.Settings.email.plugin.label.testAddress',
defaultMessage: 'Recipient email',
})}
value={testAddress}
error={
formErrors.email?.id &&
formatMessage({
id: `email.${formErrors.email?.id}`,
defaultMessage: 'This is an invalid email',
})
}
placeholder={formatMessage({
id: 'email.Settings.email.plugin.placeholder.testAddress',
defaultMessage: 'ex: developer@example.com',
})}
/>
</GridItem>
<GridItem col={7} s={12}>
<Button
loading={mutation.isLoading}
disabled={!isTestAddressValid}
type="submit"
startIcon={<Envelop />}
>
{formatMessage({
id: 'email.Settings.email.plugin.button.test-email',
defaultMessage: 'Send test email',
})}
</Button>
</GridItem>
</Grid>
</Flex>
</Flex>
</form>
)}
</ContentLayout>
</Main>
);
};
export default ProtectedSettingsPage;

View File

@ -1,9 +1,11 @@
const translations = require('../en.json');
import translations from '../en.json';
const typedTranslations: Record<string, string> = translations;
describe('translations', () => {
describe('plural syntax', () => {
it('should avoid .plural/.singular syntax', () => {
Object.keys(translations).forEach((translationKey) => {
Object.keys(typedTranslations).forEach((translationKey) => {
const keyParts = translationKey.split('.');
const lastKeyPart = keyParts.pop();
@ -13,7 +15,7 @@ describe('translations', () => {
keyParts.push('plural');
const pluralKey = keyParts.join('.');
expect(translations[pluralKey]).toBeUndefined();
expect(typedTranslations[pluralKey]).toBeUndefined();
}
});
});

View File

@ -1,8 +1,6 @@
import { translatedErrors } from '@strapi/helper-plugin';
import * as yup from 'yup';
const schema = yup.object().shape({
export const schema = yup.object().shape({
email: yup.string().email(translatedErrors.email).required(translatedErrors.required),
});
export default schema;

View File

@ -0,0 +1,8 @@
{
"extends": "tsconfig/client.json",
"include": ["./src", "../shared/types.ts"],
"compilerOptions": {
"rootDir": "../",
}
}

View File

@ -19,15 +19,42 @@
"url": "https://strapi.io"
}
],
"exports": {
"./strapi-admin": {
"types": "./dist/admin/src/index.d.ts",
"source": "./admin/src/index.ts",
"import": "./dist/admin/index.mjs",
"require": "./dist/admin/index.js",
"default": "./dist/admin/index.js"
},
"./strapi-server": {
"types": "./dist/server/src/index.d.ts",
"source": "./server/src/index.ts",
"import": "./dist/server/index.mjs",
"require": "./dist/server/index.js",
"default": "./dist/server/index.js"
},
"./package.json": "./package.json"
},
"files": [
"./dist",
"strapi-server.js"
],
"scripts": {
"build": "pack-up build",
"clean": "run -T rimraf ./dist",
"lint": "run -T eslint .",
"prepublishOnly": "yarn clean && yarn build",
"test:front": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js",
"test:front:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js",
"test:front:watch": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js --watchAll",
"test:front:watch:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js --watchAll"
"test:front:watch:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js --watchAll",
"test:ts:front": "run -T tsc -p admin/tsconfig.json",
"watch": "pack-up watch"
},
"dependencies": {
"@strapi/design-system": "1.12.0",
"@strapi/helper-plugin": "4.14.4",
"@strapi/icons": "1.12.0",
"@strapi/provider-email-sendmail": "4.14.4",
"@strapi/utils": "4.14.4",
@ -38,8 +65,12 @@
"yup": "0.32.9"
},
"devDependencies": {
"@strapi/helper-plugin": "4.14.4",
"@strapi/pack-up": "workspace:*",
"@strapi/types": "workspace:*",
"@testing-library/react": "14.0.0",
"@types/koa": "2.13.4",
"@types/lodash": "^4.14.191",
"koa": "2.13.4",
"msw": "1.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -47,6 +78,7 @@
"styled-components": "5.3.3"
},
"peerDependencies": {
"koa": "2.13.4",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-router-dom": "5.3.4",

View File

@ -0,0 +1,27 @@
import { defineConfig } from '@strapi/pack-up';
export default defineConfig({
bundles: [
{
source: './admin/src/index.ts',
import: './dist/admin/index.mjs',
require: './dist/admin/index.js',
types: './dist/admin/src/index.d.ts',
runtime: 'web',
},
{
source: './server/src/index.ts',
import: './dist/server/index.mjs',
require: './dist/server/index.js',
types: './dist/server/src/index.d.ts',
runtime: 'node',
},
],
dist: './dist',
/**
* Because we're exporting a server & client package
* which have different runtimes we want to ignore
* what they look like in the package.json
*/
exports: {},
});

View File

@ -0,0 +1,7 @@
module.exports = {
root: true,
extends: ['custom/back/typescript'],
parserOptions: {
project: ['./server/tsconfig.eslint.json'],
},
};

View File

@ -1,43 +0,0 @@
'use strict';
const createProvider = (emailConfig) => {
const providerName = emailConfig.provider?.toLowerCase();
let provider;
let modulePath;
try {
modulePath = require.resolve(`@strapi/provider-email-${providerName}`);
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND') {
modulePath = providerName;
} else {
throw error;
}
}
try {
provider = require(modulePath);
} catch (err) {
throw new Error(`Could not load email provider "${providerName}".`);
}
return provider.init(emailConfig.providerOptions, emailConfig.settings);
};
module.exports = async ({ strapi }) => {
const emailConfig = strapi.config.get('plugin.email');
strapi.plugin('email').provider = createProvider(emailConfig);
// Add permissions
const actions = [
{
section: 'settings',
category: 'email',
displayName: 'Access the Email Settings page',
uid: 'settings.read',
pluginName: 'email',
},
];
await strapi.admin.services.permission.actionProvider.registerMany(actions);
};

View File

@ -1,68 +0,0 @@
'use strict';
const { pick } = require('lodash/fp');
const { ApplicationError } = require('@strapi/utils').errors;
/**
* Email.js controller
*
* @description: A set of functions called "actions" of the `email` plugin.
*/
module.exports = {
async send(ctx) {
const options = ctx.request.body;
try {
await strapi.plugin('email').service('email').send(options);
} catch (e) {
if (e.statusCode === 400) {
throw new ApplicationError(e.message);
} else {
throw new Error(`Couldn't send email: ${e.message}.`);
}
}
// Send 200 `ok`
ctx.send({});
},
async test(ctx) {
const { to } = ctx.request.body;
if (!to) {
throw new ApplicationError('No recipient(s) are given');
}
const email = {
to,
subject: `Strapi test mail to: ${to}`,
text: `Great! You have correctly configured the Strapi email plugin with the ${strapi.config.get(
'plugin.email.provider'
)} provider. \r\nFor documentation on how to use the email plugin checkout: https://docs.strapi.io/developer-docs/latest/plugins/email.html`,
};
try {
await strapi.plugin('email').service('email').send(email);
} catch (e) {
if (e.statusCode === 400) {
throw new ApplicationError(e.message);
} else {
throw new Error(`Couldn't send test email: ${e.message}.`);
}
}
// Send 200 `ok`
ctx.send({});
},
async getSettings(ctx) {
const config = strapi.plugin('email').service('email').getProviderSettings();
ctx.send({
config: pick(
['provider', 'settings.defaultFrom', 'settings.defaultReplyTo', 'settings.testAddress'],
config
),
});
},
};

View File

@ -1,7 +0,0 @@
'use strict';
const email = require('./email');
module.exports = {
email,
};

View File

@ -1,6 +0,0 @@
'use strict';
module.exports = {
admin: require('./admin'),
'content-api': require('./content-api'),
};

View File

@ -1,7 +0,0 @@
'use strict';
const email = require('./email');
module.exports = {
email,
};

View File

@ -0,0 +1,57 @@
import type { Strapi } from '@strapi/types';
import type { EmailConfig, SendOptions } from './types';
interface EmailProvider {
send: (options: SendOptions) => Promise<any>;
}
interface EmailProviderModule {
init: (
options: EmailConfig['providerOptions'],
settings: EmailConfig['settings']
) => EmailProvider;
name?: string;
provider?: string;
}
const createProvider = (emailConfig: EmailConfig) => {
const providerName = emailConfig.provider.toLowerCase();
let provider: EmailProviderModule;
let modulePath: string;
try {
modulePath = require.resolve(`@strapi/provider-email-${providerName}`);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'MODULE_NOT_FOUND') {
modulePath = providerName;
} else {
throw error;
}
}
try {
provider = require(modulePath);
} catch (err) {
throw new Error(`Could not load email provider "${providerName}".`);
}
return provider.init(emailConfig.providerOptions, emailConfig.settings);
};
export const bootstrap = async ({ strapi }: { strapi: Strapi }) => {
const emailConfig: EmailConfig = strapi.config.get('plugin.email');
strapi.plugin('email').provider = createProvider(emailConfig);
// Add permissions
const actions = [
{
section: 'settings',
category: 'email',
displayName: 'Access the Email Settings page',
uid: 'settings.read',
pluginName: 'email',
},
];
await strapi.admin!.services.permission.actionProvider.registerMany(actions);
};

View File

@ -1,6 +1,6 @@
'use strict';
import type { StrapiConfig } from './types';
module.exports = {
export const config: StrapiConfig = {
default: {
provider: 'sendmail',
providerOptions: {},

View File

@ -0,0 +1,77 @@
import { pick } from 'lodash/fp';
import { errors } from '@strapi/utils';
import type Koa from 'koa';
import type { EmailConfig, SendOptions } from '../types';
const { ApplicationError } = errors;
/**
* Email.js controller
*
* @description: A set of functions called "actions" of the `email` plugin.
*/
const emailController = {
async send(ctx: Koa.Context) {
const options: SendOptions = ctx.request.body;
try {
await strapi.plugin('email').service('email').send(options);
} catch (error) {
if (error instanceof Error) {
if ('statusCode' in error && error.statusCode === 400) {
throw new ApplicationError(error.message);
} else {
throw new Error(`Couldn't send email: ${error.message}.`);
}
}
}
// Send 200 `ok`
ctx.send({});
},
async test(ctx: Koa.Context) {
const { to } = ctx.request.body;
if (!to) {
throw new ApplicationError('No recipient(s) are given');
}
const email: SendOptions = {
to,
subject: `Strapi test mail to: ${to}`,
text: `Great! You have correctly configured the Strapi email plugin with the ${strapi.config.get(
'plugin.email.provider'
)} provider. \r\nFor documentation on how to use the email plugin checkout: https://docs.strapi.io/developer-docs/latest/plugins/email.html`,
};
try {
await strapi.plugin('email').service('email').send(email);
} catch (error) {
if (error instanceof Error) {
if ('statusCode' in error && error.statusCode === 400) {
throw new ApplicationError(error.message);
} else {
throw new Error(`Couldn't send test email: ${error.message}.`);
}
}
}
// Send 200 `ok`
ctx.send({});
},
async getSettings(ctx: Koa.Context) {
const config: EmailConfig = strapi.plugin('email').service('email').getProviderSettings();
ctx.send({
config: pick(
['provider', 'settings.defaultFrom', 'settings.defaultReplyTo', 'settings.testAddress'],
config
),
});
},
};
export default emailController;

View File

@ -0,0 +1,3 @@
import email from './email';
export const controllers = { email };

View File

@ -0,0 +1,13 @@
import { bootstrap } from './bootstrap';
import { services } from './services';
import { routes } from './routes';
import { controllers } from './controllers';
import { config } from './config';
export default {
bootstrap,
services,
routes,
controllers,
config,
};

View File

@ -1,6 +1,4 @@
'use strict';
module.exports = {
export default {
type: 'admin',
routes: [
{

View File

@ -1,6 +1,4 @@
'use strict';
module.exports = {
export default {
type: 'content-api',
routes: [
{

View File

@ -0,0 +1,7 @@
import admin from './admin';
import contentApi from './content-api';
export const routes = {
admin,
'content-api': contentApi,
};

View File

@ -1,18 +1,19 @@
'use strict';
import * as _ from 'lodash';
import { keysDeep, template } from '@strapi/utils';
const _ = require('lodash');
const {
template: { createStrictInterpolationRegExp },
keysDeep,
} = require('@strapi/utils');
import type {
EmailConfig,
EmailOptions,
EmailTemplate,
EmailTemplateData,
SendOptions,
} from '../types';
const getProviderSettings = () => {
return strapi.config.get('plugin.email');
};
const { createStrictInterpolationRegExp } = template;
const send = async (options) => {
return strapi.plugin('email').provider.send(options);
};
const getProviderSettings = (): EmailConfig => strapi.config.get('plugin.email');
const send = async (options: SendOptions) => strapi.plugin('email').provider.send(options);
/**
* fill subject, text and html using lodash template
@ -21,9 +22,14 @@ const send = async (options) => {
* @param {object} data - data used to fill the template
* @returns {{ subject, text, subject }}
*/
const sendTemplatedEmail = (emailOptions = {}, emailTemplate = {}, data = {}) => {
const sendTemplatedEmail = (
emailOptions: EmailOptions,
emailTemplate: EmailTemplate,
data: EmailTemplateData
) => {
const attributes = ['subject', 'text', 'html'];
const missingAttributes = _.difference(attributes, Object.keys(emailTemplate));
if (missingAttributes.length > 0) {
throw new Error(
`Following attributes are missing from your email template : ${missingAttributes.join(', ')}`
@ -39,8 +45,6 @@ const sendTemplatedEmail = (emailOptions = {}, emailTemplate = {}, data = {}) =>
? Object.assign(compiled, {
[attribute]: _.template(emailTemplate[attribute], {
interpolate,
evaluate: false,
escape: false,
})(data),
})
: compiled,
@ -50,8 +54,10 @@ const sendTemplatedEmail = (emailOptions = {}, emailTemplate = {}, data = {}) =>
return strapi.plugin('email').provider.send({ ...emailOptions, ...templatedAttributes });
};
module.exports = () => ({
const emailService = () => ({
getProviderSettings,
send,
sendTemplatedEmail,
});
export default emailService;

View File

@ -0,0 +1,3 @@
import email from './email';
export const services = { email };

View File

@ -0,0 +1,43 @@
import type { Plugin } from '@strapi/types';
export interface EmailConfig extends Record<string, unknown> {
provider: string;
providerOptions?: object;
settings?: {
defaultFrom?: string;
};
}
type LoadedPluginConfig = Plugin.LoadedPlugin['config'];
export interface StrapiConfig extends LoadedPluginConfig {
default: EmailConfig;
}
export interface EmailTemplateData {
url?: string;
user?: {
email: string;
firstname: string;
lastname: string;
username: string;
};
}
export interface EmailOptions {
from?: string;
to: string;
cc?: string;
bcc?: string;
replyTo?: string;
[key: string]: string | undefined; // to allow additional template attributes if needed
}
export interface EmailTemplate {
subject: string;
text: string;
html?: string;
[key: string]: string | undefined; // to allow additional template attributes if needed
}
export type SendOptions = EmailOptions & EmailTemplate;

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"include": ["./src"],
"exclude": ["./src/**/*.test.ts"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist/server"
}
}

View File

@ -1,5 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,8 @@
{
"extends": "tsconfig/base.json",
"include": ["src"],
"exclude": ["node_modules"],
"compilerOptions": {
"esModuleInterop": true
},
}

View File

@ -0,0 +1,11 @@
export interface EmailSettings {
config: ConfigSettings;
}
export interface ConfigSettings {
provider: string;
settings: {
defaultFrom: string;
defaultReplyTo: string;
};
}

View File

@ -1,3 +0,0 @@
'use strict';
module.exports = require('./admin/src').default;

View File

@ -1,17 +1,3 @@
'use strict';
const bootstrap = require('./server/bootstrap');
const services = require('./server/services');
const routes = require('./server/routes');
const controllers = require('./server/controllers');
const config = require('./server/config');
module.exports = () => {
return {
bootstrap,
config,
routes,
controllers,
services,
};
};
module.exports = require('./dist/server');

View File

@ -0,0 +1,8 @@
{
"extends": "tsconfig/client.json",
"include": ["./admin", "./shared", "./server"],
"compilerOptions": {
"declarationDir": "./dist",
"outDir": "./dist"
}
}

View File

@ -12,12 +12,15 @@ import type { domain } from '@strapi/permissions';
type Permission = domain.permission.Permission;
export interface CheckPagePermissions {
export interface CheckPagePermissionsProps {
children: React.ReactNode;
permissions?: Permission[];
}
const CheckPagePermissions = ({ permissions = [], children }: CheckPagePermissions) => {
const CheckPagePermissions = ({
permissions = [],
children,
}: CheckPagePermissionsProps): React.JSX.Element => {
const abortController = new AbortController();
const { signal } = abortController;
const { allPermissions } = useRBACProvider();
@ -73,7 +76,7 @@ const CheckPagePermissions = ({ permissions = [], children }: CheckPagePermissio
return <Redirect to="/" />;
}
return children;
return <>{children}</>;
};
export { CheckPagePermissions };

View File

@ -11,12 +11,12 @@ type Permission = domain.permission.Permission;
// NOTE: this component is very similar to the CheckPagePermissions
// except that it does not handle redirections nor loading state
export interface CheckPagePermissions {
export interface CheckPermissionsProps {
children: React.ReactNode;
permissions?: Permission[];
}
const CheckPermissions = ({ permissions = [], children }: CheckPagePermissions) => {
const CheckPermissions = ({ permissions = [], children }: CheckPermissionsProps) => {
const { allPermissions } = useRBACProvider();
const toggleNotification = useNotification();
const [state, setState] = React.useState({ isLoading: true, canAccess: false });
@ -69,7 +69,7 @@ const CheckPermissions = ({ permissions = [], children }: CheckPagePermissions)
return null;
}
return children;
return <>{children}</>;
};
export { CheckPermissions };

View File

@ -2,30 +2,14 @@ import { join } from 'path';
import fse from 'fs-extra';
import { defaultsDeep, defaults, getOr, get } from 'lodash/fp';
import { env } from '@strapi/utils';
import type { Strapi, Common, Schema } from '@strapi/types';
import type { Strapi, Plugin } from '@strapi/types';
import { loadFile } from '../../app-configuration/load-config-file';
import loadFiles from '../../../load/load-files';
import { getEnabledPlugins } from './get-enabled-plugins';
import { getUserPluginsConfig } from './get-user-plugins-config';
type LoadedPlugin = {
config: {
default: Record<string, unknown> | ((opts: { env: typeof env }) => Record<string, unknown>);
validator: (config: Record<string, unknown>) => void;
};
bootstrap: ({ strapi }: { strapi: Strapi }) => void | Promise<void>;
destroy: ({ strapi }: { strapi: Strapi }) => void | Promise<void>;
register: ({ strapi }: { strapi: Strapi }) => void | Promise<void>;
routes: Record<string, Common.Router>;
controllers: Record<string, Common.Controller>;
services: Record<string, Common.Service>;
policies: Record<string, Common.Policy>;
middlewares: Record<string, Common.Middleware>;
contentTypes: Record<string, { schema: Schema.ContentType }>;
};
interface Plugins {
[key: string]: LoadedPlugin;
[key: string]: Plugin.LoadedPlugin;
}
const defaultPlugin = {

View File

@ -3,5 +3,5 @@
export interface AdminInput {
register: unknown;
bootstrap: unknown;
registerTrads: unknown;
registerTrads: ({ locales }: { locales: string[] }) => Promise<unknown>;
}

View File

@ -1,4 +1,6 @@
import { env } from '@strapi/utils';
export interface Config {
validator: () => unknown;
default: object | (() => object);
validator: (config: Record<string, unknown>) => void;
default: Record<string, unknown> | ((opts: { env: typeof env }) => Record<string, unknown>);
}

View File

@ -1,4 +1,7 @@
import type { Common, Shared, Utils } from '../..';
import { env } from '@strapi/utils';
import type { Common, Shared, Utils, Schema } from '../..';
import type { Strapi } from '../../..';
export type IsEnabled<
TName extends keyof any,
@ -15,4 +18,20 @@ export type IsEnabled<
: false
: false;
export type LoadedPlugin = {
config: {
default: Record<string, unknown> | ((opts: { env: typeof env }) => Record<string, unknown>);
validator: (config: Record<string, unknown>) => void;
};
bootstrap: ({ strapi }: { strapi: Strapi }) => void | Promise<void>;
destroy: ({ strapi }: { strapi: Strapi }) => void | Promise<void>;
register: ({ strapi }: { strapi: Strapi }) => void | Promise<void>;
routes: Record<string, Common.Router>;
controllers: Record<string, Common.Controller>;
services: Record<string, Common.Service>;
policies: Record<string, Common.Policy>;
middlewares: Record<string, Common.Middleware>;
contentTypes: Record<string, { schema: Schema.ContentType }>;
};
export * as Config from './config';

View File

@ -7934,9 +7934,14 @@ __metadata:
"@strapi/design-system": 1.12.0
"@strapi/helper-plugin": 4.14.4
"@strapi/icons": 1.12.0
"@strapi/pack-up": "workspace:*"
"@strapi/provider-email-sendmail": 4.14.4
"@strapi/types": "workspace:*"
"@strapi/utils": 4.14.4
"@testing-library/react": 14.0.0
"@types/koa": 2.13.4
"@types/lodash": ^4.14.191
koa: 2.13.4
lodash: 4.17.21
msw: 1.3.0
prop-types: ^15.8.1
@ -7948,6 +7953,7 @@ __metadata:
styled-components: 5.3.3
yup: 0.32.9
peerDependencies:
koa: 2.13.4
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: 5.3.4