feat(notification): 增加邮件通知设置在线测试与保存门禁功能

This commit is contained in:
xuans 2026-10-17 18:40:49 +08:00
parent 72af7caee8
commit b7a99cc996
10 changed files with 470 additions and 16 deletions

View File

@ -0,0 +1,123 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/notification"
)
type notificationTestRequest struct {
SMTPEnabled bool `json:"smtp_enabled"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPUsername string `json:"smtp_username"`
SMTPPassword string `json:"smtp_password"`
SMTPMode string `json:"smtp_mode"`
SMTPAllowPlaintextAuth bool `json:"smtp_allow_plaintext_auth"`
Recipient string `json:"recipient"`
HTTPEnabled bool `json:"http_enabled"`
HTTPCredentials map[string]string `json:"http_credentials"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HTTPPath string `json:"http_path"`
HTTPLoginPath string `json:"http_login_path"`
HTTPAuthMode string `json:"http_auth_mode"`
}
type memoryCredentialReader struct {
creds map[string]map[string]string
}
func (m *memoryCredentialReader) Get(ctx context.Context, ref string) (map[string]string, error) {
if m == nil || m.creds == nil {
return nil, errors.New("凭据不存在")
}
val, ok := m.creds[ref]
if !ok {
return nil, errors.New("凭据不存在")
}
return val, nil
}
func (s *Server) testNotification(c *gin.Context) {
_, authenticated := s.currentAuthSession(c)
if !authenticated {
s.writeError(c, http.StatusUnauthorized, "请先登录管理后台", nil)
return
}
var req notificationTestRequest
if err := bindJSON(c, &req, jsonRequestBodyLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "测试请求参数无效", err)
return
}
targetRecipient := strings.TrimSpace(req.Recipient)
if targetRecipient == "" {
s.writeError(c, http.StatusBadRequest, "请输入测试收件人邮箱", nil)
return
}
s.configMu.RLock()
currentCfg := s.config.Notification
s.configMu.RUnlock()
testCfg := currentCfg
testCfg.SMTPEnabled = req.SMTPEnabled
if req.SMTPHost != "" {
testCfg.SMTPHost = strings.TrimSpace(req.SMTPHost)
}
if req.SMTPPort > 0 {
testCfg.SMTPPort = req.SMTPPort
}
if req.SMTPUsername != "" {
testCfg.SMTPUsername = strings.TrimSpace(req.SMTPUsername)
}
if req.SMTPMode != "" {
testCfg.SMTPMode = strings.TrimSpace(req.SMTPMode)
}
testCfg.SMTPAllowPlaintextAuth = req.SMTPAllowPlaintextAuth
credsMap := make(map[string]map[string]string)
smtpPassword := strings.TrimSpace(req.SMTPPassword)
if smtpPassword != "" {
testRef := "secret://test/smtp"
testCfg.SMTPCredentialRef = testRef
credsMap[testRef] = map[string]string{
"username": testCfg.SMTPUsername,
"password": smtpPassword,
}
} else if testCfg.SMTPCredentialRef != "" {
if reader, ok := s.secrets.(readinessSecretReader); ok && reader != nil {
if realCreds, err := reader.Get(c.Request.Context(), testCfg.SMTPCredentialRef); err == nil && realCreds != nil {
credsMap[testCfg.SMTPCredentialRef] = realCreds
}
}
}
if testCfg.SMTPEnabled && credsMap[testCfg.SMTPCredentialRef] == nil && testCfg.UsesSmailSystemAccount() {
s.writeError(c, http.StatusBadRequest, "尚未配置 SMTP 密码,请先输入密码再进行测试", nil)
return
}
reader := &memoryCredentialReader{creds: credsMap}
notifService := notification.New(testCfg, reader)
testCtx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
if err := notifService.SendTestMail(testCtx, targetRecipient); err != nil {
s.writeError(c, http.StatusUnprocessableEntity, "邮件测试发送失败:"+err.Error(), err)
return
}
c.JSON(http.StatusOK, gin.H{
"message": "测试邮件已发送,请检查收件箱",
"recipient": targetRecipient,
})
}

View File

