diff --git a/backend/cmd/smail-admin/systemd_policy_test.go b/backend/cmd/smail-admin/systemd_policy_test.go
index 4bca5477..f8488d97 100644
--- a/backend/cmd/smail-admin/systemd_policy_test.go
+++ b/backend/cmd/smail-admin/systemd_policy_test.go
@@ -86,7 +86,7 @@ func TestInstallerManagesProtectedEnvironmentFile(t *testing.T) {
"/etc/smail/smail.env",
"install -d -o root -g root -m 0750 /etc/smail",
"if [ ! -e /etc/smail/smail.env ]; then",
- "'SMAIL_ROOT=/opt/smail'",
+ "SMAIL_ROOT=",
"chmod 0600 /etc/smail/smail.env",
"每次部署都重新创建服务环境文件,不继承旧的凭据或代理配置。",
} {
@@ -104,8 +104,8 @@ func TestInstallerInstallsConfigurationTool(t *testing.T) {
}
content := string(installer)
for _, required := range []string{
- "out/backend/smail-config_linux_$arch",
- "install -o root -g root -m 0700 \"$root_dir/out/backend/smail-config_linux_$arch\" \"$runtime_dir/smail-config\"",
+ "smail-config",
+ "install -o root -g root -m 0700 \"$backend_smail_config\" \"$runtime_dir/smail-config\"",
} {
if !strings.Contains(content, required) {
t.Fatalf("installer does not install smail-config: missing %q", required)
diff --git a/backend/controllers/system_notification.go b/backend/controllers/system_notification.go
index 6344fc55..fd72c761 100644
--- a/backend/controllers/system_notification.go
+++ b/backend/controllers/system_notification.go
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
+ "strings"
"time"
"git.pcux.cn/xuans/Smail/config"
@@ -21,11 +22,12 @@ import (
)
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"`
+ UserID int `json:"user_id"`
+ Version int `json:"version"`
+ Enabled *bool `json:"enabled"`
+ MinuteLimit int `json:"minute_limit"`
+ DayLimit int `json:"day_limit"`
+ SenderName string `json:"sender_name"`
}
func loadNotificationUser(ctx *context.Context, w http.ResponseWriter, userID int, write bool) (*models.User, bool) {
@@ -110,7 +112,14 @@ func GetNotificationStatus(ctx *context.Context, w http.ResponseWriter, req *htt
"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": "系统通知",
+ "sender": validation.NotificationAddress(domain),
+ "sender_name": func() string {
+ if strings.TrimSpace(item.SenderName) != "" {
+ return strings.TrimSpace(item.SenderName)
+ }
+ return "系统通知"
+ }(),
+ "custom_sender_name": item.SenderName,
"max_recipients": notification.MaxRecipients, "max_message_bytes": notification.MaxMessageBytes,
}).FPrint(w)
}
@@ -175,8 +184,8 @@ func SetNotificationAuthorization(ctx *context.Context, w http.ResponseWriter, r
if !ok {
return
}
- err := notification.SetAuthorization(db.Instance, *user, input.Version, *input.Enabled, input.MinuteLimit, input.DayLimit, accountWriteGuard(ctx, *user))
- recordNotificationManagement(ctx, user.ID, "设置系统通知授权", err, map[string]any{"enabled": *input.Enabled, "minute_limit": input.MinuteLimit, "day_limit": input.DayLimit})
+ err := notification.SetAuthorization(db.Instance, *user, input.Version, *input.Enabled, input.MinuteLimit, input.DayLimit, input.SenderName, accountWriteGuard(ctx, *user))
+ recordNotificationManagement(ctx, user.ID, "设置系统通知授权", err, map[string]any{"enabled": *input.Enabled, "minute_limit": input.MinuteLimit, "day_limit": input.DayLimit, "sender_name": input.SenderName})
if err != nil {
writeNotificationError(w, err)
return
diff --git a/backend/db/migration/notification_grant.go b/backend/db/migration/notification_grant.go
index 35ccf7fe..314705d8 100644
--- a/backend/db/migration/notification_grant.go
+++ b/backend/db/migration/notification_grant.go
@@ -28,3 +28,7 @@ func notificationGrantStatements() map[Dialect][]string {
DialectPostgres: {statement},
}
}
+
+func applyNotificationSenderNameSchema(ctx context.Context, exec executor, dialect Dialect) error {
+ return ensureColumn(ctx, exec, dialect, "notification_grant", "sender_name", "VARCHAR(64) NULL")
+}
diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go
index c6071d66..7dd995c4 100644
--- a/backend/db/migration/registry.go
+++ b/backend/db/migration/registry.go
@@ -45,6 +45,7 @@ func DefaultMigrations() []Migration {
DefineCustom(28, "expand_recovery_code_ciphertext", "expand_recovery_code_ciphertext_v1", applyRecoveryCodeCiphertextSchema),
DefineCustom(29, "expand_email_thread", "expand_email_thread_v1", applyEmailThreadSchema),
DefineCustom(30, "expand_outbound_submission", "expand_outbound_submission_v1", applyOutboundSubmissionSchema),
+ DefineCustom(31, "expand_notification_sender_name", "expand_notification_sender_name_v1", applyNotificationSenderNameSchema),
}
}
diff --git a/backend/db/migration/registry_test.go b/backend/db/migration/registry_test.go
index 2367a1ec..58457344 100644
--- a/backend/db/migration/registry_test.go
+++ b/backend/db/migration/registry_test.go
@@ -304,6 +304,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
28: "dd1698fc6fa0762ee6399324fec75e839fdb79aa93413acd547e573fb740c654",
29: "5f027d90d556d50b236d7cfb48fbf7821041e46b8c8968563b4a4ebffde7ff3a",
30: "392621bda08c66f6e8494bba1773f3c9ca9be6591f21afb094f2fcc3beaecfa8",
+ 31: "147c2c8369a3faadcad6fd9b33af262aa91fd3f94ea90966d6383935d010420d",
}
migrations := DefaultMigrations()
if len(migrations) != len(expected) {
diff --git a/backend/listen/smtp_server/action.go b/backend/listen/smtp_server/action.go
index 7f744e4e..dc324b86 100644
--- a/backend/listen/smtp_server/action.go
+++ b/backend/listen/smtp_server/action.go
@@ -95,7 +95,7 @@ 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)) {
+ if s.isNotification() && !validation.IsAuthorizedNotificationSender(from, 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")
diff --git a/backend/listen/smtp_server/notification.go b/backend/listen/smtp_server/notification.go
index 10b15eef..b4997ecc 100644
--- a/backend/listen/smtp_server/notification.go
+++ b/backend/listen/smtp_server/notification.go
@@ -19,10 +19,13 @@ 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)
+ primaryDomain := config.CurrentMailDomains().Primary
+ if !validation.IsAuthorizedNotificationSender(s.From, primaryDomain) || email == nil || email.From == nil || email.Sender == nil {
+ return false
+ }
+ return validation.IsAuthorizedNotificationSender(email.From.EmailAddress, primaryDomain) &&
+ validation.IsAuthorizedNotificationSender(email.Sender.EmailAddress, primaryDomain) &&
+ validation.IsAuthorizedNotificationSender(email.EnvelopeFrom, primaryDomain)
}
// 插件处理后再校验最终内容,不能通过邮件头额外添加信封中没有的收件人。
@@ -59,8 +62,15 @@ func (s *Session) prepareNotification(email *parsemail.Email, rawBytes int) erro
}
return localPersistenceSMTPError()
}
- // 系统通知不能冒充账号恢复邮件,也不能请求向任意地址回执。
- email.From.Name, email.Sender.Name = "系统通知", "系统通知"
+ // 自定义发信名称:客户端From已指定名称 > 后台授权配置的SenderName > 默认"系统通知"
+ senderName := "系统通知"
+ if s.authorization != nil && strings.TrimSpace(s.authorization.NotificationSenderName()) != "" {
+ senderName = strings.TrimSpace(s.authorization.NotificationSenderName())
+ }
+ if email.From != nil && strings.TrimSpace(email.From.Name) != "" && email.From.Name != email.From.EmailAddress {
+ senderName = strings.TrimSpace(email.From.Name)
+ }
+ email.From.Name, email.Sender.Name = senderName, senderName
email.ReplyTo, email.ReadReceipt = nil, nil
return nil
}
diff --git a/backend/models/notification_grant.go b/backend/models/notification_grant.go
index 16360130..d98d55bf 100644
--- a/backend/models/notification_grant.go
+++ b/backend/models/notification_grant.go
@@ -15,6 +15,7 @@ type NotificationGrant struct {
MinuteCount int `xorm:"minute_count" json:"-"`
DayKey string `xorm:"day_key" json:"-"`
DayCount int `xorm:"day_count" json:"-"`
+ SenderName string `xorm:"sender_name varchar(64) null" json:"sender_name"`
}
func (NotificationGrant) TableName() string { return "notification_grant" }
diff --git a/backend/services/access/access_test.go b/backend/services/access/access_test.go
index 220ae749..10713c39 100644
--- a/backend/services/access/access_test.go
+++ b/backend/services/access/access_test.go
@@ -172,7 +172,7 @@ func TestAdminPluginRestartScope(t *testing.T) {
func TestManagementEndpointCatalog(t *testing.T) {
endpoints := ManagementEndpoints()
- if len(endpoints) != 47 {
+ if len(endpoints) != 49 {
t.Fatalf("unexpected endpoint count: %d", len(endpoints))
}
seen := map[string]struct{}{}
diff --git a/backend/services/auth/protocol_notification.go b/backend/services/auth/protocol_notification.go
index 59e82b36..93a02dfe 100644
--- a/backend/services/auth/protocol_notification.go
+++ b/backend/services/auth/protocol_notification.go
@@ -65,3 +65,10 @@ func (s *ProtocolSession) ReserveNotification(recipients int) error {
}
return s.notification.Reserve(db.Instance, recipients, time.Now())
}
+
+func (s *ProtocolSession) NotificationSenderName() string {
+ if !s.IsNotification() {
+ return ""
+ }
+ return s.notification.SenderName()
+}
diff --git a/backend/services/notification/service.go b/backend/services/notification/service.go
index 7f0934d7..47dcc1a5 100644
--- a/backend/services/notification/service.go
+++ b/backend/services/notification/service.go
@@ -56,7 +56,7 @@ func Configured(item models.NotificationGrant, user models.User) bool {
}
// SetAuthorization 使用版本条件避免覆盖其他管理员的修改;关闭时永久清除旧凭据。
-func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabled bool, minuteLimit, dayLimit int, guards ...accountgroup.WriteGuard) error {
+func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabled bool, minuteLimit, dayLimit int, senderName string, guards ...accountgroup.WriteGuard) error {
if minuteLimit < 1 || minuteLimit > 100 || dayLimit < minuteLimit || dayLimit > 10000 {
return ErrLimits
}
@@ -93,6 +93,11 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl
item.CodeHash = ""
item.CodeCiphertext = ""
}
+ senderName = strings.TrimSpace(senderName)
+ if len(senderName) > 64 {
+ senderName = senderName[:64]
+ }
+ item.SenderName = senderName
item.MinuteLimit, item.DayLimit = minuteLimit, dayLimit
item.CodeVersion++
if expected == 0 {
@@ -102,7 +107,7 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl
return tx.Commit()
}
affected, err := tx.ID(user.ID).Where("code_version = ?", expected).
- Cols("enabled", "code_hash", "code_ciphertext", "code_version", "minute_limit", "day_limit").Update(&item)
+ Cols("enabled", "code_hash", "code_ciphertext", "code_version", "minute_limit", "day_limit", "sender_name").Update(&item)
if err != nil {
return err
}
@@ -214,6 +219,14 @@ type Grant struct {
hash string
sessionVersion int
securityVersion int
+ senderName string
+}
+
+func (g *Grant) SenderName() string {
+ if g == nil {
+ return ""
+ }
+ return g.senderName
}
func (g *Grant) QueueBinding() (string, int, int, string) {
@@ -234,7 +247,7 @@ func Authenticate(engine *xorm.Engine, user models.User, plain string) (*Grant,
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
+ return &Grant{engine, user.ID, item.CodeVersion, item.CodeHash, item.SessionVersion, item.SecurityVersion, item.SenderName}, nil
}
func (g *Grant) Valid(engine *xorm.Engine) bool {
diff --git a/backend/services/outbox/runtime.go b/backend/services/outbox/runtime.go
index 5bfcb46f..5ce23240 100644
--- a/backend/services/outbox/runtime.go
+++ b/backend/services/outbox/runtime.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "strings"
"sync"
"time"
@@ -185,9 +184,10 @@ func authorizedQueuedSender(submission Submission, message *parsemail.Email, use
}
domains := config.CurrentMailDomains()
if submission.Authorization.Kind == "notification" {
- sender := validation.NotificationAddress(domains.Primary)
- return sender != "" && strings.EqualFold(submission.EnvelopeFrom, sender) &&
- strings.EqualFold(message.From.EmailAddress, sender) && strings.EqualFold(message.Sender.EmailAddress, sender)
+ primaryDomain := domains.Primary
+ return primaryDomain != "" && validation.IsAuthorizedNotificationSender(submission.EnvelopeFrom, primaryDomain) &&
+ validation.IsAuthorizedNotificationSender(message.From.EmailAddress, primaryDomain) &&
+ validation.IsAuthorizedNotificationSender(message.Sender.EmailAddress, primaryDomain)
}
allowAlias := user.EffectiveRole() == access.RoleSuperAdmin
return validation.IsAuthorizedSender(message.From.EmailAddress, user.Account, allowAlias, domains.Accepted) &&
diff --git a/backend/utils/validation/notification.go b/backend/utils/validation/notification.go
index 34e40a5b..6eb77e20 100644
--- a/backend/utils/validation/notification.go
+++ b/backend/utils/validation/notification.go
@@ -7,8 +7,12 @@ import (
var ErrNotificationRecipient = errors.New("notification address does not accept mail")
-// 加号不属于可注册账号字符,保留地址不会占用普通用户的邮箱。
-const NotificationLocalPart = "notification+system"
+// notification 为主发信账号;notification+system 为历史保留兼容账号。
+// 两者均为系统保留地址,禁止普通用户占用,且拒收任何外部来信。
+const (
+ NotificationLocalPart = "notification"
+ LegacyNotificationLocalPart = "notification+system"
+)
func NotificationAddress(domain string) string {
if domain == "" {
@@ -17,9 +21,19 @@ func NotificationAddress(domain string) string {
return NotificationLocalPart + "@" + domain
}
+func LegacyNotificationAddress(domain string) string {
+ if domain == "" {
+ return ""
+ }
+ return LegacyNotificationLocalPart + "@" + domain
+}
+
func IsNotificationAddress(address string, domains []string) bool {
parts := strings.Split(strings.ToLower(strings.TrimSpace(address)), "@")
- if len(parts) != 2 || parts[0] != NotificationLocalPart {
+ if len(parts) != 2 {
+ return false
+ }
+ if parts[0] != NotificationLocalPart && parts[0] != LegacyNotificationLocalPart {
return false
}
for _, domain := range domains {
@@ -29,3 +43,14 @@ func IsNotificationAddress(address string, domains []string) bool {
}
return false
}
+
+func IsAuthorizedNotificationSender(address, domain string) bool {
+ if domain == "" {
+ return false
+ }
+ parts := strings.Split(strings.ToLower(strings.TrimSpace(address)), "@")
+ if len(parts) != 2 || !strings.EqualFold(parts[1], domain) {
+ return false
+ }
+ return parts[0] == NotificationLocalPart || parts[0] == LegacyNotificationLocalPart
+}
diff --git a/backend/utils/validation/notification_test.go b/backend/utils/validation/notification_test.go
new file mode 100644
index 00000000..253a088f
--- /dev/null
+++ b/backend/utils/validation/notification_test.go
@@ -0,0 +1,46 @@
+package validation
+
+import "testing"
+
+func TestNotificationAddressAndSenderValidation(t *testing.T) {
+ domain := "example.com"
+ primarySender := NotificationAddress(domain)
+ if primarySender != "notification@example.com" {
+ t.Fatalf("NotificationAddress want notification@example.com, got %s", primarySender)
+ }
+
+ legacySender := LegacyNotificationAddress(domain)
+ if legacySender != "notification+system@example.com" {
+ t.Fatalf("LegacyNotificationAddress want notification+system@example.com, got %s", legacySender)
+ }
+
+ domains := []string{"example.com", "other.com"}
+
+ // 验证 IsNotificationAddress:主地址和历史地址均应被识别为通知地址
+ if !IsNotificationAddress("notification@example.com", domains) {
+ t.Fatalf("notification@example.com should be recognized as notification address")
+ }
+ if !IsNotificationAddress("notification+system@example.com", domains) {
+ t.Fatalf("notification+system@example.com should be recognized as notification address")
+ }
+ if !IsNotificationAddress("NOTIFICATION@OTHER.COM", domains) {
+ t.Fatalf("case insensitive notification address check failed")
+ }
+ if IsNotificationAddress("user@example.com", domains) {
+ t.Fatalf("user@example.com should NOT be notification address")
+ }
+
+ // 验证 IsAuthorizedNotificationSender
+ if !IsAuthorizedNotificationSender("notification@example.com", domain) {
+ t.Fatalf("notification@example.com should be authorized sender")
+ }
+ if !IsAuthorizedNotificationSender("notification+system@example.com", domain) {
+ t.Fatalf("notification+system@example.com should be authorized sender")
+ }
+ if IsAuthorizedNotificationSender("attacker@example.com", domain) {
+ t.Fatalf("attacker@example.com should NOT be authorized notification sender")
+ }
+ if IsAuthorizedNotificationSender("notification@attacker.com", domain) {
+ t.Fatalf("notification@attacker.com should NOT be authorized notification sender for domain")
+ }
+}
diff --git a/frontend/src/views/components/HomeAside/HomeAside.vue b/frontend/src/views/components/HomeAside/HomeAside.vue
index eca7d025..a352cea0 100644
--- a/frontend/src/views/components/HomeAside/HomeAside.vue
+++ b/frontend/src/views/components/HomeAside/HomeAside.vue
@@ -57,7 +57,7 @@
{{ status.username }}{{ status.sender }}必须启用加密和证书校验。服务器名需正确解析且由证书覆盖;此处显示配置,不代表公网连通性已验收。