From c6f4dcdb706a67396d802cd4b8e71c8948a3a971 Mon Sep 17 00:00:00 2001 From: Maksim Eltyshev Date: Mon, 22 Sep 2025 20:35:13 +0200 Subject: [PATCH] feat: Add ability to configure and test SMTP via UI --- charts/planka/values.yaml | 4 +- client/src/actions/config.js | 59 +++ client/src/actions/core.js | 8 +- client/src/actions/index.js | 2 + client/src/actions/login.js | 4 +- client/src/actions/socket.js | 2 + client/src/actions/users.js | 2 + client/src/api/bootstrap.js | 14 + client/src/api/config.js | 10 +- client/src/api/index.js | 6 +- .../AdministrationModal.jsx | 24 +- .../common/AdministrationModal/SmtpPane.jsx | 227 +++++++++++ .../AdministrationModal/SmtpPane.module.scss | 41 ++ .../UsersPane/UsersPane.jsx | 4 +- client/src/components/common/Core/Core.jsx | 4 +- .../src/components/common/Login/Content.jsx | 6 +- client/src/constants/ActionTypes.js | 12 +- client/src/constants/EntryActionTypes.js | 6 + client/src/constants/WebhookEvents.js | 2 + client/src/entry-actions/config.js | 31 ++ client/src/entry-actions/index.js | 2 + client/src/hooks/use-form.js | 4 +- .../components/Input/InputPassword.jsx | 11 +- client/src/locales/ar-YE/core.js | 10 + client/src/locales/bg-BG/core.js | 10 + client/src/locales/cs-CZ/core.js | 10 + client/src/locales/da-DK/core.js | 10 + client/src/locales/de-DE/core.js | 10 + client/src/locales/el-GR/core.js | 10 + client/src/locales/en-GB/core.js | 10 + client/src/locales/en-US/core.js | 10 + client/src/locales/es-ES/core.js | 10 + client/src/locales/et-EE/core.js | 10 + client/src/locales/fa-IR/core.js | 10 + client/src/locales/fi-FI/core.js | 10 + client/src/locales/fr-FR/core.js | 10 + client/src/locales/hu-HU/core.js | 10 + client/src/locales/id-ID/core.js | 10 + client/src/locales/it-IT/core.js | 10 + client/src/locales/ja-JP/core.js | 10 + client/src/locales/ko-KR/core.js | 10 + client/src/locales/nl-NL/core.js | 10 + client/src/locales/pl-PL/core.js | 10 + client/src/locales/pt-BR/core.js | 10 + client/src/locales/pt-PT/core.js | 10 + client/src/locales/ro-RO/core.js | 10 + client/src/locales/ru-RU/core.js | 10 + client/src/locales/sk-SK/core.js | 10 + client/src/locales/sr-Cyrl-RS/core.js | 10 + client/src/locales/sr-Latn-RS/core.js | 10 + client/src/locales/sv-SE/core.js | 10 + client/src/locales/tr-TR/core.js | 10 + client/src/locales/uk-UA/core.js | 10 + client/src/locales/uz-UZ/core.js | 10 + client/src/locales/zh-CN/core.js | 10 + client/src/locales/zh-TW/core.js | 10 + client/src/reducers/common.js | 14 +- client/src/reducers/core.js | 43 ++- client/src/reducers/ui/index.js | 2 + client/src/reducers/ui/smtp-test.js | 35 ++ client/src/sagas/core/index.js | 6 +- client/src/sagas/core/requests/core.js | 4 + client/src/sagas/core/services/config.js | 49 +++ client/src/sagas/core/services/core.js | 6 +- client/src/sagas/core/services/index.js | 2 + client/src/sagas/core/services/socket.js | 4 +- client/src/sagas/core/services/users.js | 9 +- client/src/sagas/core/watchers/config.js | 21 + client/src/sagas/core/watchers/index.js | 2 + client/src/sagas/core/watchers/socket.js | 20 + client/src/sagas/login/services/login.js | 8 +- client/src/sagas/login/services/router.js | 4 +- client/src/selectors/common.js | 13 +- client/src/selectors/core.js | 3 + docker-compose-dev.yml | 3 +- docker-compose.yml | 3 +- server/.env.sample | 3 +- server/api/controllers/bootstrap/show.js | 72 ++++ server/api/controllers/config/show.js | 31 +- server/api/controllers/config/test-smtp.js | 117 ++++++ server/api/controllers/config/update.js | 151 ++++++++ .../controllers/notification-services/test.js | 13 +- .../api/controllers/users/update-password.js | 12 +- server/api/helpers/actions/create-one.js | 34 +- server/api/helpers/bootstrap/present-one.js | 29 ++ server/api/helpers/comments/create-one.js | 42 +- server/api/helpers/config/present-one.js | 17 +- server/api/helpers/config/update-main.js | 53 +++ .../helpers/notification-services/test-one.js | 33 -- .../api/helpers/notifications/create-many.js | 358 ++++++++++++++++++ .../api/helpers/notifications/create-one.js | 29 +- .../helpers/utils/make-smtp-transporter.js | 61 +++ server/api/helpers/utils/send-email.js | 11 +- server/api/hooks/oidc/index.js | 22 ++ .../query-methods/models/Notification.js | 34 +- .../models/NotificationService.js | 6 + server/api/hooks/smtp/index.js | 55 --- server/api/models/Config.js | 139 +++++-- server/api/models/Webhook.js | 2 + server/config/custom.js | 2 +- server/config/locales/de-DE.json | 2 +- server/config/locales/el-GR.json | 2 +- server/config/locales/en-GB.json | 2 +- server/config/locales/en-US.json | 2 +- server/config/locales/es-ES.json | 2 +- server/config/locales/fi-FI.json | 2 +- server/config/locales/fr-FR.json | 2 +- server/config/locales/it-IT.json | 2 +- server/config/locales/ru-RU.json | 2 +- server/config/locales/tr-TR.json | 2 +- server/config/locales/uk-UA.json | 2 +- server/config/policies.js | 6 +- server/config/routes.js | 6 +- ...48_add_ability_to_configure_smtp_via_ui.js | 36 ++ 114 files changed, 2161 insertions(+), 301 deletions(-) create mode 100644 client/src/actions/config.js create mode 100644 client/src/api/bootstrap.js create mode 100644 client/src/components/common/AdministrationModal/SmtpPane.jsx create mode 100644 client/src/components/common/AdministrationModal/SmtpPane.module.scss create mode 100644 client/src/entry-actions/config.js create mode 100644 client/src/reducers/ui/smtp-test.js create mode 100644 client/src/sagas/core/services/config.js create mode 100644 client/src/sagas/core/watchers/config.js create mode 100644 server/api/controllers/bootstrap/show.js create mode 100644 server/api/controllers/config/test-smtp.js create mode 100644 server/api/controllers/config/update.js create mode 100644 server/api/helpers/bootstrap/present-one.js create mode 100644 server/api/helpers/config/update-main.js delete mode 100644 server/api/helpers/notification-services/test-one.js create mode 100644 server/api/helpers/notifications/create-many.js create mode 100644 server/api/helpers/utils/make-smtp-transporter.js delete mode 100644 server/api/hooks/smtp/index.js create mode 100644 server/db/migrations/20250917123048_add_ability_to_configure_smtp_via_ui.js diff --git a/charts/planka/values.yaml b/charts/planka/values.yaml index 8b084ff8..27bd67c1 100644 --- a/charts/planka/values.yaml +++ b/charts/planka/values.yaml @@ -224,11 +224,11 @@ extraEnv: [] ## value: "Your Name" ## - name: SMTP_SECURE ## value: "true" +## - name: SMTP_TLS_REJECT_UNAUTHORIZED +## value: "false" ## - name: SMTP_USER ## value: "your_email@example.com" ## - name: SMTP_PASSWORD ## value: "your_password" ## - name: SMTP_FROM ## value: "your_email@example.com" -## - name: SMTP_TLS_REJECT_UNAUTHORIZED -## value: "false" diff --git a/client/src/actions/config.js b/client/src/actions/config.js new file mode 100644 index 00000000..97a694cc --- /dev/null +++ b/client/src/actions/config.js @@ -0,0 +1,59 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +import ActionTypes from '../constants/ActionTypes'; + +const updateConfig = (data) => ({ + type: ActionTypes.CONFIG_UPDATE, + payload: { + data, + }, +}); + +updateConfig.success = (config) => ({ + type: ActionTypes.CONFIG_UPDATE__SUCCESS, + payload: { + config, + }, +}); + +updateConfig.failure = (error) => ({ + type: ActionTypes.CONFIG_UPDATE__FAILURE, + payload: { + error, + }, +}); + +const handleConfigUpdate = (config) => ({ + type: ActionTypes.CONFIG_UPDATE_HANDLE, + payload: { + config, + }, +}); + +const testSmtpConfig = () => ({ + type: ActionTypes.SMTP_CONFIG_TEST, + payload: {}, +}); + +testSmtpConfig.success = (logs) => ({ + type: ActionTypes.SMTP_CONFIG_TEST__SUCCESS, + payload: { + logs, + }, +}); + +testSmtpConfig.failure = (error) => ({ + type: ActionTypes.SMTP_CONFIG_TEST__FAILURE, + payload: { + error, + }, +}); + +export default { + updateConfig, + handleConfigUpdate, + testSmtpConfig, +}; diff --git a/client/src/actions/core.js b/client/src/actions/core.js index a2f05f4a..85182ffd 100644 --- a/client/src/actions/core.js +++ b/client/src/actions/core.js @@ -6,6 +6,7 @@ import ActionTypes from '../constants/ActionTypes'; const initializeCore = ( + config, user, board, webhooks, @@ -32,6 +33,7 @@ const initializeCore = ( ) => ({ type: ActionTypes.CORE_INITIALIZE, payload: { + config, user, board, webhooks, @@ -58,10 +60,10 @@ const initializeCore = ( }, }); -initializeCore.fetchConfig = (config) => ({ - type: ActionTypes.CORE_INITIALIZE__CONFIG_FETCH, +initializeCore.fetchBootstrap = (bootstrap) => ({ + type: ActionTypes.CORE_INITIALIZE__BOOTSTRAP_FETCH, payload: { - config, + bootstrap, }, }); diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 46a254a1..fcf3a702 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -8,6 +8,7 @@ import socket from './socket'; import login from './login'; import core from './core'; import modals from './modals'; +import config from './config'; import webhooks from './webhooks'; import users from './users'; import projects from './projects'; @@ -36,6 +37,7 @@ export default { ...login, ...core, ...modals, + ...config, ...webhooks, ...users, ...projects, diff --git a/client/src/actions/login.js b/client/src/actions/login.js index 67e31e11..da0ccaa1 100644 --- a/client/src/actions/login.js +++ b/client/src/actions/login.js @@ -5,10 +5,10 @@ import ActionTypes from '../constants/ActionTypes'; -const initializeLogin = (config) => ({ +const initializeLogin = (bootstrap) => ({ type: ActionTypes.LOGIN_INITIALIZE, payload: { - config, + bootstrap, }, }); diff --git a/client/src/actions/socket.js b/client/src/actions/socket.js index 8e6f07f4..16dfd77f 100644 --- a/client/src/actions/socket.js +++ b/client/src/actions/socket.js @@ -11,6 +11,7 @@ const handleSocketDisconnect = () => ({ }); const handleSocketReconnect = ( + bootstrap, config, user, board, @@ -38,6 +39,7 @@ const handleSocketReconnect = ( ) => ({ type: ActionTypes.SOCKET_RECONNECT_HANDLE, payload: { + bootstrap, config, user, board, diff --git a/client/src/actions/users.js b/client/src/actions/users.js index 101d65c6..1b2ece4a 100644 --- a/client/src/actions/users.js +++ b/client/src/actions/users.js @@ -65,6 +65,7 @@ const handleUserUpdate = ( user, projectIds, boardIds, + bootstrap, config, board, webhooks, @@ -94,6 +95,7 @@ const handleUserUpdate = ( user, projectIds, boardIds, + bootstrap, config, board, webhooks, diff --git a/client/src/api/bootstrap.js b/client/src/api/bootstrap.js new file mode 100644 index 00000000..4253466f --- /dev/null +++ b/client/src/api/bootstrap.js @@ -0,0 +1,14 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +import http from './http'; + +/* Actions */ + +const getBootstrap = (headers) => http.get('/bootstrap', undefined, headers); + +export default { + getBootstrap, +}; diff --git a/client/src/api/config.js b/client/src/api/config.js index 65577881..ea127a17 100644 --- a/client/src/api/config.js +++ b/client/src/api/config.js @@ -3,12 +3,18 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -import http from './http'; +import socket from './socket'; /* Actions */ -const getConfig = (headers) => http.get('/config', undefined, headers); +const getConfig = (headers) => socket.get('/config', undefined, headers); + +const updateConfig = (data, headers) => socket.patch('/config', data, headers); + +const testSmtpConfig = (headers) => socket.post('/config/test-smtp', undefined, headers); export default { getConfig, + updateConfig, + testSmtpConfig, }; diff --git a/client/src/api/index.js b/client/src/api/index.js index 98d045f1..eea95c26 100755 --- a/client/src/api/index.js +++ b/client/src/api/index.js @@ -5,9 +5,10 @@ import http from './http'; import socket from './socket'; -import config from './config'; +import bootstrap from './bootstrap'; import terms from './terms'; import accessTokens from './access-tokens'; +import config from './config'; import webhooks from './webhooks'; import users from './users'; import projects from './projects'; @@ -35,9 +36,10 @@ import notificationServices from './notification-services'; export { http, socket }; export default { - ...config, + ...bootstrap, ...terms, ...accessTokens, + ...config, ...webhooks, ...users, ...projects, diff --git a/client/src/components/common/AdministrationModal/AdministrationModal.jsx b/client/src/components/common/AdministrationModal/AdministrationModal.jsx index d79d22ec..01fe2aa9 100644 --- a/client/src/components/common/AdministrationModal/AdministrationModal.jsx +++ b/client/src/components/common/AdministrationModal/AdministrationModal.jsx @@ -5,18 +5,22 @@ import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { Modal, Tab } from 'semantic-ui-react'; +import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { useClosableModal } from '../../../hooks'; import UsersPane from './UsersPane'; +import SmtpPane from './SmtpPane'; import WebhooksPane from './WebhooksPane'; import styles from './AdministrationModal.module.scss'; const AdministrationModal = React.memo(() => { + const config = useSelector(selectors.selectConfig); + const dispatch = useDispatch(); const [t] = useTranslation(); const [activeTabIndex, setActiveTabIndex] = useState(0); @@ -38,13 +42,21 @@ const AdministrationModal = React.memo(() => { }), render: () => , }, - { - menuItem: t('common.webhooks', { + ]; + if (config.smtpHost !== undefined) { + panes.push({ + menuItem: t('common.smtp', { context: 'title', }), - render: () => , - }, - ]; + render: () => , + }); + } + panes.push({ + menuItem: t('common.webhooks', { + context: 'title', + }), + render: () => , + }); const isUsersPaneActive = activeTabIndex === 0; diff --git a/client/src/components/common/AdministrationModal/SmtpPane.jsx b/client/src/components/common/AdministrationModal/SmtpPane.jsx new file mode 100644 index 00000000..80d8955a --- /dev/null +++ b/client/src/components/common/AdministrationModal/SmtpPane.jsx @@ -0,0 +1,227 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +import { dequal } from 'dequal'; +import React, { useCallback, useMemo } from 'react'; +import classNames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import TextareaAutosize from 'react-textarea-autosize'; +import { Button, Checkbox, Divider, Form, Header, Tab, TextArea } from 'semantic-ui-react'; +import { Input } from '../../../lib/custom-ui'; + +import selectors from '../../../selectors'; +import entryActions from '../../../entry-actions'; +import { useForm, useNestedRef } from '../../../hooks'; + +import styles from './SmtpPane.module.scss'; + +const SmtpPane = React.memo(() => { + const config = useSelector(selectors.selectConfig); + const smtpTest = useSelector(selectors.selectSmtpTest); + + const dispatch = useDispatch(); + const [t] = useTranslation(); + + const [passwordFieldRef, handlePasswordFieldRef] = useNestedRef('inputRef'); + + const defaultData = useMemo( + () => ({ + smtpHost: config.smtpHost, + smtpPort: config.smtpPort, + smtpName: config.smtpName, + smtpSecure: config.smtpSecure, + smtpTlsRejectUnauthorized: config.smtpTlsRejectUnauthorized, + smtpUser: config.smtpUser, + smtpPassword: config.smtpPassword, + smtpFrom: config.smtpFrom, + }), + [config], + ); + + const [data, handleFieldChange] = useForm(() => ({ + ...defaultData, + smtpHost: defaultData.smtpHost || '', + smtpPort: defaultData.smtpPort || '', + smtpName: defaultData.smtpName || '', + smtpSecure: defaultData.smtpSecure, + smtpTlsRejectUnauthorized: defaultData.smtpTlsRejectUnauthorized, + smtpUser: defaultData.smtpUser || '', + smtpPassword: defaultData.smtpPassword || '', + smtpFrom: defaultData.smtpFrom || '', + })); + + const isPasswordSet = defaultData.smtpPassword === undefined; + + const cleanData = useMemo( + () => ({ + ...data, + smtpHost: data.smtpHost.trim() || null, + smtpPort: parseInt(data.smtpPort, 10) || null, + smtpName: data.smtpName.trim() || null, + smtpUser: data.smtpUser.trim() || null, + smtpPassword: data.smtpPassword || (isPasswordSet ? undefined : null), + smtpFrom: data.smtpFrom.trim() || null, + }), + [data, isPasswordSet], + ); + + const handleSubmit = useCallback(() => { + dispatch(entryActions.updateConfig(cleanData)); + }, [dispatch, cleanData]); + + const handlePasswordClear = useCallback(() => { + dispatch( + entryActions.updateConfig({ + smtpPassword: null, + }), + ); + + passwordFieldRef.current.focus(); + }, [dispatch, passwordFieldRef]); + + const handleTestClick = useCallback(() => { + dispatch(entryActions.testSmtpConfig()); + }, [dispatch]); + + const isModified = !dequal(cleanData, defaultData); + + return ( + +
+
{t('common.host')}
+ +
{t('common.port')}
+ +
+ {t('common.clientHostnameInEhlo')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ + + +
+ {t('common.username')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ +
+ {t('common.password')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ +
+ {t('common.defaultFrom')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ +
+
+ + {smtpTest.logs && ( + <> + +
+ {t('common.testLog', { + context: 'title', + })} +
+
+