From 7ede29a18a4596d521142401c906781553a71c2e Mon Sep 17 00:00:00 2001 From: dsh Date: Wed, 19 Aug 2026 09:36:55 -0400 Subject: [PATCH 1/4] =?UTF-8?q?fix(imap):=20UIDVALIDITY=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8C=81=E4=B9=85=E5=8C=96=E9=9A=8F=E6=9C=BA=E5=80=BC?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=95=B0=E6=8D=AE=E5=BA=93=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=E5=90=8E=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E6=B0=B8=E4=B8=8D=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:Status() 硬编码 UidValidity=1。数据库重建后消息 ID 空间 完全变化(uid 2-166 对应完全不同的邮件),但 UIDVALIDITY 不变, Thunderbird 认为本地缓存(旧 uid 1-166)仍然有效,只下载"新增" (uid 167:*)与"缺失"(2,4,8,9)的少数邮件——列表只剩 1-3 封, 每次刷新数量随机。 修复(RFC 3501 UIDVALIDITY 语义):新增 mailbox_states 表, 每(用户,文件夹)首次访问随机生成并持久化 UIDVALIDITY;数据库 重建后新值触发客户端丢弃缓存全量重同步。0 值修正,不同用户/ 文件夹互相独立。 新增 TestMailboxStateUidValidity 单元测试。 --- internal/db/models.go | 18 +++++++ internal/imap_server/backend.go | 14 ++++-- internal/store/mailbox_state_store.go | 56 ++++++++++++++++++++++ internal/store/mailbox_state_store_test.go | 56 ++++++++++++++++++++++ internal/store/stores.go | 3 ++ 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 internal/store/mailbox_state_store.go create mode 100644 internal/store/mailbox_state_store_test.go diff --git a/internal/db/models.go b/internal/db/models.go index ffc78a4..b813658 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -170,3 +170,21 @@ type Attachment struct { func (Attachment) TableName() string { return "attachments" } + +// MailboxState 记录每个邮箱(用户+文件夹)的持久化 IMAP 状态。 +// UidValidity 在首次访问时随机生成并持久化:数据库重建(消息 ID 空间 +// 变化)后该值随之改变,客户端(Thunderbird 等)会据此丢弃本地缓存 +// 并全量重新同步。此前硬编码为 1,数据库重建后客户端缓存永不失效, +// 导致只显示/下载少量"缺失"邮件。 +type MailboxState struct { + UserID uint `gorm:"primaryKey" json:"user_id"` + Folder string `gorm:"primaryKey;size:64" json:"folder"` + UidValidity uint32 `gorm:"not null" json:"uid_validity"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName specifies the table name for MailboxState. +func (MailboxState) TableName() string { + return "mailbox_states" +} diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index cbc4b5d..ec5eec4 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -354,7 +354,6 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro if err != nil { return nil, err } - status.Messages = uint32(len(messages)) var unseenCount uint32 @@ -370,7 +369,16 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro return nil, err } status.UidNext = uint32(maxID + 1) - status.UidValidity = 1 + // UIDVALIDITY 持久化随机值(RFC 3501):数据库重建导致消息 ID 空间 + // 变化时该值随之改变,客户端才会丢弃旧缓存全量重同步。此前硬编码 1, + // 数据库重建后 Thunderbird 等客户端缓存永不失效(只下载"新增"的 + // UID),表现为列表只剩少量邮件。 + uidValidity, err := m.stores.MailboxState.UidValidity(m.user.id, m.name) + if err != nil { + log.Printf("IMAP: 获取 UIDVALIDITY 失败 folder=%s: %v", m.name, err) + uidValidity = 1 + } + status.UidValidity = uidValidity return status, nil } @@ -394,7 +402,6 @@ func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.F if err != nil { return err } - if len(dbMessages) == 0 { return nil } @@ -567,7 +574,6 @@ func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([ } } } - return results, nil } diff --git a/internal/store/mailbox_state_store.go b/internal/store/mailbox_state_store.go new file mode 100644 index 0000000..f2f04b5 --- /dev/null +++ b/internal/store/mailbox_state_store.go @@ -0,0 +1,56 @@ +package store + +import ( + "crypto/rand" + "encoding/binary" + + "mail_go/internal/db" + + "gorm.io/gorm" +) + +// MailboxStateStore 提供 IMAP 邮箱持久化状态(UIDVALIDITY)的存取。 +type MailboxStateStore interface { + // UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。 + UidValidity(userID uint, folder string) (uint32, error) +} + +type mailboxStateStoreGorm struct { + db *gorm.DB +} + +func newMailboxStateStore(database *gorm.DB) *mailboxStateStoreGorm { + return &mailboxStateStoreGorm{db: database} +} + +// UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。 +// 随机值保证:邮箱内容身份变化(如数据库重建导致消息 ID 空间变化)时, +// 新库生成的新值会让客户端丢弃旧缓存全量重同步(RFC 3501 UIDVALIDITY +// 语义)。绝不返回 0(0 不是合法 UIDVALIDITY)。 +func (s *mailboxStateStoreGorm) UidValidity(userID uint, folder string) (uint32, error) { + var st db.MailboxState + err := s.db.Where("user_id = ? AND folder = ?", userID, folder).First(&st).Error + if err == nil { + if st.UidValidity != 0 { + return st.UidValidity, nil + } + } else if err != gorm.ErrRecordNotFound { + return 0, err + } + + // 首次访问(或旧数据为 0):随机生成并持久化 + var buf [4]byte + if _, err := rand.Read(buf[:]); err != nil { + return 0, err + } + v := binary.BigEndian.Uint32(buf[:]) + if v == 0 { + v = 1 + } + + st = db.MailboxState{UserID: userID, Folder: folder, UidValidity: v} + if err := s.db.Save(&st).Error; err != nil { + return 0, err + } + return v, nil +} diff --git a/internal/store/mailbox_state_store_test.go b/internal/store/mailbox_state_store_test.go new file mode 100644 index 0000000..a3b03cf --- /dev/null +++ b/internal/store/mailbox_state_store_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "testing" + + "mail_go/internal/db" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// TestMailboxStateUidValidity 验证 UIDVALIDITY:首次访问随机生成、重复访问 +// 稳定返回、0 值被修正、不同邮箱互不影响。 +func TestMailboxStateUidValidity(t *testing.T) { + gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := gdb.AutoMigrate(&db.MailboxState{}); err != nil { + t.Fatal(err) + } + s := newMailboxStateStore(gdb) + + // 首次访问:随机非 0 + v1, err := s.UidValidity(1, "INBOX") + if err != nil { + t.Fatal(err) + } + if v1 == 0 { + t.Fatal("UIDVALIDITY 不应为 0") + } + // 重复访问:稳定 + v2, err := s.UidValidity(1, "INBOX") + if err != nil { + t.Fatal(err) + } + if v1 != v2 { + t.Fatalf("UIDVALIDITY 不稳定: %d != %d", v1, v2) + } + // 不同邮箱:独立 + v3, err := s.UidValidity(1, "Sent") + if err != nil { + t.Fatal(err) + } + if v3 == v1 { + t.Fatal("不同邮箱的 UIDVALIDITY 不应相同") + } + // 不同用户:独立 + v4, err := s.UidValidity(2, "INBOX") + if err != nil { + t.Fatal(err) + } + if v4 == v1 { + t.Fatal("不同用户的 UIDVALIDITY 不应相同") + } +} diff --git a/internal/store/stores.go b/internal/store/stores.go index 07eb505..9831069 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -15,6 +15,7 @@ type Stores struct { Bans BanStore Outbound OutboundStore ProtocolLogs ProtocolLogStore + MailboxState MailboxStateStore } // NewStores creates a new Stores instance with all GORM-backed implementations. @@ -27,6 +28,7 @@ func NewStores(database *gorm.DB) *Stores { Bans: newBanStore(database), Outbound: newOutboundStore(database), ProtocolLogs: newProtocolLogStore(database), + MailboxState: newMailboxStateStore(database), } } @@ -37,3 +39,4 @@ var _ = db.Message{} var _ = db.Attachment{} var _ = db.BanEntry{} var _ = db.ProtocolLog{} +var _ = db.MailboxState{} -- 2.47.3 From 6d5a7a8ed4c849e7255c3b38b28cf1489d0a9db5 Mon Sep 17 00:00:00 2001 From: dsh Date: Wed, 19 Aug 2026 09:45:39 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(imap):=20mailbox=5Fstates=20=E8=A1=A8?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=20AutoMigrate=EF=BC=88=E6=AD=A4=E5=89=8D?= =?UTF-8?q?=E6=BC=8F=E8=BF=81=E7=A7=BB=E5=AF=BC=E8=87=B4=20UIDVALIDITY=20?= =?UTF-8?q?=E5=9B=9E=E9=80=80=201=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/db/db.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/db/db.go b/internal/db/db.go index c29fa34..33c76f6 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -56,7 +56,7 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D } // Auto-migrate all models - if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}); err != nil { + if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}); err != nil { return nil, fmt.Errorf("数据库迁移失败: %w", err) } -- 2.47.3 From f48776e89b8955660622db5a2a887fe287dc64b6 Mon Sep 17 00:00:00 2001 From: dsh Date: Wed, 19 Aug 2026 10:12:28 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix(auth):=20=E5=8D=8F=E8=AE=AE=E7=99=BB?= =?UTF-8?q?=E5=BD=95=EF=BC=88IMAP/SMTP/POP3=EF=BC=89=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E6=97=B6=E6=B8=85=E9=9B=B6=E5=A4=B1=E8=B4=A5=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=20+=20=E6=94=AF=E6=8C=81=E8=A3=B8=E7=94=A8=E6=88=B7=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:ResetFail 只在 Web 登录调用,协议层失败计数只增不减。 合法用户的客户端(手机 APP 用裸用户名重试、配置向导探测、输错 密码等)失败次数持续累积,每达到阈值就触发一次封禁档位,从第 4 次触发起真实封禁 30 分钟+——用户被反复误封,手机端表现为一直 卡在"正在接收邮件"。 修复: - IMAP/SMTP/POP3 认证成功路径调用 Bans.ResetFail(与 Web 一致) - 新增 UserStore.AuthenticateLogin:支持裸用户名(如 "kevin"), 唯一归属时自动解析到其域名;跨域名同名歧义时要求完整邮箱 - 新增 TestAuthenticateLoginBareUsername 单元测试 --- internal/imap_server/backend.go | 7 +- internal/pop3_server/server.go | 6 +- internal/smtp_server/server.go | 6 +- internal/store/user_store.go | 33 ++++++++++ internal/store/user_store_authlogin_test.go | 73 +++++++++++++++++++++ 5 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 internal/store/user_store_authlogin_test.go diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index ec5eec4..cd5f393 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -145,7 +145,7 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) return nil, backend.ErrInvalidCredentials } - user, err := b.stores.Users.Authenticate(username, password) + user, err := b.stores.Users.AuthenticateLogin(username, password) if err != nil { // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries) b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin, "邮件协议认证失败次数过多") @@ -153,6 +153,11 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) return nil, fmt.Errorf("invalid credentials: %w", err) } + // 登录成功清零失败计数(与 Web 登录一致):否则协议客户端的失败计数 + // 只增不减(如配置探测、输错密码、APP 用裸用户名重试等),累计触发 + // 档位封禁,合法用户 IP 被反复误封。 + b.stores.Bans.ResetFail(clientIP) + email := user.Username + "@" domain, err := b.stores.Domains.GetByID(user.DomainID) if err == nil { diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go index 5ea1091..dea5dd0 100644 --- a/internal/pop3_server/server.go +++ b/internal/pop3_server/server.go @@ -395,7 +395,7 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) ( clientIP := store.ClientIPFromAddr(conn.RemoteAddr()) - authUser, err := s.stores.Users.Authenticate(user.Username, password) + authUser, err := s.stores.Users.AuthenticateLogin(user.Username, password) if err != nil { // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries) s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多") @@ -403,6 +403,10 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) ( return nil, nil, nil } + // 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP + // 因失败计数只增不减被反复误封。 + s.stores.Bans.ResetFail(clientIP) + // 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用 authUser.Username = user.Username diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go index 179e450..7591e6e 100644 --- a/internal/smtp_server/server.go +++ b/internal/smtp_server/server.go @@ -219,7 +219,7 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { return smtp.ErrAuthFailed } - user, err := s.backend.server.stores.Users.Authenticate(username, password) + user, err := s.backend.server.stores.Users.AuthenticateLogin(username, password) if err != nil { // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries) s.backend.server.stores.RecordAuthFailure( @@ -232,6 +232,10 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { return smtp.ErrAuthFailed } + // 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP + // 因失败计数只增不减被反复误封。 + s.backend.server.stores.Bans.ResetFail(s.clientIP) + domainName := user.Domain.Name if domainName == "" { domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID) diff --git a/internal/store/user_store.go b/internal/store/user_store.go index 01e55b2..13011ca 100644 --- a/internal/store/user_store.go +++ b/internal/store/user_store.go @@ -16,6 +16,10 @@ type UserStore interface { GetByUsername(username string, domainID uint) (*db.User, error) GetByEmail(email string) (*db.User, error) Authenticate(email, password string) (*db.User, error) + // AuthenticateLogin 协议层登录(IMAP/SMTP/POP3):与 Authenticate 相同, + // 但支持裸用户名(如 "kevin"),自动解析到其唯一所属域名;多域名下 + // 用户名存在歧义时要求完整邮箱。兼容手机/客户端只填用户名的配置。 + AuthenticateLogin(login, password string) (*db.User, error) Update(user *db.User) error Delete(id uint) error List(domainID uint, page, size int) ([]db.User, int64, error) @@ -94,6 +98,35 @@ func (s *userStoreGorm) Authenticate(email, password string) (*db.User, error) { return user, nil } +// AuthenticateLogin 协议层登录:优先按完整邮箱认证;裸用户名(无 @)时 +// 按用户名全局查找,仅在唯一归属时接受(多域名同名视为歧义,返回失败, +// 客户端应改用完整邮箱)。密码校验与 IsActive 逻辑与 Authenticate 一致。 +func (s *userStoreGorm) AuthenticateLogin(login, password string) (*db.User, error) { + if strings.Contains(login, "@") { + return s.Authenticate(login, password) + } + + var users []db.User + if err := s.db.Joins("JOIN domains ON domains.id = users.domain_id"). + Where("users.username = ?", login). + Preload("Domain"). + Find(&users).Error; err != nil { + return nil, ErrInvalidCredentials + } + if len(users) != 1 { + // 0 个:用户不存在;多个:跨域名同名歧义,要求完整邮箱 + return nil, ErrInvalidCredentials + } + user := users[0] + if !user.IsActive { + return nil, ErrUserInactive + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, ErrInvalidCredentials + } + return &user, nil +} + // Update saves changes to an existing user record. func (s *userStoreGorm) Update(user *db.User) error { return s.db.Save(user).Error diff --git a/internal/store/user_store_authlogin_test.go b/internal/store/user_store_authlogin_test.go new file mode 100644 index 0000000..06b07f0 --- /dev/null +++ b/internal/store/user_store_authlogin_test.go @@ -0,0 +1,73 @@ +package store + +import ( + "testing" + + "mail_go/internal/db" + + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// TestAuthenticateLoginBareUsername 验证协议层登录支持裸用户名: +// 唯一归属时可用,密码错误/用户不存在/跨域名同名歧义时拒绝。 +func TestAuthenticateLoginBareUsername(t *testing.T) { + gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}); err != nil { + t.Fatal(err) + } + us := newUserStore(gdb) + ds := newDomainStore(gdb) + + dom1 := &db.Domain{Name: "example.com"} + if err := ds.Create(dom1); err != nil { + t.Fatal(err) + } + hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) + u1 := &db.User{Username: "alice", DomainID: dom1.ID, PasswordHash: string(hashed), IsActive: true} + if err := us.Create(u1); err != nil { + t.Fatal(err) + } + + // 裸用户名 + 正确密码 → 成功 + u, err := us.AuthenticateLogin("alice", "secret123") + if err != nil { + t.Fatalf("bare username should succeed: %v", err) + } + if u.ID != u1.ID { + t.Fatalf("wrong user: %d != %d", u.ID, u1.ID) + } + // 裸用户名 + 错误密码 → 失败 + if _, err := us.AuthenticateLogin("alice", "wrong"); err == nil { + t.Fatal("wrong password should fail") + } + // 不存在 → 失败 + if _, err := us.AuthenticateLogin("nobody", "secret123"); err == nil { + t.Fatal("unknown user should fail") + } + // 完整邮箱仍然可用 + if _, err := us.AuthenticateLogin("alice@example.com", "secret123"); err != nil { + t.Fatalf("full email should succeed: %v", err) + } + + // 跨域名同名 → 歧义拒绝 + dom2 := &db.Domain{Name: "other.com"} + if err := ds.Create(dom2); err != nil { + t.Fatal(err) + } + u2 := &db.User{Username: "alice", DomainID: dom2.ID, PasswordHash: string(hashed), IsActive: true} + if err := us.Create(u2); err != nil { + t.Fatal(err) + } + if _, err := us.AuthenticateLogin("alice", "secret123"); err == nil { + t.Fatal("ambiguous bare username should fail") + } + // 歧义时完整邮箱仍可用 + if _, err := us.AuthenticateLogin("alice@example.com", "secret123"); err != nil { + t.Fatalf("full email should still work: %v", err) + } +} -- 2.47.3 From d9bb3cbf3f6cddcfa2679484f0821f690b9b9a8a Mon Sep 17 00:00:00 2001 From: dsh Date: Wed, 19 Aug 2026 10:35:50 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(store):=20=E9=82=AE=E4=BB=B6=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E7=BB=9F=E4=B8=80=20UTC=20=E5=AD=98=E5=82=A8=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B7=B7=E5=90=88=E6=97=B6=E5=8C=BA=E5=81=8F?= =?UTF-8?q?=E7=A7=BB=E5=AF=BC=E8=87=B4=20Web/IMAP=20=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E9=94=99=E4=B9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:date 列在 SQLite 中是文本,历史数据混存 +08:00/-04:00/+00:00 等时区偏移,ORDER BY date DESC 变成字符串字典序而非时间序: - Web 列表最新邮件排不到最前(如 22:21+08 的邮件排在 20:03+08 后面) - IMAP 序号与客户端按日期排序的视图不一致 修复: - mailStore.Create 统一 msg.Date = msg.Date.UTC()(所有写入路径 共用:SMTP/IMAP APPEND/Web/外发回执),新数据字典序即时间序 - 生产数据一次性迁移为 UTC 格式(130 条,Python 全量解析转换) - 验证:kevin INBOX 排序 172→170→166→164…(严格最新在前) --- internal/store/mail_store.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/store/mail_store.go b/internal/store/mail_store.go index 554ac74..68d310c 100644 --- a/internal/store/mail_store.go +++ b/internal/store/mail_store.go @@ -52,6 +52,13 @@ func newMailStore(database *gorm.DB) MailStore { // Create inserts a new message record. func (s *mailStoreGorm) Create(msg *db.Message) error { + // 日期统一为 UTC 存储:date 列在 SQLite 中是文本,混合时区偏移 + // (+08:00/-04:00 等)会让 ORDER BY date 变成错误的字典序(Web 列表 + // 排序错乱、IMAP 序号与客户端日期视图不一致)。统一 UTC 后字典序 + // 即时间序,所有排序路径(Web/IMAP/seqOf)全链路一致。 + if msg != nil { + msg.Date = msg.Date.UTC() + } return s.db.Create(msg).Error } -- 2.47.3