From 9be7a6f990a26401588621eae19ff0e9dcfdad1f Mon Sep 17 00:00:00 2001 From: smail Date: Fri, 25 Sep 2026 21:19:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9=EF=BC=9A=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E9=80=9A=E7=9F=A5=E6=8E=88=E6=9D=83=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E7=8A=B6=E6=80=81=EF=BC=8C=E5=BC=80=E5=A7=8B=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=88=86=E7=BB=84=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/system_notification.go | 196 ++++++++++++++++++ backend/db/migration/notification_grant.go | 24 +++ backend/db/migration/registry.go | 1 + backend/listen/http_server/api_v1_compat.go | 8 + backend/listen/http_server/operation_log.go | 2 + backend/listen/smtp_server/action.go | 13 ++ backend/listen/smtp_server/notification.go | 66 ++++++ backend/listen/smtp_server/read_content.go | 30 ++- backend/models/notification_grant.go | 19 ++ backend/services/access/authorize.go | 9 + backend/services/access/capability.go | 4 + backend/services/access/catalog.go | 2 + .../services/auth/protocol_notification.go | 67 ++++++ backend/services/auth/protocol_session.go | 8 +- backend/services/notification/service.go | 192 +++++++++++++++++ backend/utils/send/send.go | 4 + backend/utils/validation/notification.go | 26 +++ backend/utils/validation/sender.go | 3 + .../views/components/HomeHeader/HomeHeader.js | 2 + .../SystemNotification/SystemNotification.css | 61 ++++++ .../SystemNotification/SystemNotification.js | 171 +++++++++++++++ .../SystemNotification/SystemNotification.vue | 123 +++++++++++ 22 files changed, 1024 insertions(+), 7 deletions(-) create mode 100644 backend/controllers/system_notification.go create mode 100644 backend/db/migration/notification_grant.go create mode 100644 backend/listen/smtp_server/notification.go create mode 100644 backend/models/notification_grant.go create mode 100644 backend/services/auth/protocol_notification.go create mode 100644 backend/services/notification/service.go create mode 100644 backend/utils/validation/notification.go create mode 100644 frontend/src/views/components/SystemNotification/SystemNotification.css create mode 100644 frontend/src/views/components/SystemNotification/SystemNotification.js create mode 100644 frontend/src/views/components/SystemNotification/SystemNotification.vue diff --git a/backend/controllers/system_notification.go b/backend/controllers/system_notification.go new file mode 100644 index 0000000..01bd347 --- /dev/null +++ b/backend/controllers/system_notification.go @@ -0,0 +1,196 @@ +package controllers + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/logstore" + "git.pcux.cn/xuans/Smail/services/notification" + "git.pcux.cn/xuans/Smail/utils/context" + "git.pcux.cn/xuans/Smail/utils/validation" +) + +type notificationRequest struct { + UserID int `json:"user_id"` + Version int `json:"version"` + Enabled *bool `json:"enabled"` + MinuteLimit int `json:"minute_limit"` + DayLimit int `json:"day_limit"` +} + +func loadNotificationUser(ctx *context.Context, w http.ResponseWriter, userID int, write bool) (*models.User, bool) { + if ctx == nil || ctx.UserID <= 0 { + writeSecurityError(w, response.NeedLogin, "请先登录") + return nil, false + } + if db.Instance == nil || userID <= 0 { + writeSecurityError(w, response.ParamsError, "目标账号无效") + return nil, false + } + var target models.User + found, err := db.Instance.ID(userID).Get(&target) + if err != nil || !found { + writeSecurityError(w, response.ParamsError, "无法读取目标账号") + return nil, false + } + own := ctx.UserID == target.ID + if write { + if err := authorizeCapability(ctx, access.CapabilityNotificationManage, target.EffectiveRole(), own); err != nil { + if access.IsDenied(err, access.CodeRecentAuthRequired) { + writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先确认当前管理员密码", string(access.CodeRecentAuthRequired)) + } else if access.IsDenied(err, access.CodeRecoveryCodeRequired) { + writeSecurityError(w, response.NoAccessPrivileges, "请先为当前管理员配置安全码") + } else { + writeSecurityError(w, response.NoAccessPrivileges, "无权修改该账号的系统通知授权") + } + return nil, false + } + } else if !own && !access.CanManageNotification(ctx.EffectiveRole(), target.EffectiveRole(), false) { + writeSecurityError(w, response.NoAccessPrivileges, "只能查看本人或管理范围内的账号状态") + return nil, false + } + return &target, true +} + +// GetNotificationStatus 不返回授权码或摘要,普通用户只能查询自己的状态。 +func GetNotificationStatus(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + if ctx == nil { + writeSecurityError(w, response.NeedLogin, "请先登录") + return + } + userID := ctx.UserID + if value := req.URL.Query().Get("user_id"); value != "" { + var err error + userID, err = strconv.Atoi(value) + if err != nil || userID <= 0 { + writeSecurityError(w, response.ParamsError, "目标账号无效") + return + } + } + user, ok := loadNotificationUser(ctx, w, userID, false) + if !ok { return } + item, err := notification.Load(db.Instance, user.ID) + if err != nil { + writeSecurityError(w, response.ServerError, "读取系统通知授权失败") + return + } + minuteUsed, dayUsed := 0, 0 + now := time.Now().UTC() + if item.MinuteKey == now.Format("2006-01-02T15:04") { minuteUsed = item.MinuteCount } + if item.DayKey == now.Format("2006-01-02") { dayUsed = item.DayCount } + domain := config.CurrentMailDomains().Primary + ports := config.CurrentMailPorts() + response.NewSuccessResponse(map[string]any{ + "user_id": user.ID, "account": user.Account, "enabled": item.Enabled == 1, + "configured": notification.Configured(item, *user), "version": item.CodeVersion, + "account_available": user.Disabled == 0 && user.MustChangePassword == 0, + "can_manage": access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID), + "minute_limit": item.MinuteLimit, "day_limit": item.DayLimit, + "minute_used": minuteUsed, "day_used": dayUsed, + "host": "smtp." + domain, "tls_port": ports.SMTPTLS, "starttls_port": ports.SMTPSTARTTLS, + "username": notification.UsernamePrefix + user.Account + "@" + domain, + "sender": validation.NotificationAddress(domain), "sender_name": "系统通知", + "max_recipients": notification.MaxRecipients, "max_message_bytes": notification.MaxMessageBytes, + }).FPrint(w) +} + +// ListNotificationAccounts 仅列出当前管理员可以管理的账号,分页且不暴露安全字段。 +func ListNotificationAccounts(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + if ctx == nil || ctx.UserID <= 0 || (ctx.EffectiveRole() != access.RoleAdmin && ctx.EffectiveRole() != access.RoleSuperAdmin) { + writeSecurityError(w, response.NoAccessPrivileges, "无权查看通知授权账号列表") + return + } + if db.Instance == nil { + writeSecurityError(w, response.ServerError, "数据库不可用") + return + } + page, _ := strconv.Atoi(req.URL.Query().Get("page")) + if page < 1 || page > 100000 { page = 1 } + query := db.Instance.Table(&models.User{}) + defer query.Close() + if ctx.EffectiveRole() == access.RoleAdmin { + query.Where("(" + models.OrdinaryUserPredicate + ") OR id = ?", ctx.UserID) + } + var users []models.User + total, err := query.Asc("id").Limit(20, (page - 1) * 20).FindAndCount(&users) + if err != nil { + writeSecurityError(w, response.ServerError, "读取账号列表失败") + return + } + items := make([]map[string]any, 0, len(users)) + userIDs := make([]int, 0, len(users)) + for _, user := range users { userIDs = append(userIDs, user.ID) } + grants := make(map[int]models.NotificationGrant) + if len(userIDs) > 0 { + var rows []models.NotificationGrant + if err := db.Instance.In("user_id", userIDs).Find(&rows); err != nil { + writeSecurityError(w, response.ServerError, "读取账号通知状态失败") + return + } + for _, item := range rows { grants[item.UserID] = item } + } + for _, user := range users { + item := grants[user.ID] + items = append(items, map[string]any{"user_id": user.ID, "account": user.Account, "name": user.Name, "disabled": user.Disabled != 0, + "enabled": item.Enabled == 1, "configured": notification.Configured(item, user)}) + } + response.NewSuccessResponse(map[string]any{"list": items, "total": total, "page": page}).FPrint(w) +} + +func SetNotificationAuthorization(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + var input notificationRequest + if decodeJSONRequest(w, req, &input) != nil || input.Enabled == nil || input.Version < 0 { + writeSecurityError(w, response.ParamsError, "通知授权参数无效") + return + } + user, ok := loadNotificationUser(ctx, w, input.UserID, true) + if !ok { return } + err := notification.SetAuthorization(db.Instance, *user, input.Version, *input.Enabled, input.MinuteLimit, input.DayLimit) + recordNotificationManagement(ctx, user.ID, "设置系统通知授权", err, map[string]any{"enabled": *input.Enabled, "minute_limit": input.MinuteLimit, "day_limit": input.DayLimit}) + if err != nil { writeNotificationError(w, err); return } + response.NewSuccessResponse(map[string]any{"user_id": user.ID, "enabled": *input.Enabled}).FPrint(w) +} + +func RotateNotificationCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + var input notificationRequest + if decodeJSONRequest(w, req, &input) != nil || input.Version < 1 { + writeSecurityError(w, response.ParamsError, "通知凭据参数无效") + return + } + user, ok := loadNotificationUser(ctx, w, input.UserID, true) + if !ok { return } + code, err := notification.Rotate(db.Instance, *user, input.Version) + recordNotificationManagement(ctx, user.ID, "重置系统通知凭据", err, nil) + if err != nil { writeNotificationError(w, err); return } + w.Header().Set("Cache-Control", "no-store") + response.NewSuccessResponse(map[string]any{"code": code, "user_id": user.ID}).FPrint(w) +} + +func recordNotificationManagement(ctx *context.Context, userID int, action string, err error, changes map[string]any) { + result := "success" + if err != nil { result = "failure" } + payload, _ := json.Marshal(map[string]any{"target_user_id": userID, "changes": changes}) + logstore.Record(ctx, logstore.Entry{Kind: "operation", ActorID: strconv.Itoa(ctx.UserID), ActorRole: string(ctx.EffectiveRole()), Action: action, + Section: "系统通知发信", Result: result, CreatedAt: time.Now(), Payload: payload}) +} + +func writeNotificationError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, notification.ErrConflict): + writeSecurityError(w, response.ParamsError, "授权状态已变化,请刷新后重试") + case errors.Is(err, notification.ErrDenied): + writeSecurityError(w, response.NoAccessPrivileges, "账号不可用或尚未开启系统通知发信") + case errors.Is(err, notification.ErrLimits): + writeSecurityError(w, response.ParamsError, "每分钟限额为 1–100,每日限额不能小于分钟限额且不能超过 10000") + default: + writeSecurityError(w, response.ServerError, "保存系统通知授权失败,请刷新确认状态") + } +} diff --git a/backend/db/migration/notification_grant.go b/backend/db/migration/notification_grant.go new file mode 100644 index 0000000..0e7f701 --- /dev/null +++ b/backend/db/migration/notification_grant.go @@ -0,0 +1,24 @@ +package migration + +func notificationGrantStatements() map[Dialect][]string { + // 不依赖自增、布尔或日期方言;计数窗口统一使用 UTC 字符串。 + statement := `CREATE TABLE IF NOT EXISTS notification_grant ( + user_id BIGINT NOT NULL PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + code_version INTEGER NOT NULL DEFAULT 1, + code_hash VARCHAR(64) NOT NULL DEFAULT '', + session_version INTEGER NOT NULL DEFAULT 0, + security_version INTEGER NOT NULL DEFAULT 0, + minute_limit INTEGER NOT NULL DEFAULT 10, + day_limit INTEGER NOT NULL DEFAULT 100, + minute_key VARCHAR(16) NOT NULL DEFAULT '', + minute_count INTEGER NOT NULL DEFAULT 0, + day_key VARCHAR(10) NOT NULL DEFAULT '', + day_count INTEGER NOT NULL DEFAULT 0 + )` + return map[Dialect][]string{ + DialectSQLite: {statement}, + DialectMySQL: {statement + " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"}, + DialectPostgres: {statement}, + } +} diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index 94d28a2..067554b 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -39,6 +39,7 @@ func DefaultMigrations() []Migration { DialectMySQL: {configstate.Schema("mysql")}, DialectPostgres: {configstate.Schema("postgres")}, }), + DefineForDialects(25, "create_notification_grants", notificationGrantStatements()), } } diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index ea26eea..a1aca81 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -11,6 +11,7 @@ import ( // registerV1Routes 注册业务 API 的稳定版本入口。旧入口仍然保留,便于已有客户端平滑迁移。 func registerV1Routes(mux *http.ServeMux) { + registerNotificationRoutes(mux) registerV1MethodRoute(mux, "/api/v1/security/setup/acknowledge", "/api/security/setup/acknowledge", http.MethodPost, 4<<10, controllers.AcknowledgeSecuritySetup) registerV1MethodRoute(mux, "/api/v1/login", "/api/login", http.MethodPost, 64<<10, controllers.Login) registerV1MethodRoute(mux, "/api/v1/security/reauth", "/api/security/reauth", http.MethodPost, 4<<10, controllers.ReauthenticateCurrentUser) @@ -101,6 +102,13 @@ func registerV1MethodRoute(mux *http.ServeMux, path string, legacyPath string, m mux.HandleFunc(path, onlyMethod(method, wrapped)) } +func registerNotificationRoutes(mux *http.ServeMux) { + registerV1MethodRoute(mux, "/api/v1/system/notification/status", "/api/system/notification/status", http.MethodGet, 0, controllers.GetNotificationStatus) + registerV1MethodRoute(mux, "/api/v1/system/notification/accounts", "/api/system/notification/accounts", http.MethodGet, 0, controllers.ListNotificationAccounts) + registerV1MethodRoute(mux, "/api/v1/system/notification/authorization", "/api/system/notification/authorization", http.MethodPost, 4<<10, controllers.SetNotificationAuthorization) + registerV1MethodRoute(mux, "/api/v1/system/notification/credential", "/api/system/notification/credential", http.MethodPost, 4<<10, controllers.RotateNotificationCredential) +} + func registerV1SenderFiltersRoute(mux *http.ServeMux) { get := contextIterceptor(rewriteV1Path("/api/email/sender-filters", email.SenderFiltersList)) post := limitBody(16<<10, contextIterceptor(rewriteV1Path("/api/email/sender-filters", email.AddSenderFilter))) diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index d40aeae..d28d4f5 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -15,6 +15,8 @@ import ( // loggedOperations 注册显式的静态状态变更路由,仅记录状态变更操作,不将列表查询误认为操作,也不复制请求或响应载荷。 var loggedOperations = map[string]string{ + "/api/system/notification/authorization": "修改系统通知发信权限", + "/api/system/notification/credential": "生成系统通知发信凭据", "/api/email/draft/save": "保存邮件草稿", "/api/login": "处理账号登录", "/api/logout": "退出登录", diff --git a/backend/listen/smtp_server/action.go b/backend/listen/smtp_server/action.go index d5b350a..d0bf53c 100644 --- a/backend/listen/smtp_server/action.go +++ b/backend/listen/smtp_server/action.go @@ -2,11 +2,15 @@ package smtp_server import ( "errors" + "strings" + "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/services/notification" "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/id" "git.pcux.cn/xuans/Smail/utils/password" + "git.pcux.cn/xuans/Smail/utils/validation" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" log "github.com/sirupsen/logrus" @@ -91,6 +95,9 @@ func (s *Session) Mail(from string, opts *smtp.MailOptions) error { if err := s.checkAuthorization(); err != nil { return err } + if s.isNotification() && !strings.EqualFold(from, validation.NotificationAddress(config.CurrentMailDomains().Primary)) { + return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 7, 1}, Message: "Notification sender is not authorized"} + } log.WithContext(s.Ctx).Debug("SMTP MAIL accepted") s.From = from return nil @@ -100,6 +107,12 @@ func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { if err := s.checkAuthorization(); err != nil { return err } + if validation.IsNotificationAddress(to, config.CurrentMailDomains().Accepted) { + return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 1, 1}, Message: "Notification address does not accept mail"} + } + if s.isNotification() && len(s.To) >= notification.MaxRecipients { + return &smtp.SMTPError{Code: 452, EnhancedCode: smtp.EnhancedCode{4, 5, 3}, Message: "Too many notification recipients"} + } log.WithContext(s.Ctx).Debug("SMTP RCPT accepted") s.To = append(s.To, to) diff --git a/backend/listen/smtp_server/notification.go b/backend/listen/smtp_server/notification.go new file mode 100644 index 0000000..d48cae5 --- /dev/null +++ b/backend/listen/smtp_server/notification.go @@ -0,0 +1,66 @@ +package smtp_server + +import ( + "errors" + "strings" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/dto/parsemail" + "git.pcux.cn/xuans/Smail/services/notification" + "git.pcux.cn/xuans/Smail/utils/validation" + "github.com/emersion/go-smtp" +) + +func (s *Session) isNotification() bool { + return s.authorization != nil && s.authorization.IsNotification() +} + +func (s *Session) authorizedSubmission(email *parsemail.Email) bool { + if !s.isNotification() { + return authorizedSubmissionEmail(email, s.From, s.Ctx) + } + sender := validation.NotificationAddress(config.CurrentMailDomains().Primary) + return sender != "" && email != nil && email.From != nil && email.Sender != nil && + strings.EqualFold(s.From, sender) && strings.EqualFold(email.From.EmailAddress, sender) && + strings.EqualFold(email.Sender.EmailAddress, sender) && strings.EqualFold(email.EnvelopeFrom, sender) +} + +// 插件处理后再校验最终内容,不能通过邮件头额外添加信封中没有的收件人。 +func (s *Session) prepareNotification(email *parsemail.Email, rawBytes int) error { + if err := s.checkAuthorization(); err != nil { + return err + } + if rawBytes > notification.MaxMessageBytes { + return &smtp.SMTPError{Code: 552, EnhancedCode: smtp.EnhancedCode{5, 3, 4}, Message: "Notification message exceeds size limit"} + } + if err := validation.ValidateEmail(email, validation.EmailLimits{ + MaxRecipients: notification.MaxRecipients, MaxAttachments: 10, + MaxAttachmentBytes: notification.MaxMessageBytes, MaxTotalAttachmentBytes: notification.MaxMessageBytes, + MaxBodyBytes: notification.MaxMessageBytes, MaxEstimatedBytes: notification.MaxMessageBytes, + }); err != nil { + return &smtp.SMTPError{Code: 552, EnhancedCode: smtp.EnhancedCode{5, 3, 4}, Message: "Notification content exceeds safety limits"} + } + allowed := make(map[string]bool, len(s.To)) + for _, address := range s.To { + allowed[strings.ToLower(address)] = true + } + recipients := append(append(append([]*parsemail.User{}, email.To...), email.Cc...), email.Bcc...) + for _, recipient := range recipients { + if recipient == nil || !allowed[strings.ToLower(recipient.EmailAddress)] { + return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 7, 1}, Message: "Notification recipient is not authorized by envelope"} + } + } + if err := s.authorization.ReserveNotification(len(recipients)); err != nil { + if errors.Is(err, notification.ErrQuota) { + return &smtp.SMTPError{Code: 452, EnhancedCode: smtp.EnhancedCode{4, 7, 0}, Message: "Notification sending quota exceeded"} + } + if errors.Is(err, notification.ErrDenied) { + return &smtp.SMTPError{Code: 535, EnhancedCode: smtp.EnhancedCode{5, 7, 8}, Message: "Notification authorization revoked"} + } + return localPersistenceSMTPError() + } + // 系统通知不能冒充账号恢复邮件,也不能请求向任意地址回执。 + email.From.Name, email.Sender.Name = "系统通知", "系统通知" + email.ReplyTo, email.ReadReceipt = nil, nil + return nil +} diff --git a/backend/listen/smtp_server/read_content.go b/backend/listen/smtp_server/read_content.go index 504e3dd..55226cc 100644 --- a/backend/listen/smtp_server/read_content.go +++ b/backend/listen/smtp_server/read_content.go @@ -22,6 +22,7 @@ import ( "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/logstore" + "git.pcux.cn/xuans/Smail/services/notification" "git.pcux.cn/xuans/Smail/services/rule" "git.pcux.cn/xuans/Smail/utils/array" "git.pcux.cn/xuans/Smail/utils/async" @@ -58,6 +59,9 @@ func (s *Session) Data(r io.Reader) (resultErr error) { if s.Ctx.UserID > 0 { direction = "submission" details.Trigger = "SMTP 客户端提交" + if s.isNotification() { + details.Trigger = "系统通知 SMTP 提交" + } } if result == "failure" { details.Outcome = details.Stage + "失败" @@ -84,13 +88,17 @@ func (s *Session) Data(r io.Reader) (resultErr error) { log.WithContext(ctx).Debugf("收到邮件") - emailData, err := io.ReadAll(io.LimitReader(r, maxSMTPMessageBytes+1)) + messageLimit := int64(maxSMTPMessageBytes) + if s.isNotification() { + messageLimit = notification.MaxMessageBytes + } + emailData, err := io.ReadAll(io.LimitReader(r, messageLimit+1)) messageBytes = len(emailData) if err != nil { log.WithContext(ctx).Error("邮件内容无法读取", err) return err } - if len(emailData) > maxSMTPMessageBytes { + if int64(len(emailData)) > messageLimit { return &smtp.SMTPError{Code: 552, EnhancedCode: smtp.EnhancedCode{5, 3, 4}, Message: "Message size exceeds fixed maximum message size"} } // DATA 上传期间也可能撤销授权,读取完成后必须再次复核。 @@ -110,7 +118,7 @@ func (s *Session) Data(r io.Reader) (resultErr error) { } log.WithContext(ctx).Debugf("开始执行插件ReceiveParseBefore End!") - if len(emailData) > maxSMTPMessageBytes { + if int64(len(emailData)) > messageLimit { return &smtp.SMTPError{Code: 552, EnhancedCode: smtp.EnhancedCode{5, 3, 4}, Message: "Message size exceeds fixed maximum message size"} } details.Stage = "解析与校验邮件" @@ -140,7 +148,7 @@ func (s *Session) Data(r io.Reader) (resultErr error) { // 判断是收信还是转发,只要是登陆了,都当成转发处理 if s.Ctx.UserID > 0 { details.Stage = "校验发件权限" - if !authorizedSubmissionEmail(email, s.From, ctx) { + if !s.authorizedSubmission(email) { return oerrors.New("No Auth") } @@ -157,12 +165,22 @@ func (s *Session) Data(r io.Reader) (resultErr error) { if email == nil { return nil } - if !authorizedSubmissionEmail(email, s.From, ctx) { + if !s.authorizedSubmission(email) { return oerrors.New("No Auth") } details.Stage = "保存并投递客户端邮件" - resultErr = s.deliverOutgoingEmail(email, send.Send) + sender := outgoingSender(send.Send) + if s.isNotification() { + details.Stage = "校验系统通知配额并投递" + if err := s.prepareNotification(email, len(emailData)); err != nil { + return err + } + sender = func(ctx *context.Context, email *parsemail.Email) (error, map[string]error) { + return send.SendTriggered(ctx, email, "系统通知 SMTP 发信") + } + } + resultErr = s.deliverOutgoingEmail(email, sender) if resultErr == nil { result = "accepted" } diff --git a/backend/models/notification_grant.go b/backend/models/notification_grant.go new file mode 100644 index 0000000..768de44 --- /dev/null +++ b/backend/models/notification_grant.go @@ -0,0 +1,19 @@ +package models + +// NotificationGrant 与业务数据共用数据库,只保存通知发信授权及不可逆凭据摘要。 +type NotificationGrant struct { + UserID int `xorm:"user_id pk" json:"user_id"` + Enabled int `xorm:"enabled" json:"-"` + CodeVersion int `xorm:"code_version" json:"version"` + CodeHash string `xorm:"code_hash" json:"-"` + SessionVersion int `xorm:"session_version" json:"-"` + SecurityVersion int `xorm:"security_version" json:"-"` + MinuteLimit int `xorm:"minute_limit" json:"minute_limit"` + DayLimit int `xorm:"day_limit" json:"day_limit"` + MinuteKey string `xorm:"minute_key" json:"-"` + MinuteCount int `xorm:"minute_count" json:"-"` + DayKey string `xorm:"day_key" json:"-"` + DayCount int `xorm:"day_count" json:"-"` +} + +func (NotificationGrant) TableName() string { return "notification_grant" } diff --git a/backend/services/access/authorize.go b/backend/services/access/authorize.go index e4e846a..51fb935 100644 --- a/backend/services/access/authorize.go +++ b/backend/services/access/authorize.go @@ -63,6 +63,10 @@ func Authorize(request Request) error { func authorizeResource(request Request) error { switch request.Capability { + case CapabilityNotificationManage: + if !CanManageNotification(request.ActorRole, request.TargetRole, request.OwnResource) { + return deny(CodeTargetRoleDenied) + } case CapabilityPasswordChangeOwn, CapabilityPluginWriteOwn, CapabilityProfileWriteOwn: if !request.OwnResource { return deny(CodeAccessDenied) @@ -112,6 +116,11 @@ func canManageAccountTarget(actor Role, target Role) bool { } } +// 通知授权不改变账号角色;管理员只管理本人和普通用户,不能操作其他管理员。 +func CanManageNotification(actor, target Role, own bool) bool { + return target.Valid() && (actor == RoleSuperAdmin || actor == RoleAdmin && (target == RoleUser || own && target == RoleAdmin)) +} + func canManagePluginTarget(actor Role, target Role) bool { if actor == RoleSuperAdmin { return target.Valid() diff --git a/backend/services/access/capability.go b/backend/services/access/capability.go index 8631c45..3672ffa 100644 --- a/backend/services/access/capability.go +++ b/backend/services/access/capability.go @@ -25,6 +25,7 @@ const ( CapabilityPasswordReset Capability = "password.reset" CapabilityAuditRead Capability = "audit.read" CapabilityAuditExport Capability = "audit.export" + CapabilityNotificationManage Capability = "notification.manage" ) type Policy struct { @@ -36,6 +37,7 @@ type Policy struct { } var policies = map[Capability]Policy{ + CapabilityNotificationManage: {Capability: CapabilityNotificationManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true}, CapabilityConfigRead: {Capability: CapabilityConfigRead}, CapabilityConfigWriteGlobal: {Capability: CapabilityConfigWriteGlobal, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, CapabilityConfigRollback: {Capability: CapabilityConfigRollback, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, @@ -62,6 +64,7 @@ var policies = map[Capability]Policy{ var grants = map[Role]map[Capability]struct{}{ RoleSuperAdmin: grant( + CapabilityNotificationManage, CapabilityPluginReadGlobal, CapabilityUserCreate, CapabilityProfileWriteOwn, CapabilityConfigRead, CapabilityConfigWriteGlobal, CapabilityConfigRollback, CapabilityDatabaseMigrate, CapabilityPluginRead, CapabilityPluginControl, @@ -71,6 +74,7 @@ var grants = map[Role]map[Capability]struct{}{ CapabilityAuditRead, CapabilityAuditExport, ), RoleAdmin: grant( + CapabilityNotificationManage, CapabilityPluginReadGlobal, CapabilityPluginRead, CapabilityPluginWriteGlobal, CapabilityProfileWriteOwn, CapabilityPluginWriteAnyUser, CapabilityPluginWriteOwn, CapabilityUserRead, CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordChangeOwn, CapabilityPasswordReset, diff --git a/backend/services/access/catalog.go b/backend/services/access/catalog.go index 07f0cfb..20aa539 100644 --- a/backend/services/access/catalog.go +++ b/backend/services/access/catalog.go @@ -9,6 +9,8 @@ type Endpoint struct { } var managementEndpoints = []Endpoint{ + {Method: "POST", Path: "/api/v1/system/notification/authorization", Capability: CapabilityNotificationManage}, + {Method: "POST", Path: "/api/v1/system/notification/credential", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/settings/modify_password", Capability: CapabilityPasswordChangeOwn}, {Method: "POST", Path: "/api/user/create", Capability: CapabilityUserCreate}, {Method: "POST", Path: "/api/user/profile", Capability: CapabilityProfileWriteOwn}, diff --git a/backend/services/auth/protocol_notification.go b/backend/services/auth/protocol_notification.go new file mode 100644 index 0000000..59e82b3 --- /dev/null +++ b/backend/services/auth/protocol_notification.go @@ -0,0 +1,67 @@ +package auth + +import ( + "strings" + "time" + + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/notification" +) + +// 通知身份拥有独立用户名空间,不会回退到普通协议或网页认证。 +func authenticateNotification(protocol, remoteAddress, account, plain string) (*models.User, *ProtocolSession, error) { + account = strings.ToLower(strings.TrimSpace(account)) + identifier := strings.TrimPrefix(account, notification.UsernamePrefix) + remote := protocolRemoteHost(remoteAddress) + pairKey := hashedProtocolLoginKey("pair", "notification", remote, identifier) + accountKey := hashedProtocolLoginKey("account", identifier) + remoteKey := hashedProtocolLoginKey("remote", remote) + fail := func() (*models.User, *ProtocolSession, error) { + protocolPairAttempts.recordFailure(pairKey) + protocolAccountAttempts.recordFailure(accountKey) + protocolRemoteAttempts.recordFailure(remoteKey) + return nil, nil, notification.ErrDenied + } + if protocolPairAttempts.blocked(pairKey) || protocolAccountAttempts.blocked(accountKey) || protocolRemoteAttempts.blocked(remoteKey) { + return nil, nil, notification.ErrDenied + } + if protocol != "smtp" || db.Instance == nil { + return fail() + } + identifier, err := NormalizeProtocolIdentifier(identifier) + if err != nil { + return fail() + } + identity, err := ResolveIdentifier(identifier) + if err != nil || identity.UserID <= 0 { + return fail() + } + var user models.User + found, err := db.Instance.ID(identity.UserID).Get(&user) + if err != nil || !found { + return fail() + } + grant, err := notification.Authenticate(db.Instance, user, plain) + if err != nil { + return fail() + } + session := &ProtocolSession{user: user, notification: grant} + if err := session.Check(); err != nil { + return fail() + } + protocolPairAttempts.clear(pairKey) + protocolAccountAttempts.clear(accountKey) + return &user, session, nil +} + +func (s *ProtocolSession) IsNotification() bool { + return s != nil && s.notification != nil +} + +func (s *ProtocolSession) ReserveNotification(recipients int) error { + if !s.IsNotification() || s.Check() != nil { + return notification.ErrDenied + } + return s.notification.Reserve(db.Instance, recipients, time.Now()) +} diff --git a/backend/services/auth/protocol_session.go b/backend/services/auth/protocol_session.go index 4c93a8c..ba76302 100644 --- a/backend/services/auth/protocol_session.go +++ b/backend/services/auth/protocol_session.go @@ -7,16 +7,21 @@ import ( "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/services/notification" ) // ProtocolSession 保存不可变身份与凭据快照,拒绝后的连接不能通过重新启用恢复权限。 type ProtocolSession struct { user models.User grant *credentials.ProtocolGrant + notification *notification.Grant invalid atomic.Bool } func AuthenticateProtocolSession(protocol, remoteAddress, account, plain string) (*models.User, *ProtocolSession, error) { + if notification.IsUsername(account) { + return authenticateNotification(protocol, remoteAddress, account, plain) + } var grant *credentials.ProtocolGrant user, err := authenticateProtocol(protocol, remoteAddress, account, plain, &grant) if err != nil { @@ -36,7 +41,8 @@ func (s *ProtocolSession) Check() error { return denied } engine := db.Instance - if engine != nil && s.grant.Valid(engine) { + validGrant := engine != nil && (s.grant.Valid(engine) || s.notification.Valid(engine)) + if validGrant { var user models.User has, err := engine.ID(s.user.ID).Where("disabled = 0").Get(&user) if err == nil && has && user.Account == s.user.Account && user.Password == s.user.Password && diff --git a/backend/services/notification/service.go b/backend/services/notification/service.go new file mode 100644 index 0000000..bac129f --- /dev/null +++ b/backend/services/notification/service.go @@ -0,0 +1,192 @@ +package notification + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "time" + + "git.pcux.cn/xuans/Smail/models" + "xorm.io/xorm" +) + +const ( + UsernamePrefix = "notify:" + DefaultMinuteLimit = 10 + DefaultDayLimit = 100 + MaxRecipients = 10 + MaxMessageBytes = 1 << 20 +) + +var ( + ErrDenied = errors.New("notification authorization denied") + ErrConflict = errors.New("notification authorization changed") + ErrQuota = errors.New("notification quota exceeded") + ErrUnavailable = errors.New("notification authorization unavailable") + ErrLimits = errors.New("invalid notification limits") +) + +func IsUsername(username string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(username)), UsernamePrefix) +} + +func Load(engine *xorm.Engine, userID int) (models.NotificationGrant, error) { + var item models.NotificationGrant + if engine == nil || userID <= 0 { + return item, ErrUnavailable + } + found, err := engine.ID(userID).Get(&item) + if err == nil && !found { + item = models.NotificationGrant{UserID: userID, MinuteLimit: DefaultMinuteLimit, DayLimit: DefaultDayLimit} + } + return item, err +} + +func Configured(item models.NotificationGrant, user models.User) bool { + return item.Enabled == 1 && item.CodeHash != "" && user.Disabled == 0 && user.MustChangePassword == 0 && + item.SessionVersion == user.SessionVersion && item.SecurityVersion == user.SecurityVersion +} + +// SetAuthorization 使用版本条件避免覆盖其他管理员的修改;关闭时永久清除旧凭据。 +func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabled bool, minuteLimit, dayLimit int) error { + if minuteLimit < 1 || minuteLimit > 100 || dayLimit < minuteLimit || dayLimit > 10000 { + return ErrLimits + } + if enabled && (user.Disabled != 0 || user.MustChangePassword != 0) { + return ErrDenied + } + item, err := Load(engine, user.ID) + if err != nil { + return err + } + if item.CodeVersion != expected { + return ErrConflict + } + item.Enabled = 0 + if enabled { + item.Enabled = 1 + } else { + item.CodeHash = "" + } + item.MinuteLimit, item.DayLimit = minuteLimit, dayLimit + item.CodeVersion++ + if expected == 0 { + _, err = engine.Insert(&item) + return err + } + affected, err := engine.ID(user.ID).Where("code_version = ?", expected). + Cols("enabled", "code_hash", "code_version", "minute_limit", "day_limit").Update(&item) + if err == nil && affected != 1 { + return ErrConflict + } + return err +} + +// Rotate 仅返回一次明文。随机凭据具有 256 位熵,数据库只保存 SHA-256 摘要。 +func Rotate(engine *xorm.Engine, user models.User, expected int) (string, error) { + item, err := Load(engine, user.ID) + if err != nil { + return "", err + } + if item.Enabled != 1 || user.Disabled != 0 || user.MustChangePassword != 0 { + return "", ErrDenied + } + if item.CodeVersion != expected { + return "", ErrConflict + } + var entropy [32]byte + if _, err := rand.Read(entropy[:]); err != nil { + return "", err + } + code := "sn_" + base64.RawURLEncoding.EncodeToString(entropy[:]) + item.CodeHash = digest(code) + item.CodeVersion++ + item.SessionVersion, item.SecurityVersion = user.SessionVersion, user.SecurityVersion + affected, err := engine.ID(user.ID).Where("code_version = ? AND enabled = 1", expected). + Cols("code_hash", "code_version", "session_version", "security_version").Update(&item) + if err != nil { + return "", err + } + if affected != 1 { + return "", ErrConflict + } + return code, nil +} + +type Grant struct { + engine *xorm.Engine + userID int + version int + hash string + sessionVersion int + securityVersion int +} + +func Authenticate(engine *xorm.Engine, user models.User, plain string) (*Grant, error) { + if len(plain) != 46 || !strings.HasPrefix(plain, "sn_") { + return nil, ErrDenied + } + item, err := Load(engine, user.ID) + if err != nil || !Configured(item, user) { + return nil, ErrDenied + } + if subtle.ConstantTimeCompare([]byte(item.CodeHash), []byte(digest(plain))) != 1 { + return nil, ErrDenied + } + return &Grant{engine, user.ID, item.CodeVersion, item.CodeHash, item.SessionVersion, item.SecurityVersion}, nil +} + +func (g *Grant) Valid(engine *xorm.Engine) bool { + if g == nil || engine == nil || engine != g.engine { + return false + } + valid, err := engine.ID(g.userID).Where("enabled = 1 AND code_version = ? AND code_hash = ?", g.version, g.hash). + Exist(&models.NotificationGrant{}) + if err != nil || !valid { + return false + } + valid, err = engine.ID(g.userID).Where("disabled = 0 AND must_change_password = 0 AND session_version = ? AND security_version = ?", g.sessionVersion, g.securityVersion). + Exist(&models.User{}) + return err == nil && valid +} + +// Reserve 将收件人数计入配额,重试和失败也计数;并发连接和服务重启不能绕过限制。 +func (g *Grant) Reserve(engine *xorm.Engine, recipients int, now time.Time) error { + if !g.Valid(engine) { + return ErrDenied + } + if recipients < 1 || recipients > MaxRecipients { + return ErrLimits + } + minute, day := now.UTC().Format("2006-01-02T15:04"), now.UTC().Format("2006-01-02") + result, err := engine.Exec(`UPDATE notification_grant SET + minute_count = CASE WHEN minute_key = ? THEN minute_count + ? ELSE ? END, + day_count = CASE WHEN day_key = ? THEN day_count + ? ELSE ? END, + minute_key = ?, day_key = ? + WHERE user_id = ? AND enabled = 1 AND code_version = ? AND code_hash = ? + AND ? <= minute_limit AND ? <= day_limit + AND (minute_key <> ? OR minute_count + ? <= minute_limit) + AND (day_key <> ? OR day_count + ? <= day_limit)`, + minute, recipients, recipients, day, recipients, recipients, minute, day, + g.userID, g.version, g.hash, recipients, recipients, minute, recipients, day, recipients) + if err != nil { + return ErrUnavailable + } + affected, err := result.RowsAffected() + if err != nil { + return ErrUnavailable + } + if affected != 1 { + return ErrQuota + } + return nil +} + +func digest(plain string) string { + hash := sha256.Sum256([]byte(plain)) + return hex.EncodeToString(hash[:]) +} diff --git a/backend/utils/send/send.go b/backend/utils/send/send.go index c522d7e..40de08b 100644 --- a/backend/utils/send/send.go +++ b/backend/utils/send/send.go @@ -13,6 +13,7 @@ import ( "git.pcux.cn/xuans/Smail/utils/consts" "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/smtp" + "git.pcux.cn/xuans/Smail/utils/validation" log "github.com/sirupsen/logrus" "net" "net/mail" @@ -134,6 +135,9 @@ func parseRecipientAddress(user *parsemail.User) (string, string, error) { return "", "", errors.New("recipient is nil") } address := strings.TrimSpace(user.EmailAddress) + if validation.IsNotificationAddress(address, config.CurrentMailDomains().Accepted) { + return address, "", errors.New("notification address does not accept mail") + } if address == "" || address != user.EmailAddress { return address, "", errors.New("recipient address is empty or contains surrounding whitespace") } diff --git a/backend/utils/validation/notification.go b/backend/utils/validation/notification.go new file mode 100644 index 0000000..16e68b8 --- /dev/null +++ b/backend/utils/validation/notification.go @@ -0,0 +1,26 @@ +package validation + +import "strings" + +// 加号不属于可注册账号字符,保留地址不会占用普通用户的邮箱。 +const NotificationLocalPart = "notification+system" + +func NotificationAddress(domain string) string { + if domain == "" { + return "" + } + return NotificationLocalPart + "@" + domain +} + +func IsNotificationAddress(address string, domains []string) bool { + parts := strings.Split(strings.ToLower(strings.TrimSpace(address)), "@") + if len(parts) != 2 || parts[0] != NotificationLocalPart { + return false + } + for _, domain := range domains { + if strings.EqualFold(parts[1], domain) { + return true + } + } + return false +} diff --git a/backend/utils/validation/sender.go b/backend/utils/validation/sender.go index 505cc7c..b09138c 100644 --- a/backend/utils/validation/sender.go +++ b/backend/utils/validation/sender.go @@ -6,6 +6,9 @@ import ( ) func IsAuthorizedSender(address, account string, allowLocalAlias bool, domains []string) bool { + if IsNotificationAddress(address, domains) { + return false + } address = strings.TrimSpace(address) parsed, err := mail.ParseAddress(address) if err != nil || parsed.Address != address { diff --git a/frontend/src/views/components/HomeHeader/HomeHeader.js b/frontend/src/views/components/HomeHeader/HomeHeader.js index 45f0772..0cc79f6 100644 --- a/frontend/src/views/components/HomeHeader/HomeHeader.js +++ b/frontend/src/views/components/HomeHeader/HomeHeader.js @@ -20,6 +20,7 @@ const lazySettings = loader => defineAsyncComponent({ }) const SecuritySettings = lazySettings(() => import('../SecuritySettings/SecuritySettings.vue')) const ThirdPartyAuthorization = lazySettings(() => import('../ThirdPartyAuthorization/ThirdPartyAuthorization.vue')) +const SystemNotification = lazySettings(() => import('../SystemNotification/SystemNotification.vue')) const ConfigSettings = lazySettings(() => import('../ConfigSettings/ConfigSettings.vue')) const GroupSettings = lazySettings(() => import('../GroupSettings/GroupSettings.vue')) const RuleSettings = lazySettings(() => import('../RuleSettings/RuleSettings.vue')) @@ -50,6 +51,7 @@ export function usePage() { const settingsMenu = computed(() => [ { key: 'security', label: lang.security, hint: '账号与登录保护', icon: Lock, component: SecuritySettings, capability: 'password.change_own' }, { key: 'third-party', label: '邮件客户端', hint: '当前账号协议授权', icon: Connection, component: ThirdPartyAuthorization, capability: 'user.read' }, + { key: 'system-notification', label: '系统通知发信', hint: '逐账号授权,仅发信', icon: Connection, component: SystemNotification, capability: 'user.read' }, { key: 'config', label: '配置管理', hint: '服务与运行配置', icon: Setting, component: ConfigSettings, capability: 'config.read' }, { key: 'group', label: lang.group_settings, hint: '邮箱分组管理', icon: FolderOpened, component: GroupSettings, capability: 'user.read' }, { key: 'rule', label: lang.rule_setting, hint: '邮件处理规则', icon: Filter, component: RuleSettings, capability: 'user.read' }, diff --git a/frontend/src/views/components/SystemNotification/SystemNotification.css b/frontend/src/views/components/SystemNotification/SystemNotification.css new file mode 100644 index 0000000..2af7f37 --- /dev/null +++ b/frontend/src/views/components/SystemNotification/SystemNotification.css @@ -0,0 +1,61 @@ +.notification-settings { + display: flex; + flex-direction: column; + gap: 18px; + padding: 20px; + color: var(--el-text-color-primary); +} + +.notification-header, +.notification-heading, +.notification-actions, +.notification-limits { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +.notification-header, +.notification-heading { + justify-content: space-between; +} + +.notification-settings h2, +.notification-settings h3 { + margin: 0 0 10px; +} + +.notification-header p, +.notification-hint { + margin: 6px 0 14px; + color: var(--el-text-color-secondary); + font-size: 13px; + line-height: 1.7; +} + +.notification-panel { + min-height: 80px; + padding: 18px; + border: 1px solid var(--el-border-color-light); + border-radius: 10px; + background: var(--el-bg-color); +} + +.notification-actions { + margin-bottom: 22px; +} + +.notification-warning { + color: var(--el-color-warning-dark-2); +} + +.notification-panel code { + overflow-wrap: anywhere; + user-select: all; +} + +.notification-panel .el-pagination, +.notification-secret { + margin-top: 16px; +} diff --git a/frontend/src/views/components/SystemNotification/SystemNotification.js b/frontend/src/views/components/SystemNotification/SystemNotification.js new file mode 100644 index 0000000..937ba1e --- /dev/null +++ b/frontend/src/views/components/SystemNotification/SystemNotification.js @@ -0,0 +1,171 @@ +import { computed, onMounted, onUnmounted, reactive, ref } from 'vue' +import { ElMessageBox, ElNotification } from 'element-plus' +import { http } from '@/utils/axios' +import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' +import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication' + +export function usePage() { + const globalStatus = useGlobalStatusStore() + const manager = computed(() => globalStatus.hasCapability('notification.manage')) + const loading = ref(false) + const submitting = ref(false) + const error = ref('') + const status = ref(null) + const accounts = ref([]) + const page = ref(1) + const total = ref(0) + const selectedID = ref(Number(globalStatus.userInfos?.id || 0)) + const form = reactive({ enabled: false, minute_limit: 10, day_limit: 100 }) + const secret = ref('') + const secretAccount = ref('') + const secretVisible = ref(false) + const seconds = ref(0) + let secretTimer = null + let requestSequence = 0 + let accountsSequence = 0 + let disposed = false + + const canManage = computed(() => manager.value && Boolean(status.value?.can_manage)) + const stateText = computed(() => { + if (!status.value?.enabled) return '未开启' + if (!status.value.account_available) return '账号不可用' + return status.value.configured ? '已开启,可发信' : '已开启,待生成凭据' + }) + const dirty = computed(() => status.value && ( + form.enabled !== status.value.enabled || form.minute_limit !== status.value.minute_limit || form.day_limit !== status.value.day_limit + )) + + const dataOf = result => { + if (result?.errorNo !== 0) throw new Error(result?.errorMsg || '操作失败') + return result.data + } + const showError = cause => { + if (isRecentAuthenticationCancelled(cause) || cause === 'cancel' || cause === 'close') return + ElNotification({ title: '操作失败', message: cause?.message || '请稍后重试', type: 'error' }) + } + const clearSecret = () => { + if (secretTimer) clearInterval(secretTimer) + secretTimer = null + secret.value = '' + secretAccount.value = '' + seconds.value = 0 + secretVisible.value = false + } + + const loadStatus = async () => { + const sequence = ++requestSequence + loading.value = true + error.value = '' + try { + const data = dataOf(await http.get('/api/v1/system/notification/status', { params: { user_id: selectedID.value || undefined } })) + if (disposed || sequence !== requestSequence) return + status.value = data + Object.assign(form, { enabled: data.enabled, minute_limit: data.minute_limit, day_limit: data.day_limit }) + } catch (cause) { + if (disposed || sequence !== requestSequence) return + status.value = null + error.value = cause?.message || '读取通知发信状态失败' + } finally { + if (!disposed && sequence === requestSequence) loading.value = false + } + } + const loadAccounts = async () => { + if (!manager.value) return + const sequence = ++accountsSequence + try { + const data = dataOf(await http.get('/api/v1/system/notification/accounts', { params: { page: page.value } })) + if (disposed || sequence !== accountsSequence) return + accounts.value = data.list || [] + total.value = data.total || 0 + } catch (cause) { + if (!disposed && sequence === accountsSequence) { + accounts.value = [] + showError(cause) + } + } + } + const beforeLeave = async () => { + if (submitting.value) return false + if (!dirty.value) return true + try { + await ElMessageBox.confirm('通知授权尚未保存,是否放弃本次修改?', '离开确认', { + confirmButtonText: '放弃修改', cancelButtonText: '继续编辑', type: 'warning' + }) + return true + } catch { return false } + } + const selectAccount = async row => { + if (loading.value || !(await beforeLeave())) return + clearSecret() + status.value = null + selectedID.value = row.user_id + await loadStatus() + } + const refresh = async () => { + if (!(await beforeLeave())) return + await Promise.all([loadStatus(), loadAccounts()]) + } + const save = async () => { + if (!canManage.value || !status.value || submitting.value) return + const target = status.value + const input = { user_id: target.user_id, version: target.version, ...form } + if (input.day_limit < input.minute_limit) { + showError(new Error('每日限额不能小于每分钟限额')) + return + } + try { + await ElMessageBox.confirm( + input.enabled ? `保存账号「${target.account}」的通知发信授权与限额?` : `关闭账号「${target.account}」的通知发信后,旧凭据将永久撤销;普通收发信不受影响。`, + '确认通知授权', { confirmButtonText: '确认保存', cancelButtonText: '取消', type: 'warning' } + ) + submitting.value = true + dataOf(await requestWithRecentAuthentication(() => http.post('/api/v1/system/notification/authorization', input))) + clearSecret() + ElNotification({ title: '已保存', message: '账号通知授权已更新', type: 'success' }) + await Promise.all([loadStatus(), loadAccounts()]) + } catch (cause) { + showError(cause) + } finally { submitting.value = false } + } + const rotate = async () => { + if (!canManage.value || !status.value?.enabled || submitting.value || dirty.value) return + const target = status.value + try { + await ElMessageBox.confirm(`为「${target.account}」生成新凭据?旧通知凭据和旧连接将失效,普通邮箱凭据不变。`, '确认重置凭据', { + confirmButtonText: '生成新凭据', cancelButtonText: '取消', type: 'warning' + }) + submitting.value = true + const data = dataOf(await requestWithRecentAuthentication(() => http.post('/api/v1/system/notification/credential', { + user_id: target.user_id, version: target.version + }))) + if (disposed) return + clearSecret() + secret.value = data.code + secretAccount.value = target.account + seconds.value = 60 + secretVisible.value = true + secretTimer = setInterval(() => { + seconds.value -= 1 + if (seconds.value <= 0) clearSecret() + }, 1000) + await Promise.all([loadStatus(), loadAccounts()]) + } catch (cause) { showError(cause) } + finally { submitting.value = false } + } + const copySecret = async () => { + if (!secret.value) return + try { + await navigator.clipboard.writeText(secret.value) + ElNotification({ title: '已复制', message: '请安全交付给账号使用者,不要放入日志或代码仓库', type: 'success' }) + } catch { showError(new Error('复制失败,请手动复制授权码')) } + } + + onMounted(() => { loadStatus(); loadAccounts() }) + onUnmounted(() => { disposed = true; requestSequence += 1; accountsSequence += 1; clearSecret() }) + + return { + manager, loading, submitting, error, status, accounts, page, total, form, + secret, secretAccount, secretVisible, seconds, canManage, stateText, dirty, + loadAccounts, selectAccount, refresh, save, rotate, clearSecret, copySecret, beforeLeave + } +} diff --git a/frontend/src/views/components/SystemNotification/SystemNotification.vue b/frontend/src/views/components/SystemNotification/SystemNotification.vue new file mode 100644 index 0000000..ac23aa9 --- /dev/null +++ b/frontend/src/views/components/SystemNotification/SystemNotification.vue @@ -0,0 +1,123 @@ + + + + +