feat: 支持第三方发信自定义发信名称并简化系统发信地址,发布V2.13.0部署包

This commit is contained in:
smail 2026-10-18 01:13:06 +08:00
parent 8fd67812b3
commit 1ce9f708e3
17 changed files with 161 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -304,6 +304,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
28: "dd1698fc6fa0762ee6399324fec75e839fdb79aa93413acd547e573fb740c654",
29: "5f027d90d556d50b236d7cfb48fbf7821041e46b8c8968563b4a4ebffde7ff3a",
30: "392621bda08c66f6e8494bba1773f3c9ca9be6591f21afb094f2fcc3beaecfa8",
31: "147c2c8369a3faadcad6fd9b33af262aa91fd3f94ea90966d6383935d010420d",
}
migrations := DefaultMigrations()
if len(migrations) != len(expected) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -57,7 +57,7 @@
<el-icon><Setting /></el-icon>
<span class="settings-label">{{ lang.settings }}</span>
</div>
<span class="app-version-badge">V2.12.0</span>
<span class="app-version-badge">V2.13.0</span>
</button>
</div>
</div>

View File

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

View File

@ -117,6 +117,14 @@
<el-form-item label="允许该账号通过 SMTP 发送系统通知">
<el-switch v-model="form.enabled" active-text="开启" inactive-text="关闭" />
</el-form-item>
<el-form-item label="自定义发件名称(可选)">
<el-input
v-model="form.sender_name"
maxlength="30"
placeholder="留空默认显示“系统通知”"
clearable
/>
</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" />
@ -152,7 +160,7 @@
<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-item label="发件名称">{{ status.sender_name }}(未自定义时默认“系统通知”)</el-descriptions-item>
</el-descriptions>
<p class="notification-hint">必须启用加密和证书校验。服务器名需正确解析且由证书覆盖;此处显示配置,不代表公网连通性已验收。</p>
</div>