fix(imap): 序号按到达顺序(id ASC)分配,INTERNALDATE 使用到达时间
- ListAllByUserAndFolder 由 date DESC 改为 id ASC:新邮件永远追加到末尾 (seq = EXISTS 数),与 Dovecot/Courier 一致;带旧 Date 头的新邮件不再 插入中间导致既有邮件序号位移,seq 增量同步客户端不会漏收/标错 - INTERNALDATE 改用 CreatedAt(服务器接收时间, RFC 3501 §2.3.4), 零值降级用 Date 头;不再返回可被伪造的 Date 头 - Web 分页排序补 id DESC tiebreak,与 IMAP 全链路一致 - ListDeletedByUserAndFolder 同步改为 id ASC - 测试:TestSeqStoreClientSelfNumbered 替换为 TestSeqOrderArrival, 断言到达顺序序号映射、旧 Date 头新邮件落在末尾、INTERNALDATE=CreatedAt
This commit is contained in:
@@ -159,27 +159,58 @@ func TestSeqStoreServerIssued(t *testing.T) {
|
|||||||
assertReadState(t, stores, ids[2], true)
|
assertReadState(t, stores, ids[2], true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSeqStoreClientSelfNumbered 复现风险场景:客户端不信任服务器序号,
|
// TestSeqOrderArrival 验证序号按到达顺序(id ASC,最早 = seq 1)分配:
|
||||||
// 按自己的视图(日期倒序,最新在前)自行编号后发 seq 式 STORE。
|
// 与主流服务器行为一致——新邮件永远追加到末尾(seq = 新 EXISTS 数),
|
||||||
// 服务器规范排序必须与常见客户端视图一致(date DESC, id DESC),
|
// 既不位移既有邮件序号,也能被 seq 增量同步(seq 4)正确获取;
|
||||||
// 否则会把另一封邮件标为已读、目标邮件永远未读。
|
// INTERNALDATE 返回到达时间(CreatedAt)而非 Date 头。
|
||||||
func TestSeqStoreClientSelfNumbered(t *testing.T) {
|
func TestSeqOrderArrival(t *testing.T) {
|
||||||
stores, addr := startIntegrationServer(t)
|
stores, addr := startIntegrationServer(t)
|
||||||
ids := seedMailbox(t, stores, 1, 3) // 3 封,日期递增,最新的是 ids[2]
|
ids := seedMailbox(t, stores, 1, 3)
|
||||||
|
|
||||||
c := loginAndSelect(t, addr)
|
c := loginAndSelect(t, addr)
|
||||||
|
|
||||||
// 客户端按日期倒序视图:最新一封 = seq 1
|
msgs, err := c.Fetch(imap.SeqSetNum(1, 2, 3), &imap.FetchOptions{UID: true}).Collect()
|
||||||
cmd := c.Store(imap.SeqSetNum(1), &imap.StoreFlags{
|
if err != nil {
|
||||||
Op: imap.StoreFlagsAdd,
|
t.Fatalf("fetch: %v", err)
|
||||||
Flags: []imap.Flag{imap.FlagSeen},
|
}
|
||||||
}, nil)
|
seqOf := map[imap.UID]uint32{}
|
||||||
if _, err := cmd.Collect(); err != nil {
|
for _, m := range msgs {
|
||||||
t.Fatalf("store: %v", err)
|
seqOf[m.UID] = m.SeqNum
|
||||||
|
}
|
||||||
|
if seqOf[imap.UID(ids[0])] != 1 || seqOf[imap.UID(ids[1])] != 2 || seqOf[imap.UID(ids[2])] != 3 {
|
||||||
|
t.Fatalf("seq mapping = %v, want ids[0]=1 ids[1]=2 ids[2]=3", seqOf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端意图是标记最新一封(ids[2])为已读
|
// 新邮件到达(Date 头较旧):仍追加到末尾,不移位既有邮件
|
||||||
assertReadState(t, stores, ids[2], true)
|
late := &db.Message{
|
||||||
|
UserID: 1,
|
||||||
|
Folder: "INBOX",
|
||||||
|
FromAddr: "x@y",
|
||||||
|
ToAddr: "alice@example.com",
|
||||||
|
Subject: "late",
|
||||||
|
Date: time.Now().Add(-24 * time.Hour),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := stores.Mails.Create(late); err != nil {
|
||||||
|
t.Fatalf("create late message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err = c.Fetch(imap.SeqSetNum(4), &imap.FetchOptions{UID: true, InternalDate: true}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch seq 4: %v", err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 1 || msgs[0].UID != imap.UID(late.ID) {
|
||||||
|
t.Fatalf("seq 4 = %v, want 新邮件 uid=%d", msgs, late.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// INTERNALDATE = 到达时间(CreatedAt),而不是 Date 头(协议格式仅到秒)
|
||||||
|
stored, err := stores.Mails.GetByID(late.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get late msg: %v", err)
|
||||||
|
}
|
||||||
|
if !msgs[0].InternalDate.Equal(stored.CreatedAt.Truncate(time.Second)) {
|
||||||
|
t.Fatalf("internaldate = %v, want CreatedAt %v", msgs[0].InternalDate, stored.CreatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
|
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
|
||||||
|
|||||||
@@ -497,7 +497,13 @@ func (s *imapSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, optio
|
|||||||
fw.WriteRFC822Size(int64(len(raw)))
|
fw.WriteRFC822Size(int64(len(raw)))
|
||||||
}
|
}
|
||||||
if options.InternalDate {
|
if options.InternalDate {
|
||||||
fw.WriteInternalDate(msg.Date)
|
// INTERNALDATE 是服务器接收时间(RFC 3501 §2.3.4),不是 Date
|
||||||
|
// 头;使用 CreatedAt(到达时间),旧数据为零时降级为 Date。
|
||||||
|
arrival := msg.CreatedAt
|
||||||
|
if arrival.IsZero() {
|
||||||
|
arrival = msg.Date
|
||||||
|
}
|
||||||
|
fw.WriteInternalDate(arrival)
|
||||||
}
|
}
|
||||||
if options.Envelope {
|
if options.Envelope {
|
||||||
var env *imap.Envelope
|
var env *imap.Envelope
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type MailStore interface {
|
|||||||
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。
|
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。
|
||||||
SetDeletedStates(ids []uint, deleted bool) error
|
SetDeletedStates(ids []uint, deleted bool) error
|
||||||
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
|
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
|
||||||
// (按 date DESC, id DESC 排序,与全量列表一致,序号映射全链路相同)。
|
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
|
||||||
ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error)
|
ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error)
|
||||||
// DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。
|
// DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。
|
||||||
DeleteMany(ids []uint) error
|
DeleteMany(ids []uint) error
|
||||||
@@ -93,7 +93,7 @@ func (s *mailStoreGorm) ListByUserAndFolder(userID uint, folder string, page, si
|
|||||||
}
|
}
|
||||||
|
|
||||||
offset := (page - 1) * size
|
offset := (page - 1) * size
|
||||||
if err := query.Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
|
if err := query.Order("date DESC, id DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return messages, total, nil
|
return messages, total, nil
|
||||||
@@ -145,11 +145,12 @@ func (s *mailStoreGorm) SetDeletedStates(ids []uint, deleted bool) error {
|
|||||||
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_deleted", deleted).Error
|
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_deleted", deleted).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件。
|
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
|
||||||
|
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
|
||||||
func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
||||||
var messages []db.Message
|
var messages []db.Message
|
||||||
if err := s.db.Where("user_id = ? AND folder = ? AND is_deleted = ?", userID, folder, true).
|
if err := s.db.Where("user_id = ? AND folder = ? AND is_deleted = ?", userID, folder, true).
|
||||||
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
|
Order("id ASC").Find(&messages).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return messages, nil
|
return messages, nil
|
||||||
@@ -180,14 +181,16 @@ func (s *mailStoreGorm) CountUnread(userID uint, folder string) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination.
|
// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination.
|
||||||
// 按 date DESC, id DESC 排序(最新在前):与主流邮件客户端(Thunderbird、
|
// 按 id ASC(到达顺序,最早在前)排序:新邮件永远获得最大序号(seq =
|
||||||
// 手机客户端等)默认视图一致,客户端自行按日期编号的 seq 式 STORE 不会
|
// EXISTS 数),与主流服务器(Dovecot/Courier)行为一致,依赖「新邮件 =
|
||||||
// 错位标错邮件。所有 IMAP 序号相关路径(Status/ListMessages/推送/seqOf)
|
// seq N+1」做增量同步的客户端不会漏收或标错邮件;新邮件到达不会使既有
|
||||||
// 共用本排序,保证序号全链路一致。
|
// 邮件序号位移(只有 EXPUNGE 才会,属正常行为)。所有 IMAP 序号相关路径
|
||||||
|
// (Status/Fetch/Search/Store/Copy/Move/Expunge/推送/seqOf)共用本排序,
|
||||||
|
// 保证序号全链路一致。INTERNALDATE 使用 CreatedAt(到达时间),与排序一致。
|
||||||
func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
||||||
var messages []db.Message
|
var messages []db.Message
|
||||||
if err := s.db.Where("user_id = ? AND folder = ?", userID, folder).
|
if err := s.db.Where("user_id = ? AND folder = ?", userID, folder).
|
||||||
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
|
Order("id ASC").Find(&messages).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return messages, nil
|
return messages, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user