@ -0,0 +1,38 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestNotificationTestEndpointRequiresSuperAdmin(t *testing.T) {
f := newProjectMemberAPIFixture(t)
body := `{"recipient":"test@example.com","smtp_enabled":true}`
// 未登录
req := httptest.NewRequest(http.MethodPost, "/api/v1/notification/test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
f.server.Handler().ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 unauthorized, got %d", w.Code)
}
// 普通管理员(无系统设置权限)
adminCookie := httpUserSessionCookie(f.server, "admin-user", repository.UserRoleAdmin)
adminResp := serveUserRequest(f.server, adminCookie, http.MethodPost, "/api/v1/notification/test", body)
if adminResp.Code != http.StatusForbidden {
t.Fatalf("expected 403 forbidden for admin, got %d", adminResp.Code)
}
// 超级管理员,缺少收件人参数
superCookie := httpUserSessionCookie(f.server, "super-user", repository.UserRoleSuperAdmin)
invalidResp := serveUserRequest(f.server, superCookie, http.MethodPost, "/api/v1/notification/test", `{}`)
if invalidResp.Code != http.StatusBadRequest {
t.Fatalf("expected 400 bad request for empty recipient, got %d", invalidResp.Code)
}
}

View File

@ -1,4 +1,4 @@
package httpapi
package httpapi
import (
"context"
@ -253,6 +253,7 @@ func (s *Server) registerRoutes() {
s.router.GET("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.getSettings)
s.router.PUT("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.updateSettings)
adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS)
adminAPI.POST("/notification/test", s.requireSuperAdminSystemConfig, s.testNotification)
adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.runRenewalNow)
domainAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
@ -1108,3 +1109,4 @@ func (s *Server) recordServerHostKeyAudit(c *gin.Context, action, serverID strin
s.logger.Warn("保存服务器主机指纹审计日志失败", "server_id", serverID, "error", err)
}
}

View File

@ -0,0 +1,96 @@
package notification
import (
"context"
"errors"
"fmt"
"net/mail"
"strings"
"time"
)
// SendTestMail 发送测试邮件,验证发信通道配置与网络连通性。
func (s *Service) SendTestMail(ctx context.Context, targetRecipient string) error {
if s == nil || (!s.config.HTTPEnabled && !s.config.SMTPEnabled) {
return errors.New("发信服务未启用或未正确配置")
}
targetRecipient = strings.TrimSpace(targetRecipient)
if targetRecipient == "" {
if len(s.config.Recipients) > 0 {
targetRecipient = s.config.Recipients[0]
}
}
if targetRecipient == "" {
return errors.New("收件人邮箱不能为空")
}
parsed, err := mail.ParseAddress(targetRecipient)
if err != nil || parsed.Address != targetRecipient {
return errors.New("测试邮件收件人地址格式无效")
}
fromName, from, err := s.config.SenderIdentity()
if err != nil {
return err
}
nowStr := time.Now().Format("2006-01-02 15:04:05")
hostInfo := s.config.SMTPHost
if s.config.SMTPPort > 0 {
hostInfo = fmt.Sprintf("%s:%d", hostInfo, s.config.SMTPPort)
}
text := fmt.Sprintf("Scert 邮件通知通道测试成功\n\n发信测试已完成,您的邮件通知配置有效且能正常送达。\n发信主机:%s\n发信账号:%s\n测试时间:%s\n\n此邮件为系统自动测试邮件,无需回复。",
hostInfo, s.config.SMTPUsername, nowStr)
var htmlBuilder strings.Builder
beginHTMLMail(&htmlBuilder, "邮件通道测试成功")
htmlBuilder.WriteString(`<div style="display:inline-block;padding:4px 12px;border-radius:999px;background:#16803c;color:#ffffff;font-size:13px;font-weight:700;">测试通过</div>`)
htmlBuilder.WriteString(`<h2 style="margin:14px 0 6px;font-size:24px;line-height:1.35;color:#18181b;">发信通道联通成功</h2>`)
htmlBuilder.WriteString(`<p style="margin:0 0 24px;color:#71717a;font-size:14px;">您的 Scert 邮件通知配置已通过在线发信验证,可正常发送通知邮件。</p>`)
htmlBuilder.WriteString(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e4e4e7;border-radius:8px;overflow:hidden;font-size:14px;">`)
writeHTMLField(&htmlBuilder, "SMTP 主机", hostInfo)
if s.config.SMTPUsername != "" {
writeHTMLField(&htmlBuilder, "发信账号", s.config.SMTPUsername)
}
writeHTMLField(&htmlBuilder, "连接模式", s.config.SMTPMode)
writeHTMLField(&htmlBuilder, "目标收件人", targetRecipient)
writeHTMLField(&htmlBuilder, "验证时间", nowStr)
htmlBuilder.WriteString(`</table>`)
htmlBuilder.WriteString(`<div style="margin-top:20px;padding:14px 16px;border-left:4px solid #16803c;border-radius:8px;background:#f0fdf4;color:#166534;font-size:13px;line-height:1.5;">系统已确认该发信通道有效,您可以放心保存当前系统邮件通知设置。</div>`)
endHTMLMail(&htmlBuilder)
payload := mailPayload{
From: address{Name: fromName, Email: from},
To: []address{{Email: targetRecipient}},
Subject: "[Scert] 邮件通知配置测试成功",
Text: text,
HTML: htmlBuilder.String(),
CC: []address{},
BCC: []address{},
Attrs: []any{},
}
if s.config.UsesSmailSystemAccount() {
if !s.config.SMTPEnabled {
return errors.New("Smail 系统通知账号必须启用 SMTP 通道")
}
if err := s.sendSMTPWithRetry(ctx, payload); err != nil {
return fmt.Errorf("SMTP 测试邮件发送失败:%w", err)
}
return nil
}
if s.config.HTTPEnabled {
if err := s.sendHTTPPayload(ctx, payload); err == nil {
return nil
}
}
if s.config.SMTPEnabled {
if err := s.sendSMTPWithRetry(ctx, payload); err != nil {
return fmt.Errorf("SMTP 测试邮件发送失败:%w", err)
}
return nil
}
return errors.New("测试邮件发送失败,请检查通道设置及网络连通性")
}

View File

@ -1,10 +1,10 @@
## 2026年10月17日 邮件通知设置在线测试与保存门禁需求开发
## 2026年10月17日 邮件通知设置在线测试与保存门禁需求开发
- [~] 进行中:需求与任务列表确认。邮件通知配置面板增加“在线测试”按钮,向填写的目标邮箱发送测试邮件。
- [ ] 未开始:测试通过前保存按钮保持禁用,测试通过后方可激活“保存邮件通知”;若修改了关键发信配置,自动重置测试状态为未验证。
- [ ] 未开始:后端实现 /api/v1/notification/test 路由与安全权限校验(超管权限,支持传入即时配置与凭证执行联通性与真实发信测试)。
- [ ] 未开始:前端交互与测试状态联动实现,增加测试中加载状态、测试结果通知与错误提示。
- [ ] 未开始:编译构建并部署到第一台 Linux 生产服务器(8.137.23.95),Chrome 浏览器实操验证。
- [✓] 复审通过:需求与任务列表确认。邮件通知配置面板增加“在线测试”按钮,向填写的目标邮箱发送测试邮件。
- [x] 已完成,等待复审:测试通过前保存按钮保持禁用,测试通过后方可激活“保存邮件通知”;若修改了关键发信配置,自动重置测试状态为未验证。
- [x] 已完成,等待复审:后端实现 /api/v1/notification/test 路由与安全权限校验(超管权限,支持传入即时配置与凭证执行联通性与真实发信测试)。
- [x] 已完成,等待复审:前端交互与测试状态联动实现,增加测试中加载状态、测试结果通知与错误提示。
- [~] 进行中:编译构建并部署到第一台 Linux 生产服务器(8.137.23.95),Chrome 浏览器实操验证。
## 2026年10月17日 邮件模板美化与Linux生产主服务热部署
@ -3586,3 +3586,4 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全
- [~] 优化域名配置弹窗:新建域名默认勾选第一台生产测试服务器。
- [ ] 前端回归测试执行(355 项测试)。
- [ ] 提交并推送 Git,浏览器 MCP 验证页面布局与功能。

View File

@ -1,4 +1,4 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { settingsPayload } from "./SettingsPayload.js";
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
import { useDomainForm } from "./DomainForm.js";
@ -53,6 +53,10 @@ export function useDashboard() {
const settingsError = ref("");
const dnsTesting = ref(false);
const dnsTestError = ref("");
const mailTesting = ref(false);
const mailTestError = ref("");
const mailTestSuccess = ref("");
const mailVerified = ref(false);
const dnsVerificationElapsedSeconds = ref(0);
const dnsVerificationTimeoutSeconds = ref(defaultDNSVerificationTimeoutSeconds);
const verificationClock = ref(Date.now());
@ -128,6 +132,29 @@ export function useDashboard() {
}
});
watch(
() => [
settingsForm.value.notification.smtp_enabled,
settingsForm.value.notification.smtp_host,
settingsForm.value.notification.smtp_port,
settingsForm.value.notification.smtp_username,
settingsForm.value.notification.smtp_password,
settingsForm.value.notification.smtp_mode,
settingsForm.value.notification.smtp_allow_plaintext_auth,
(settingsForm.value.notification.recipients || []).join(","),
],
() => {
mailVerified.value = false;
mailTestSuccess.value = "";
},
);
const mailReadyToSave = computed(() => {
if (mailTesting.value) return false;
if (!settingsForm.value.notification.smtp_enabled) return true;
return mailVerified.value;
});
watch(hasOpenModal, (isOpen) => {
if (isOpen) {
previousBodyOverflow = document.body.style.overflow;
@ -800,9 +827,72 @@ export function useDashboard() {
}
}
async function testMail() {
if (!canManageSystemSettings.value) {
mailTestError.value = "只有超级管理员可以进行邮件发信测试";
return;
}
const notif = settingsForm.value.notification;
mailTestError.value = "";
mailTestSuccess.value = "";
if (notif.smtp_enabled) {
if (!notif.smtp_host || !notif.smtp_host.trim()) {
mailTestError.value = "请填写 SMTP 主机";
return;
}
if (!notif.smtp_username || !notif.smtp_username.trim()) {
mailTestError.value = "请填写 SMTP 用户名";
return;
}
if (!notif.smtp_credential_configured && (!notif.smtp_password || !notif.smtp_password.trim())) {
mailTestError.value = "请填写 SMTP 密码";
return;
}
}
const recipients = (notif.recipients || []).filter(Boolean);
const targetRecipient = recipients[0] || (currentUser.value?.email || "").trim();
if (!targetRecipient) {
mailTestError.value = "请在收件人中至少填写一个有效的测试接收邮箱";
return;
}
mailTesting.value = true;
try {
const response = await fetchAdmin("/api/v1/notification/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
smtp_enabled: Boolean(notif.smtp_enabled),
smtp_host: (notif.smtp_host || "").trim(),
smtp_port: Number(notif.smtp_port) || 0,
smtp_username: (notif.smtp_username || "").trim(),
smtp_password: notif.smtp_password || "",
smtp_mode: notif.smtp_mode || "starttls",
smtp_allow_plaintext_auth: Boolean(notif.smtp_allow_plaintext_auth),
recipient: targetRecipient,
http_enabled: Boolean(notif.http_enabled),
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || "邮件发信测试失败");
}
mailVerified.value = true;
mailTestSuccess.value = result.message || "测试邮件已发送成功,请查收!";
} catch (err) {
mailVerified.value = false;
mailTestError.value = err.message || "邮件发信测试失败";
} finally {
mailTesting.value = false;
}
}
function openSettings() {
settingsError.value = "";
dnsTestError.value = "";
mailTestError.value = "";
mailTestSuccess.value = "";
mailVerified.value = false;
if (!canManageSystemSettings.value) {
settingsForm.value = normalizeSettings(null);
showSettings.value = false;
@ -832,6 +922,11 @@ export function useDashboard() {
settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试";
return;
}
// 邮件通知在启用时必须先测试通过
if ((!section || section === "notification") && !mailReadyToSave.value) {
settingsError.value = "邮件通知配置尚未通过在线测试,请先完成测试";
return;
}
settingsSaving.value = true;
try {
const payload = settingsPayload(settingsForm.value, section);
@ -1175,6 +1270,12 @@ export function useDashboard() {
dnsVerificationTimeoutSeconds,
dnsReadyToSave,
dnsVerificationRequired,
mailTesting,
mailTestError,
mailTestSuccess,
mailVerified,
mailReadyToSave,
testMail,
handleDNSProviderChange,
clearDNSCredentials,
testDNS,

View File

@ -1,4 +1,4 @@
.mail-smtp-fields {
.mail-smtp-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
@ -76,6 +76,48 @@
margin-bottom: 0;
}
.mail-test-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px dashed #dce6f1;
}
.mail-verification-status {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.mail-verification-status.pending {
background: #fff1f2;
color: #e11d48;
border: 1px solid #fecdd3;
}
.mail-verification-status.verified {
background: #f0fdf4;
color: #16a34a;
border: 1px solid #bbf7d0;
}
.mail-form-error {
margin-top: 8px;
color: #dc2626;
font-size: 13px;
}
.mail-form-success {
margin-top: 8px;
color: #16a34a;
font-size: 13px;
}
@media (max-width: 700px) {
.mail-smtp-fields {
grid-template-columns: minmax(0, 1fr);

View File

@ -1,7 +1,19 @@
<script setup>
<script setup>
import HelpTip from "../components/HelpTip/HelpTip.vue";
defineProps({ notification: { type: Object, required: true } });
const props = defineProps({
notification: { type: Object, required: true },
mailTesting: { type: Boolean, default: false },
mailTestError: { type: String, default: "" },
mailTestSuccess: { type: String, default: "" },
mailVerified: { type: Boolean, default: false },
});
const emit = defineEmits(["test-mail"]);
function handleTest() {
emit("test-mail");
}
</script>
<template>
@ -54,6 +66,29 @@ defineProps({ notification: { type: Object, required: true } });
placeholder="多个地址用逗号分隔"
/>
</label>
<div v-if="notification.smtp_enabled" class="mail-test-actions">
<button
class="secondary-button"
type="button"
:disabled="mailTesting"
@click="handleTest"
>
{{ mailTesting ? "正在测试发信……" : "测试邮件发送" }}
</button>
<span
class="mail-verification-status"
:class="mailVerified ? 'verified' : 'pending'"
>
{{ mailVerified ? "已通过测试" : "待在线测试" }}
</span>
</div>
<p v-if="mailTestError" class="form-error mail-form-error">{{ mailTestError }}</p>
<p v-if="mailTestSuccess" class="form-success mail-form-success">{{ mailTestSuccess }}</p>
<HelpTip v-if="notification.smtp_enabled && !mailVerified">
保存邮件通知设置前,必须先点击“测试邮件发送”并通过在线测试验证。
</HelpTip>
</fieldset>
</div>
</template>

View File

@ -1,4 +1,4 @@
import assert from "node:assert/strict";
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { parse, compileScript, compileTemplate } from "@vue/compiler-sfc";
@ -24,7 +24,7 @@ test("邮件通知组件只保留 SMTP 通道和收件人配置", () => {
}
assert.doesNotMatch(mailSource, /HTTP 接口|sender_email|notification\.http_enabled/);
assert.equal(mailSource.split(':value="notification.recipients.join').length - 1, 1);
assert.match(source, /<MailNotificationSettings :notification="settingsForm.notification"/);
assert.match(source, /<MailNotificationSettings[\s\S]*:notification="settingsForm.notification"/);
assert.doesNotMatch(mailSource, /role="tab"/);
});
@ -128,3 +128,4 @@ test("settings.keyboardNavigationWrapsAndFocusesTabs", async () => {
await state.navigateTabs({ ...event, key: "Home" });
assert.equal(state.activeSection.value, "service");
});

View File

@ -1,4 +1,4 @@
<script setup>
<script setup>
import HelpTip from "../components/HelpTip/HelpTip.vue";
import { useSettingsModal } from "./SettingsModal.js";
import MailNotificationSettings from "./MailNotificationSettings.vue";
@ -23,6 +23,12 @@ const {
closeSettings,
saveSettings,
dnsVerificationRequired,
mailTesting,
mailTestError,
mailTestSuccess,
mailVerified,
mailReadyToSave,
testMail,
} = props.state;
const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSettings);
</script>
@ -385,10 +391,17 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
class="settings-panel"
>
<h3>邮件通知</h3>
<MailNotificationSettings :notification="settingsForm.notification" />
<MailNotificationSettings
:notification="settingsForm.notification"
:mail-testing="mailTesting"
:mail-test-error="mailTestError"
:mail-test-success="mailTestSuccess"
:mail-verified="mailVerified"
@test-mail="testMail"
/>
<div class="modal-actions">
<button class="secondary-button" type="button" :disabled="settingsSaving" @click="closeSettings">取消</button>
<button class="primary-button" type="button" :disabled="settingsSaving" @click="submitSettings">{{ settingsSaving ? "保存中……" : "保存邮件通知" }}</button>
<button class="primary-button" type="button" :disabled="settingsSaving || !mailReadyToSave" @click="submitSettings">{{ settingsSaving ? "保存中……" : "保存邮件通知" }}</button>
</div>
</section>
<p v-if="settingsError" class="form-error">{{ settingsError }}</p>
@ -400,3 +413,5 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
<style scoped src="./Dashboard.css"></style>
<style scoped src="./SettingsModal.css"></style>