feat: add verification function to the notification settings for the mailbox (#5464)

#### What type of PR is this?

/kind feature
/area ui
/area core
/milestone 2.14.x

#### What this PR does / why we need it:

为邮件的 `通知设置` 添加验证的功能。

同时为 formkit 增加了一个新的组件 (verificationForm),用于支持验证,它的定义方式如下:
```
- $formkit: verificationForm
  action: "http://localhost:8090/verify/user"
  label: 用户验证
  children:
    - $formkit: text
      label: "用户名"
      name: username
      validation: required
    - $formkit: password
      label: "密码"
      name: password
      validation: required
```

verificationForm 支持 `action` 属性,当前端数据验证通过时,会将其下所包含的子节点数据发送至 action 所代表的接口上。
按上述示例,则验证数据会提交至 `http://localhost:8090/verify/user` 进行验证。验证的数据为 `{name: xxx, password: xxx}`

需要注意的是,verificationForm 只用于包装需要验证的数据,不会破坏原始数据的格式。因此上述数据在提交保存后仍旧为 `{name: xxx, password: xxx}` 而不会变成 `{verificationForm1: {name: xxx, password: xxx}}`

#### How to test it?

1. 测试邮箱中的 `通知设置` 新增的验证按钮是否可以正常验证邮箱。
2. 查看数据是否正常回显

#### Which issue(s) this PR fixes:

Fixes #4714 

#### Does this PR introduce a user-facing change?
```release-note
在邮件通知设置中增加了发送测试的功能。
```
pull/5596/head
Takagi 2024-03-26 16:00:07 +08:00 committed by GitHub
parent 78c00a28f3
commit a2810156da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 564 additions and 117 deletions

View File

@ -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;
/**
* <p>A notifier that can send email.</p>
@ -34,7 +32,8 @@ public class EmailNotifier implements ReactiveNotifier {
private final SubscriberEmailResolver subscriberEmailResolver;
private final NotificationTemplateRender notificationTemplateRender;
private final AtomicReference<Pair<EmailSenderConfig, JavaMailSenderImpl>>
private final EmailSenderHelper emailSenderHelper;
private final AtomicReference<Pair<EmailSenderConfig, JavaMailSender>>
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 {
</div>
""", 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);
}
}
}

View File

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

View File

@ -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;
/**
* <p>A default implementation of {@link EmailSenderHelper}.</p>
*
* @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);
};
}
}

View File

@ -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 = """
<br/>
<br/>
<br/>
""";
private final EmailSenderHelper emailSenderHelper;
private final ReactiveExtensionClient client;
@Override
public RouterFunction<ServerResponse> 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<ServerResponse> 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<String> 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");
}
}

View File

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

View File

@ -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" ]

View File

@ -38,6 +38,11 @@
- 参数
1. multiple: 是否多选,默认为 `false`
- `tagCheckbox`:选择多个标签
- `verificationForm`: 远程验证一组数据是否符合某个规则
- 参数
1. action: 对目标数据进行验证的接口地址
2. label: 验证按钮文本
3. buttonAttrs: 验证按钮的额外属性
在 Vue 单组件中使用:

View File

@ -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",

View File

@ -0,0 +1,137 @@
<script lang="ts" setup>
import {
VButton,
Toast,
IconCheckboxCircle,
IconErrorWarning,
} from "@halo-dev/components";
import { createMessage, type FormKitFrameworkContext } from "@formkit/core";
import type { PropType } from "vue";
import { i18n } from "@/locales";
import { axiosInstance } from "@/utils/api-client";
import { nextTick, onMounted, ref } from "vue";
const props = defineProps({
context: {
type: Object as PropType<FormKitFrameworkContext>,
required: true,
},
});
const loadingState = ref<boolean>(false);
const stateMessage = ref<{
state: "default" | "success" | "error";
message?: string;
}>({
state: "default",
});
const loading = createMessage({
key: "loading",
value: true,
visible: false,
});
/**
* Handle the verify event.
*
* @param e - The event
*
* @internal
*/
async function handleVerification(event: Event) {
const node = props.context.node;
event.preventDefault();
await node.settled;
if (!node.ledger.value("blocking")) {
verifyAction();
}
}
/**
* verify action
* @param node
*/
function verifyAction() {
const node = props.context.node;
const action = node.props.action;
if (!action) {
const message = i18n.global.t(
"core.formkit.verification_form.no_action_defined",
{
label: node.props.label,
}
);
stateMessage.value = {
state: "error",
message,
};
Toast.error(message);
return;
}
loadingState.value = true;
const val = node.value as Record<string, unknown>;
node.store.set(loading);
// call onSubmit
axiosInstance
.post(action, val)
.then(() => {
stateMessage.value = {
state: "success",
};
Toast.success(
i18n.global.t("core.formkit.verification_form.verify_success", {
label: node.props.label,
})
);
})
.catch((error) => {
stateMessage.value = {
state: "error",
};
const errorResponse = error.response;
const { title, detail } = errorResponse.data;
if (title || detail) {
stateMessage.value.message = detail || title;
}
})
.finally(() => {
node.store.remove("loading");
loadingState.value = false;
});
}
onMounted(() => {
nextTick(() => {
const node = props.context.node;
node.on("commit", () => {
stateMessage.value = {
state: "default",
};
});
});
});
</script>
<template>
<div :class="context.classes.submit" class="py-4" @click="handleVerification">
<VButton
v-tooltip="stateMessage.message"
:disabled="context.node.props.buttonAttrs.disabled"
:loading="loadingState"
>
{{ context.node.props.label }}
<template v-if="stateMessage.state !== 'default'" #icon>
<IconCheckboxCircle
v-if="stateMessage.state === 'success'"
class="h-full w-full text-green-500"
/>
<IconErrorWarning
v-else-if="stateMessage.state === 'error'"
class="h-full w-full text-red-500"
/>
</template>
</VButton>
</div>
</template>

View File

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

View File

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

View File

@ -33,6 +33,7 @@ const theme: Record<string, Record<string, string>> = {
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: {

View File

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

View File

@ -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: 保存

View File

@ -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: 保存