diff --git a/application/src/main/java/run/halo/app/notification/EmailNotifier.java b/application/src/main/java/run/halo/app/notification/EmailNotifier.java index 0a49e2298..be6320adf 100644 --- a/application/src/main/java/run/halo/app/notification/EmailNotifier.java +++ b/application/src/main/java/run/halo/app/notification/EmailNotifier.java @@ -1,23 +1,21 @@ package run.halo.app.notification; import com.fasterxml.jackson.databind.JsonNode; -import java.nio.charset.StandardCharsets; -import java.util.Properties; import java.util.concurrent.atomic.AtomicReference; -import lombok.Data; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.data.util.Pair; +import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; -import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessagePreparator; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.notification.EmailSenderHelper.EmailSenderConfig; /** *

A notifier that can send email.

@@ -34,7 +32,8 @@ public class EmailNotifier implements ReactiveNotifier { private final SubscriberEmailResolver subscriberEmailResolver; private final NotificationTemplateRender notificationTemplateRender; - private final AtomicReference> + private final EmailSenderHelper emailSenderHelper; + private final AtomicReference> emailSenderConfigPairRef = new AtomicReference<>(); @Override @@ -48,7 +47,7 @@ public class EmailNotifier implements ReactiveNotifier { return Mono.empty(); } - JavaMailSenderImpl javaMailSender = getJavaMailSender(emailSenderConfig); + JavaMailSender javaMailSender = getJavaMailSender(emailSenderConfig); String recipient = context.getMessage().getRecipient(); var subscriber = new Subscription.Subscriber(); @@ -83,55 +82,20 @@ public class EmailNotifier implements ReactiveNotifier { } @NonNull - private static MimeMessagePreparator getMimeMessagePreparator(String toEmail, + private MimeMessagePreparator getMimeMessagePreparator(String toEmail, EmailSenderConfig emailSenderConfig, NotificationContext.MessagePayload payload) { - return mimeMessage -> { - MimeMessageHelper helper = - new MimeMessageHelper(mimeMessage, true, StandardCharsets.UTF_8.name()); - helper.setFrom(emailSenderConfig.getSender(), emailSenderConfig.getDisplayName()); - - helper.setSubject(payload.getTitle()); - helper.setText(payload.getRawBody(), payload.getHtmlBody()); - helper.setTo(toEmail); - }; + return emailSenderHelper.createMimeMessagePreparator(emailSenderConfig, toEmail, + payload.getTitle(), + payload.getRawBody(), payload.getHtmlBody()); } - @NonNull - private static JavaMailSenderImpl createJavaMailSender(EmailSenderConfig emailSenderConfig) { - JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); - javaMailSender.setHost(emailSenderConfig.getHost()); - javaMailSender.setPort(emailSenderConfig.getPort()); - javaMailSender.setUsername(emailSenderConfig.getUsername()); - javaMailSender.setPassword(emailSenderConfig.getPassword()); - - Properties props = javaMailSender.getJavaMailProperties(); - props.put("mail.transport.protocol", "smtp"); - props.put("mail.smtp.auth", "true"); - if ("SSL".equals(emailSenderConfig.getEncryption())) { - props.put("mail.smtp.ssl.enable", "true"); - } - - if ("TLS".equals(emailSenderConfig.getEncryption())) { - props.put("mail.smtp.starttls.enable", "true"); - } - - if ("NONE".equals(emailSenderConfig.getEncryption())) { - props.put("mail.smtp.ssl.enable", "false"); - props.put("mail.smtp.starttls.enable", "false"); - } - - if (log.isDebugEnabled()) { - props.put("mail.debug", "true"); - } - return javaMailSender; - } - - JavaMailSenderImpl getJavaMailSender(EmailSenderConfig emailSenderConfig) { + JavaMailSender getJavaMailSender(EmailSenderConfig emailSenderConfig) { return emailSenderConfigPairRef.updateAndGet(pair -> { if (pair != null && pair.getFirst().equals(emailSenderConfig)) { return pair; } - return Pair.of(emailSenderConfig, createJavaMailSender(emailSenderConfig)); + return Pair.of(emailSenderConfig, + emailSenderHelper.createJavaMailSender(emailSenderConfig)); }).getSecond(); } @@ -156,34 +120,4 @@ public class EmailNotifier implements ReactiveNotifier { """, attributes); } - - @Data - static class EmailSenderConfig { - private boolean enable; - private String displayName; - private String username; - private String password; - private String host; - private Integer port; - private String encryption; - private String sender; - - /** - * Gets email display name. - * - * @return display name if not blank, otherwise username. - */ - public String getDisplayName() { - return StringUtils.defaultIfBlank(displayName, username); - } - - /** - * Gets email sender address. - * - * @return sender if not blank, otherwise username. - */ - public String getSender() { - return StringUtils.defaultIfBlank(sender, username); - } - } } diff --git a/application/src/main/java/run/halo/app/notification/EmailSenderHelper.java b/application/src/main/java/run/halo/app/notification/EmailSenderHelper.java new file mode 100644 index 000000000..48d0bd3a7 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/EmailSenderHelper.java @@ -0,0 +1,47 @@ +package run.halo.app.notification; + +import lombok.Data; +import lombok.NonNull; +import org.apache.commons.lang3.StringUtils; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessagePreparator; + +public interface EmailSenderHelper { + + @NonNull + JavaMailSender createJavaMailSender(EmailSenderConfig senderConfig); + + @NonNull + MimeMessagePreparator createMimeMessagePreparator(EmailSenderConfig senderConfig, + String toEmail, String subject, String raw, String html); + + @Data + class EmailSenderConfig { + private boolean enable; + private String displayName; + private String username; + private String sender; + private String password; + private String host; + private Integer port; + private String encryption; + + /** + * Gets email display name. + * + * @return display name if not blank, otherwise username. + */ + public String getDisplayName() { + return StringUtils.defaultIfBlank(displayName, username); + } + + /** + * Gets email sender address. + * + * @return sender if not blank, otherwise username + */ + public String getSender() { + return StringUtils.defaultIfBlank(sender, username); + } + } +} diff --git a/application/src/main/java/run/halo/app/notification/EmailSenderHelperImpl.java b/application/src/main/java/run/halo/app/notification/EmailSenderHelperImpl.java new file mode 100644 index 000000000..d0186499d --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/EmailSenderHelperImpl.java @@ -0,0 +1,68 @@ +package run.halo.app.notification; + +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.mail.javamail.MimeMessagePreparator; +import org.springframework.stereotype.Component; + +/** + *

A default implementation of {@link EmailSenderHelper}.

+ * + * @author guqing + * @since 2.14.0 + */ +@Slf4j +@Component +public class EmailSenderHelperImpl implements EmailSenderHelper { + + @Override + @NonNull + public JavaMailSender createJavaMailSender(EmailSenderConfig senderConfig) { + JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); + javaMailSender.setHost(senderConfig.getHost()); + javaMailSender.setPort(senderConfig.getPort()); + javaMailSender.setUsername(senderConfig.getUsername()); + javaMailSender.setPassword(senderConfig.getPassword()); + + Properties props = javaMailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + if ("SSL".equals(senderConfig.getEncryption())) { + props.put("mail.smtp.ssl.enable", "true"); + } + + if ("TLS".equals(senderConfig.getEncryption())) { + props.put("mail.smtp.starttls.enable", "true"); + } + + if ("NONE".equals(senderConfig.getEncryption())) { + props.put("mail.smtp.ssl.enable", "false"); + props.put("mail.smtp.starttls.enable", "false"); + } + + if (log.isDebugEnabled()) { + props.put("mail.debug", "true"); + } + return javaMailSender; + } + + @Override + @NonNull + public MimeMessagePreparator createMimeMessagePreparator(EmailSenderConfig senderConfig, + String toEmail, String subject, String raw, String html) { + return mimeMessage -> { + MimeMessageHelper helper = + new MimeMessageHelper(mimeMessage, true, StandardCharsets.UTF_8.name()); + helper.setFrom(senderConfig.getSender(), senderConfig.getDisplayName()); + + helper.setSubject(subject); + helper.setText(raw, html); + helper.setTo(toEmail); + }; + } +} diff --git a/application/src/main/java/run/halo/app/notification/endpoint/EmailConfigValidationEndpoint.java b/application/src/main/java/run/halo/app/notification/endpoint/EmailConfigValidationEndpoint.java new file mode 100644 index 000000000..8c219a2af --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/endpoint/EmailConfigValidationEndpoint.java @@ -0,0 +1,116 @@ +package run.halo.app.notification.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.security.Principal; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.mail.MailException; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.notification.EmailSenderHelper; + +/** + * Validation endpoint for email config. + * + * @author guqing + * @since 2.14.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailConfigValidationEndpoint implements CustomEndpoint { + private static final String EMAIL_SUBJECT = "测试邮件 - 验证邮箱连通性"; + private static final String EMAIL_BODY = """ + 你好!
+ 这是一封测试邮件,旨在验证您的邮箱发件配置是否正确。
+ 此邮件由系统自动发送,请勿回复。
+ 祝好 + """; + + private final EmailSenderHelper emailSenderHelper; + private final ReactiveExtensionClient client; + + @Override + public RouterFunction endpoint() { + var tag = "console.api.notification.halo.run/v1alpha1/Notifier"; + return SpringdocRouteBuilder.route() + .POST("/notifiers/default-email-notifier/verify-connection", + this::verifyEmailSenderConfig, + builder -> builder.operationId("VerifyEmailSenderConfig") + .description("Verify email sender config.") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .implementation(ValidationRequest.class) + ) + .response(responseBuilder().implementation(Void.class)) + ) + .build(); + } + + private Mono verifyEmailSenderConfig(ServerRequest request) { + return request.bodyToMono(ValidationRequest.class) + .switchIfEmpty( + Mono.error(new ServerWebInputException("Required request body is missing.")) + ) + .flatMap(validationRequest -> getCurrentUserEmail() + .flatMap(recipient -> { + var mailSender = emailSenderHelper.createJavaMailSender(validationRequest); + var message = emailSenderHelper.createMimeMessagePreparator(validationRequest, + recipient, EMAIL_SUBJECT, EMAIL_BODY, EMAIL_BODY); + try { + mailSender.send(message); + } catch (MailException e) { + String errorMsg = + "Failed to send email, please check your email configuration."; + log.error(errorMsg, e); + throw new ServerWebInputException(errorMsg, null, e); + } + return ServerResponse.ok().build(); + }) + ); + } + + Mono getCurrentUserEmail() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Principal::getName) + .flatMap(username -> client.fetch(User.class, username)) + .flatMap(user -> { + var email = user.getSpec().getEmail(); + if (StringUtils.isBlank(email)) { + return Mono.error(new ServerWebInputException( + "Your email is missing, please set it in your profile.")); + } + return Mono.just(email); + }); + } + + @Data + @EqualsAndHashCode(callSuper = true) + @Schema(name = "EmailConfigValidationRequest") + static class ValidationRequest extends EmailSenderHelper.EmailSenderConfig { + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("console.api.notification.halo.run/v1alpha1"); + } +} diff --git a/application/src/main/resources/extensions/notification.yaml b/application/src/main/resources/extensions/notification.yaml index 15eaf1954..2e6e9dac9 100644 --- a/application/src/main/resources/extensions/notification.yaml +++ b/application/src/main/resources/extensions/notification.yaml @@ -23,47 +23,46 @@ spec: label: "启用邮件通知器" value: false name: enable - - $formkit: text + - $formkit: verificationForm if: "$enable" - label: "用户名" - name: username - validation: required - - $formkit: text - if: "$enable" - label: "发信地址" - name: "sender" - help: "如果用户名为实际发信地址,可忽略" - - $formkit: password - if: "$enable" - label: "密码" - name: password - validation: required - - $formkit: text - if: "$enable" - label: "显示名称" - name: displayName - - $formkit: text - if: "$enable" - label: "SMTP 服务器地址" - name: host - validation: required - - $formkit: text - if: "$enable" - label: "端口号" - name: port - validation: required - - $formkit: select - if: "$enable" - label: "加密方式" - name: encryption - value: "SSL" - options: - - label: "SSL" + action: /apis/console.api.notification.halo.run/v1alpha1/notifiers/default-email-notifier/verify-connection + label: 测试邮箱 + children: + - $formkit: text + label: "用户名" + name: username + validation: required + - $formkit: text + if: "$enable" + label: "发信地址" + name: "sender" + help: "如果用户名为实际发信地址,可忽略" + - $formkit: password + label: "密码" + name: password + validation: required + - $formkit: text + label: "显示名称" + name: displayName + - $formkit: text + label: "SMTP 服务器地址" + name: host + validation: required + - $formkit: text + label: "端口号" + name: port + validation: required + - $formkit: select + label: "加密方式" + name: encryption value: "SSL" - - label: "TLS" - value: "TLS" - - label: "不加密" - value: "NONE" + options: + - label: "SSL" + value: "SSL" + - label: "TLS" + value: "TLS" + - label: "不加密" + value: "NONE" --- apiVersion: notification.halo.run/v1alpha1 kind: ReasonType diff --git a/application/src/main/resources/extensions/role-template-notification.yaml b/application/src/main/resources/extensions/role-template-notification.yaml index 9ddfc8cab..fdce8c33f 100644 --- a/application/src/main/resources/extensions/role-template-notification.yaml +++ b/application/src/main/resources/extensions/role-template-notification.yaml @@ -15,3 +15,7 @@ rules: - apiGroups: [ "api.console.halo.run" ] resources: [ "notifiers/sender-config" ] verbs: [ "get", "update" ] + - apiGroups: [ "console.api.notification.halo.run" ] + resources: [ "notifiers/verify-connection" ] + resourceNames: [ "default-email-notifier" ] + verbs: [ "create" ] diff --git a/ui/docs/custom-formkit-input/README.md b/ui/docs/custom-formkit-input/README.md index 1a697b73f..eb0f95199 100644 --- a/ui/docs/custom-formkit-input/README.md +++ b/ui/docs/custom-formkit-input/README.md @@ -38,6 +38,11 @@ - 参数 1. multiple: 是否多选,默认为 `false` - `tagCheckbox`:选择多个标签 +- `verificationForm`: 远程验证一组数据是否符合某个规则 + - 参数 + 1. action: 对目标数据进行验证的接口地址 + 2. label: 验证按钮文本 + 3. buttonAttrs: 验证按钮的额外属性 在 Vue 单组件中使用: diff --git a/ui/src/formkit/formkit.config.ts b/ui/src/formkit/formkit.config.ts index 6f5907ae6..84861bf41 100644 --- a/ui/src/formkit/formkit.config.ts +++ b/ui/src/formkit/formkit.config.ts @@ -21,6 +21,7 @@ import { roleSelect } from "./inputs/role-select"; import { attachmentPolicySelect } from "./inputs/attachment-policy-select"; import { attachmentGroupSelect } from "./inputs/attachment-group-select"; import { password } from "./inputs/password"; +import { verificationForm } from "./inputs/verify-form"; import radioAlt from "./plugins/radio-alt"; import stopImplicitSubmission from "./plugins/stop-implicit-submission"; @@ -59,6 +60,7 @@ const config: DefaultConfigOptions = { roleSelect, attachmentPolicySelect, attachmentGroupSelect, + verificationForm, }, locales: { zh, en }, locale: "zh", diff --git a/ui/src/formkit/inputs/verify-form/VerificationButton.vue b/ui/src/formkit/inputs/verify-form/VerificationButton.vue new file mode 100644 index 000000000..298aa4369 --- /dev/null +++ b/ui/src/formkit/inputs/verify-form/VerificationButton.vue @@ -0,0 +1,137 @@ + + + diff --git a/ui/src/formkit/inputs/verify-form/features/index.ts b/ui/src/formkit/inputs/verify-form/features/index.ts new file mode 100644 index 000000000..d00e652db --- /dev/null +++ b/ui/src/formkit/inputs/verify-form/features/index.ts @@ -0,0 +1,58 @@ +import { i18n } from "@/locales"; +import { type FormKitNode } from "@formkit/core"; + +function buildVerifyFormValue(node: FormKitNode) { + if (!node.parent) return {}; + const parentValue = { + ...(node.parent.value as Record), + }; + delete parentValue[node.name]; + return parentValue; +} + +/** + * A feature to add a submit handler and actions section. + * + * @param node - A {@link @formkit/core#FormKitNode | FormKitNode}. + * + * @public + */ +export default function verify(node: FormKitNode): void { + node.props.buttonAttrs ??= { + disabled: node.props.disabled, + }; + + node.props.label ??= i18n.global.t("core.common.buttons.verify"); + + node.on("prop:disabled", ({ payload: disabled }) => { + node.props.buttonAttrs = { ...node.props.buttonAttrs, disabled }; + }); + + node.on("created", () => { + if (node.parent) { + node.parent.hook.commit((val) => { + const parentValue = { + ...val, + }; + const verifyFormVal = (parentValue[node.name] || {}) as Record< + string, + unknown + >; + delete parentValue[node.name]; + const mergeFormValue = {}; + Object.keys(verifyFormVal).forEach((key) => { + if (node.children.find((child) => child.name === key)) { + mergeFormValue[key] = verifyFormVal[key]; + } + }); + return { ...parentValue, ...mergeFormValue }; + }); + + node.hook.input(() => { + return buildVerifyFormValue(node); + }); + + node.input(buildVerifyFormValue(node)); + } + }); +} diff --git a/ui/src/formkit/inputs/verify-form/index.ts b/ui/src/formkit/inputs/verify-form/index.ts new file mode 100644 index 000000000..8ddc4b26f --- /dev/null +++ b/ui/src/formkit/inputs/verify-form/index.ts @@ -0,0 +1,64 @@ +import type { FormKitTypeDefinition } from "@formkit/core"; +import { + createSection, + disablesChildren, + message, + messages, +} from "@formkit/inputs"; +import { default as verifyFeature } from "./features"; +import VerificationButton from "./VerificationButton.vue"; + +export const verifyInput = createSection("verificationForm", () => ({ + $el: "div", + bind: "$attrs", + attrs: { + id: "$id", + name: "$node.name", + "data-loading": "$state.loading || undefined", + }, +})); + +export const actions = createSection("actions", () => ({ + $el: "div", +})); + +export const verificationButton = createSection("verificationButton", () => ({ + $cmp: "VerificationButton", + type: "button", + bind: "$buttonAttrs", + props: { + context: "$node.context", + }, +})); + +/** + * Input definition for a form. + * @public + */ +export const verificationForm: FormKitTypeDefinition = { + /** + * The actual schema of the input, or a function that returns the schema. + */ + schema: verifyInput( + "$slots.default", + messages(message("$message.value")), + actions(verificationButton()) + ), + /** + * The type of node, can be a list, group, or input. + */ + type: "group", + /** + * An array of extra props to accept for this input. + */ + props: ["action", "label", "buttonAttrs"], + + /** + * Additional features that should be added to your input + */ + features: [verifyFeature, disablesChildren], + + library: { + VerificationButton, + }, +}; diff --git a/ui/src/formkit/theme.ts b/ui/src/formkit/theme.ts index ef3b10a16..d0f315be1 100644 --- a/ui/src/formkit/theme.ts +++ b/ui/src/formkit/theme.ts @@ -33,6 +33,7 @@ const theme: Record> = { help: "text-xs mt-2 text-gray-500", messages: "list-none p-0 mt-1.5 mb-0 transition-all", message: "text-red-500 mt-2 text-xs", + verificationForm: "pt-4 divide-y divide-gray-100", }, button: buttonClassification, color: { diff --git a/ui/src/locales/en.yaml b/ui/src/locales/en.yaml index e0dd6f2b1..f55a611fa 100644 --- a/ui/src/locales/en.yaml +++ b/ui/src/locales/en.yaml @@ -1482,6 +1482,10 @@ core: creation_label: Create {text} tag validation: trim: Please remove the leading and trailing spaces + verification_form: + no_action_defined: "{label} interface not defined" + verify_success: "{label} successful" + verify_failed: "{label} failed" common: buttons: save: Save diff --git a/ui/src/locales/zh-CN.yaml b/ui/src/locales/zh-CN.yaml index 35c9dd770..9844cb891 100644 --- a/ui/src/locales/zh-CN.yaml +++ b/ui/src/locales/zh-CN.yaml @@ -1428,6 +1428,10 @@ core: creation_label: 创建 {text} 标签 validation: trim: 不能以空格开头或结尾 + verification_form: + no_action_defined: 未定义{label}接口 + verify_success: "{label}成功" + verify_failed: "{label}失败" common: buttons: save: 保存 diff --git a/ui/src/locales/zh-TW.yaml b/ui/src/locales/zh-TW.yaml index acfe887d2..31517402c 100644 --- a/ui/src/locales/zh-TW.yaml +++ b/ui/src/locales/zh-TW.yaml @@ -1394,6 +1394,10 @@ core: creation_label: 創建 {text} 標籤 validation: trim: 不能以空格開頭或結尾 + verification_form: + no_action_defined: 未定義{label}介面 + verify_success: "{label}成功" + verify_failed: "{label}失敗" common: buttons: save: 保存