This commit is contained in:
parent
deba430f2f
commit
9be7a6f990
196
backend/controllers/system_notification.go
Normal file
196
backend/controllers/system_notification.go
Normal 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, "保存系统通知授权失败,请刷新确认状态")
|
||||
}
|
||||
}
|
||||
24
backend/db/migration/notification_grant.go
Normal file
24
backend/db/migration/notification_grant.go
Normal 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},
|
||||
}
|
||||
}
|
||||
@ -39,6 +39,7 @@ func DefaultMigrations() []Migration {
|
||||
DialectMySQL: {configstate.Schema("mysql")},
|
||||
DialectPostgres: {configstate.Schema("postgres")},
|
||||
}),
|
||||
DefineForDialects(25, "create_notification_grants", notificationGrantStatements()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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": "退出登录",
|
||||
|
||||
@ -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)
|
||||
|
||||
66
backend/listen/smtp_server/notification.go
Normal file
66
backend/listen/smtp_server/notification.go
Normal 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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
19
backend/models/notification_grant.go
Normal file
19
backend/models/notification_grant.go
Normal 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" }
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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},
|
||||
|
||||
67
backend/services/auth/protocol_notification.go
Normal file
67
backend/services/auth/protocol_notification.go
Normal 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())
|
||||
}
|
||||
@ -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 &&
|
||||
|
||||
192
backend/services/notification/service.go
Normal file
192
backend/services/notification/service.go
Normal 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[:])
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
26
backend/utils/validation/notification.go
Normal file
26
backend/utils/validation/notification.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user