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 @@ {{ lang.settings }} - V2.12.0 + V2.13.0 diff --git a/frontend/src/views/components/SystemNotification/SystemNotification.js b/frontend/src/views/components/SystemNotification/SystemNotification.js index 985052ee..f43fb2da 100644 --- a/frontend/src/views/components/SystemNotification/SystemNotification.js +++ b/frontend/src/views/components/SystemNotification/SystemNotification.js @@ -15,7 +15,7 @@ export function usePage(props = {}) { const page = ref(1) const total = ref(0) const selectedID = ref(Number(props.userId || globalStatus.userInfos?.id || 0)) - const form = reactive({ enabled: false, minute_limit: 10, day_limit: 100 }) + const form = reactive({ enabled: false, minute_limit: 10, day_limit: 100, sender_name: '' }) const secret = ref('') const secretAccount = ref('') const secretVisible = ref(false) @@ -38,7 +38,10 @@ export function usePage(props = {}) { 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 + form.enabled !== status.value.enabled || + form.minute_limit !== status.value.minute_limit || + form.day_limit !== status.value.day_limit || + (form.sender_name || '') !== (status.value.custom_sender_name || '') )) const dataOf = result => { diff --git a/frontend/src/views/components/SystemNotification/SystemNotification.vue b/frontend/src/views/components/SystemNotification/SystemNotification.vue index 250607bb..2bb304ad 100644 --- a/frontend/src/views/components/SystemNotification/SystemNotification.vue +++ b/frontend/src/views/components/SystemNotification/SystemNotification.vue @@ -117,6 +117,14 @@ + + +
@@ -152,7 +160,7 @@ {{ status.username }} 独立发信凭据 {{ status.sender }} - {{ status.sender_name }}(服务端固定) + {{ status.sender_name }}(未自定义时默认“系统通知”)

必须启用加密和证书校验。服务器名需正确解析且由证书覆盖;此处显示配置,不代表公网连通性已验收。