检查点:保留通知授权开发状态,开始用户分组隔离
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-25 21:19:28 +08:00
parent deba430f2f
commit 9be7a6f990
22 changed files with 1024 additions and 7 deletions

View File

@ -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, "保存系统通知授权失败,请刷新确认状态")
}
}

View File

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

View File

@ -39,6 +39,7 @@ func DefaultMigrations() []Migration {
DialectMySQL: {configstate.Schema("mysql")},
DialectPostgres: {configstate.Schema("postgres")},
}),
DefineForDialects(25, "create_notification_grants", notificationGrantStatements()),
}
}

View File

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

View File

@ -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": "退出登录",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[:])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,123 @@
<template>
<section class="notification-settings" aria-label="系统通知发信">
<header class="notification-header">
<div>
<h2>系统通知发信</h2>
<p>按账号授权的 SMTP 发信服务,与普通邮箱登录和收发信独立。</p>
</div>
<el-button :disabled="submitting || loading" @click="refresh">刷新状态</el-button>
</header>
<el-alert
title="默认关闭,逐账号授权"
:description="manager ? '只有管理范围内的账号可以授权。管理员修改授权前需要身份确认;关闭时永久撤销旧凭据。' : '普通用户只能查看自己的状态和连接参数。需要开启或重置凭据时,请联系管理员。'"
type="info"
show-icon
:closable="false"
/>
<div v-if="manager" class="notification-panel">
<h3>账号授权管理</h3>
<el-table :data="accounts" stripe>
<el-table-column prop="account" label="账号" min-width="110" />
<el-table-column prop="name" label="名称" min-width="90" />
<el-table-column label="通知状态" min-width="140">
<template #default="{ row }">
<el-tag :type="row.enabled && row.configured ? 'success' : 'info'">
{{ row.disabled ? '账号已禁用' : !row.enabled ? '未开启' : row.configured ? '已开启' : '待生成凭据' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="primary" :disabled="submitting || loading" @click="selectAccount(row)">管理</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="20"
:total="total"
layout="prev, pager, next"
:disabled="submitting"
@current-change="loadAccounts"
/>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<div v-loading="loading" class="notification-panel" aria-live="polite">
<template v-if="status">
<div class="notification-heading">
<h3>账号:{{ status.account }}</h3>
<el-tag :type="status.enabled && status.configured ? 'success' : 'info'">{{ stateText }}</el-tag>
</div>
<p v-if="!status.account_available" class="notification-warning">账号已禁用或尚未修改临时密码,不能生成或使用通知凭据。</p>
<el-form v-if="canManage" label-position="top" :disabled="submitting || loading" @submit.prevent="save">
<el-form-item label="允许该账号通过 SMTP 发送系统通知">
<el-switch v-model="form.enabled" active-text="开启" inactive-text="关闭" />
</el-form-item>
<div class="notification-limits">
<el-form-item label="每分钟收件人限额">
<el-input-number v-model="form.minute_limit" :min="1" :max="100" :precision="0" />
</el-form-item>
<el-form-item label="每日收件人限额(UTC)">
<el-input-number v-model="form.day_limit" :min="1" :max="10000" :precision="0" />
</el-form-item>
</div>
<div class="notification-actions">
<el-button type="primary" :loading="submitting" :disabled="!dirty" native-type="submit">保存授权</el-button>
<el-button
:disabled="!status.enabled || !status.account_available || dirty || submitting"
@click="rotate"
>{{ status.configured ? '重置发信凭据' : '生成发信凭据' }}</el-button>
</div>
</el-form>
<el-descriptions title="使用状态" :column="1" border>
<el-descriptions-item label="本分钟用量">{{ status.minute_used }} / {{ status.minute_limit }} 个收件人</el-descriptions-item>
<el-descriptions-item label="今日用量(UTC)">{{ status.day_used }} / {{ status.day_limit }} 个收件人</el-descriptions-item>
<el-descriptions-item label="单封限制">最多 {{ status.max_recipients }} 个收件人,最大 {{ status.max_message_bytes / 1048576 }} MiB</el-descriptions-item>
</el-descriptions>
<p class="notification-hint">配额按提交的收件人数计算,失败和重试也计数;更换凭据或重启不会清空配额。</p>
<el-descriptions title="应用 SMTP 配置" :column="1" border>
<el-descriptions-item label="服务器">{{ status.host }}</el-descriptions-item>
<el-descriptions-item label="隐式 TLS 端口">{{ status.tls_port || '未启用' }}</el-descriptions-item>
<el-descriptions-item label="STARTTLS 端口">{{ status.starttls_port || '未启用' }}</el-descriptions-item>
<el-descriptions-item label="SMTP 用户名"><code>{{ status.username }}</code></el-descriptions-item>
<el-descriptions-item label="SMTP 密码">管理员生成的独立发信凭据,不是网页登录密码或普通客户端授权码</el-descriptions-item>
<el-descriptions-item label="发件邮箱"><code>{{ status.sender }}</code></el-descriptions-item>
<el-descriptions-item label="发件名称">{{ status.sender_name }}(服务端固定)</el-descriptions-item>
</el-descriptions>
<p class="notification-hint">必须启用加密和证书校验。服务器名需正确解析且由证书覆盖;此处显示配置,不代表公网连通性已验收。</p>
<el-alert title="仅发信,不提供收件箱" description="通知地址拒收邮件;通知凭据不能用于网页登录、IMAP 或 POP3。普通邮箱及其原有凭据不受影响。" type="warning" :closable="false" show-icon />
</template>
<el-empty v-else-if="!loading && !error" description="尚未读取账号状态" />
</div>
<el-dialog v-model="secretVisible" title="通知发信凭据(仅显示一次)" width="min(560px, 92vw)" destroy-on-close @closed="clearSecret">
<p>账号:{{ secretAccount }}。请安全保存并交付给该账号的使用者。</p>
<el-alert :title="`${seconds} 秒后自动隐藏;关闭后无法再次查看,只能重新生成。`" type="warning" :closable="false" show-icon />
<el-input :model-value="secret" readonly aria-label="新通知发信凭据" class="notification-secret" />
<template #footer>
<el-button @click="copySecret">复制凭据</el-button>
<el-button type="primary" @click="clearSecret">已保存,关闭</el-button>
</template>
</el-dialog>
</section>
</template>
<script setup>
import { usePage } from './SystemNotification.js'
const {
manager, loading, submitting, error, status, accounts, page, total, form,
secret, secretAccount, secretVisible, seconds, canManage, stateText, dirty,
loadAccounts, selectAccount, refresh, save, rotate, clearSecret, copySecret, beforeLeave
} = usePage()
defineExpose({ beforeLeave })
</script>
<style scoped src="./SystemNotification.css"></style>