From 17d8dc15679a8d93c05ceaf166ea61e26069262c Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 20 Aug 2026 15:18:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(imap):=20=E5=BA=8F=E5=8F=B7=E6=8C=89?= =?UTF-8?q?=E5=88=B0=E8=BE=BE=E9=A1=BA=E5=BA=8F(id=20ASC)=E5=88=86?= =?UTF-8?q?=E9=85=8D,INTERNALDATE=20=E4=BD=BF=E7=94=A8=E5=88=B0=E8=BE=BE?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/imap_server/integration_test.go | 61 ++++++++++++++++++------ internal/imap_server/session.go | 8 +++- internal/store/mail_store.go | 21 ++++---- 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/internal/imap_server/integration_test.go b/internal/imap_server/integration_test.go index ee727cc..aa607f8 100644 --- a/internal/imap_server/integration_test.go +++ b/internal/imap_server/integration_test.go @@ -159,27 +159,58 @@ func TestSeqStoreServerIssued(t *testing.T) { assertReadState(t, stores, ids[2], true) } -// TestSeqStoreClientSelfNumbered 复现风险场景:客户端不信任服务器序号, -// 按自己的视图(日期倒序,最新在前)自行编号后发 seq 式 STORE。 -// 服务器规范排序必须与常见客户端视图一致(date DESC, id DESC), -// 否则会把另一封邮件标为已读、目标邮件永远未读。 -func TestSeqStoreClientSelfNumbered(t *testing.T) { +// TestSeqOrderArrival 验证序号按到达顺序(id ASC,最早 = seq 1)分配: +// 与主流服务器行为一致——新邮件永远追加到末尾(seq = 新 EXISTS 数), +// 既不位移既有邮件序号,也能被 seq 增量同步(seq 4)正确获取; +// INTERNALDATE 返回到达时间(CreatedAt)而非 Date 头。 +func TestSeqOrderArrival(t *testing.T) { stores, addr := startIntegrationServer(t) - ids := seedMailbox(t, stores, 1, 3) // 3 封,日期递增,最新的是 ids[2] + ids := seedMailbox(t, stores, 1, 3) c := loginAndSelect(t, addr) - // 客户端按日期倒序视图:最新一封 = seq 1 - cmd := c.Store(imap.SeqSetNum(1), &imap.StoreFlags{ - Op: imap.StoreFlagsAdd, - Flags: []imap.Flag{imap.FlagSeen}, - }, nil) - if _, err := cmd.Collect(); err != nil { - t.Fatalf("store: %v", err) + msgs, err := c.Fetch(imap.SeqSetNum(1, 2, 3), &imap.FetchOptions{UID: true}).Collect() + if err != nil { + t.Fatalf("fetch: %v", err) + } + seqOf := map[imap.UID]uint32{} + for _, m := range msgs { + 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])为已读 - assertReadState(t, stores, ids[2], true) + // 新邮件到达(Date 头较旧):仍追加到末尾,不移位既有邮件 + 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 编码的 diff --git a/internal/imap_server/session.go b/internal/imap_server/session.go index 802d650..a72faf4 100644 --- a/internal/imap_server/session.go +++ b/internal/imap_server/session.go @@ -497,7 +497,13 @@ func (s *imapSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, optio fw.WriteRFC822Size(int64(len(raw))) } 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 { var env *imap.Envelope diff --git a/internal/store/mail_store.go b/internal/store/mail_store.go index fd5f411..a1fdf04 100644 --- a/internal/store/mail_store.go +++ b/internal/store/mail_store.go @@ -36,7 +36,7 @@ type MailStore interface { // SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。 SetDeletedStates(ids []uint, deleted bool) error // ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件 - // (按 date DESC, id DESC 排序,与全量列表一致,序号映射全链路相同)。 + // (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。 ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) // DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。 DeleteMany(ids []uint) error @@ -93,7 +93,7 @@ func (s *mailStoreGorm) ListByUserAndFolder(userID uint, folder string, page, si } 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 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 } -// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件。 +// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件 +// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。 func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) { var messages []db.Message 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 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. -// 按 date DESC, id DESC 排序(最新在前):与主流邮件客户端(Thunderbird、 -// 手机客户端等)默认视图一致,客户端自行按日期编号的 seq 式 STORE 不会 -// 错位标错邮件。所有 IMAP 序号相关路径(Status/ListMessages/推送/seqOf) -// 共用本排序,保证序号全链路一致。 +// 按 id ASC(到达顺序,最早在前)排序:新邮件永远获得最大序号(seq = +// EXISTS 数),与主流服务器(Dovecot/Courier)行为一致,依赖「新邮件 = +// 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) { var messages []db.Message 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 messages, nil