diff --git a/backend/internal/httpapi/notification_test_api.go b/backend/internal/httpapi/notification_test_api.go new file mode 100644 index 0000000..aa6c3ab --- /dev/null +++ b/backend/internal/httpapi/notification_test_api.go @@ -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, + }) +} diff --git a/backend/internal/httpapi/notification_test_api_test.go b/backend/internal/httpapi/notification_test_api_test.go new file mode 100644 index 0000000..d6d1724 --- /dev/null +++ b/backend/internal/httpapi/notification_test_api_test.go @@ -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) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index c8d66e0..29f2a86 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -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) } } + diff --git a/backend/internal/notification/test_mail.go b/backend/internal/notification/test_mail.go new file mode 100644 index 0000000..86a374f --- /dev/null +++ b/backend/internal/notification/test_mail.go @@ -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(`
测试通过
`) + htmlBuilder.WriteString(`

发信通道联通成功

`) + htmlBuilder.WriteString(`

您的 Scert 邮件通知配置已通过在线发信验证,可正常发送通知邮件。

`) + + htmlBuilder.WriteString(``) + 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(`
`) + + htmlBuilder.WriteString(`
系统已确认该发信通道有效,您可以放心保存当前系统邮件通知设置。
`) + 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("测试邮件发送失败,请检查通道设置及网络连通性") +} diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index b4c9759..bd89245 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -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 验证页面布局与功能。 + diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 6bfc112..97d5c7e 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -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, diff --git a/frontend/src/views/Dashboard/MailNotificationSettings.css b/frontend/src/views/Dashboard/MailNotificationSettings.css index 15482f9..7d0e8e7 100644 --- a/frontend/src/views/Dashboard/MailNotificationSettings.css +++ b/frontend/src/views/Dashboard/MailNotificationSettings.css @@ -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); diff --git a/frontend/src/views/Dashboard/MailNotificationSettings.vue b/frontend/src/views/Dashboard/MailNotificationSettings.vue index e4dce98..2a6e04b 100644 --- a/frontend/src/views/Dashboard/MailNotificationSettings.vue +++ b/frontend/src/views/Dashboard/MailNotificationSettings.vue @@ -1,7 +1,19 @@ - diff --git a/frontend/src/views/Dashboard/SettingsModal.test.js b/frontend/src/views/Dashboard/SettingsModal.test.js index faa4321..3bdda35 100644 --- a/frontend/src/views/Dashboard/SettingsModal.test.js +++ b/frontend/src/views/Dashboard/SettingsModal.test.js @@ -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, / { await state.navigateTabs({ ...event, key: "Home" }); assert.equal(state.activeSection.value, "service"); }); + diff --git a/frontend/src/views/Dashboard/SettingsModal.vue b/frontend/src/views/Dashboard/SettingsModal.vue index af7ab51..365efe3 100644 --- a/frontend/src/views/Dashboard/SettingsModal.vue +++ b/frontend/src/views/Dashboard/SettingsModal.vue @@ -1,4 +1,4 @@ - @@ -385,10 +391,17 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet class="settings-panel" >

邮件通知

- +

{{ settingsError }}

@@ -400,3 +413,5 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet + +