feat: 支持第三方发信自定义发信名称并简化系统发信地址,发布V2.13.0部署包
This commit is contained in:
parent
8fd67812b3
commit
1ce9f708e3
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -304,6 +304,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
|
||||
28: "dd1698fc6fa0762ee6399324fec75e839fdb79aa93413acd547e573fb740c654",
|
||||
29: "5f027d90d556d50b236d7cfb48fbf7821041e46b8c8968563b4a4ebffde7ff3a",
|
||||
30: "392621bda08c66f6e8494bba1773f3c9ca9be6591f21afb094f2fcc3beaecfa8",
|
||||
31: "147c2c8369a3faadcad6fd9b33af262aa91fd3f94ea90966d6383935d010420d",
|
||||
}
|
||||
migrations := DefaultMigrations()
|
||||
if len(migrations) != len(expected) {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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{}{}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) &&
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
46
backend/utils/validation/notification_test.go
Normal file
46
backend/utils/validation/notification_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user