package imap_server import ( "reflect" "testing" "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/group" "git.pcux.cn/xuans/Smail/services/list" "git.pcux.cn/xuans/Smail/utils/context" "github.com/emersion/go-imap/v2" ) func TestAuditLargeSelectedMailboxMustBatchUIDQueries(t *testing.T) { user := models.User{Account: t.Name()} if _, err := db.Instance.Insert(&user); err != nil { t.Fatal(err) } message := models.Email{Subject: t.Name()} if _, err := db.Instance.Insert(&message); err != nil { t.Fatal(err) } const count = 10001 uids := make([]int, 0, count) deleted := make(map[int]bool) // 独立账户及共享邮件内容限制夹具体积,同时保留每个副本的真实 UID。 for start := 0; start < count; start += 400 { batch := make([]models.UserEmail, min(400, count-start)) for index := range batch { batch[index] = models.UserEmail{UserID: user.ID, EmailID: message.Id} if position := start + index; position == 0 || position == 500 || position == count-1 { batch[index].IsDeleted = 1 } } if _, err := db.Instance.Insert(&batch); err != nil { t.Fatal(err) } } relations, err := list.GetUEListByUID(&context.Context{UserID: user.ID}, "INBOX", 0, 0, nil) if err != nil || len(relations) != count { t.Fatalf("大邮箱夹具不完整:count=%d err=%v", len(relations), err) } for _, relation := range relations { uids = append(uids, relation.ID) if relation.IsDeleted == 1 { deleted[relation.ID] = true } } session := &serverSession{ctx: &context.Context{UserID: user.ID}, currentMailbox: "INBOX", mailboxUIDs: uids} for _, numbers := range []imap.NumSet{ imap.SeqSet{{Start: 1, Stop: 0}}, imap.UIDSet{{Start: imap.UID(uids[0]), Stop: 0}}, } { rows, err := session.selectedMessages(numbers) if err != nil || len(rows) != count { t.Fatalf("范围读取必须分批保留全部副本:count=%d err=%v", len(rows), err) } for index, row := range rows { if row.UeId != uids[index] || row.SerialNumber != index+1 { t.Fatalf("分批读取改变了 UID 或会话序号:index=%d uid=%d sequence=%d", index, row.UeId, row.SerialNumber) } } } if err := session.Expunge(nil, nil); err != nil { t.Fatalf("大邮箱 EXPUNGE 不能被查询参数上限拒绝:%v", err) } if len(session.mailboxUIDs) != count-len(deleted) { t.Fatalf("删除后快照数量错误:%d", len(session.mailboxUIDs)) } for _, uid := range session.mailboxUIDs { if deleted[uid] { t.Fatalf("已清除的 UID 仍留在会话快照:%d", uid) } } remaining, err := db.Instance.Where("user_id = ?", user.ID).Count(&models.UserEmail{}) if err != nil || remaining != int64(count-len(deleted)) { t.Fatalf("大邮箱只能清除标记邮件:remaining=%d err=%v", remaining, err) } } func TestAuditSnapshotSearchAndExpungeBoundaries(t *testing.T) { first, last := auditIMAPMessage(t), auditIMAPMessage(t) ctx := &context.Context{UserID: first.UserID} snapshot := []int{first.ID, last.ID} starUID := imap.UIDSet{{Start: 0, Stop: 0}} starSequence := imap.SeqSet{{Start: 0, Stop: 0}} for _, criteria := range []*imap.SearchCriteria{ {UID: []imap.UIDSet{starUID}}, {SeqNum: []imap.SeqSet{starSequence}}, } { rows, err := list.SearchEmails(ctx, "INBOX", criteria, snapshot) if err != nil || len(rows) != 1 || rows[0].ID != last.ID { t.Fatalf("通配应选择快照最后一封:rows=%v err=%v", rows, err) } } rows, err := list.SearchEmails(ctx, "INBOX", &imap.SearchCriteria{ UID: []imap.UIDSet{imap.UIDSetNum(imap.UID(first.ID))}, SeqNum: []imap.SeqSet{starSequence}, }, snapshot) if err != nil || len(rows) != 0 { t.Fatalf("UID 筛选不能缩小序号通配上界:rows=%v err=%v", rows, err) } session := &serverSession{ctx: ctx, currentMailbox: "INBOX", mailboxUIDs: append([]int{}, snapshot...)} if _, err := db.Instance.ID(last.ID).Delete(&models.UserEmail{}); err != nil { t.Fatal(err) } for _, criteria := range []*imap.SearchCriteria{ {UID: []imap.UIDSet{starUID}}, {SeqNum: []imap.SeqSet{starSequence}}, {Or: [][2]imap.SearchCriteria{{{UID: []imap.UIDSet{starUID}}, {SeqNum: []imap.SeqSet{starSequence}}}}}, } { rows, err := list.SearchEmails(ctx, "INBOX", criteria, snapshot) if err != nil || len(rows) != 0 { t.Fatalf("未通知删除不能把通配目标前移:rows=%v err=%v", rows, err) } } rows, err = list.SearchEmails(ctx, "INBOX", &imap.SearchCriteria{Not: []imap.SearchCriteria{{UID: []imap.UIDSet{starUID}}}}, snapshot) if err != nil || len(rows) != 1 || rows[0].ID != first.ID { t.Fatalf("NOT 必须使用同一快照:rows=%v err=%v", rows, err) } selected, err := session.selectedMessages(imap.SeqSetNum(2)) if err != nil || len(selected) != 0 { t.Fatalf("FETCH/STORE 共用目标解析不能改变已删除序号:rows=%v err=%v", selected, err) } if _, err := db.Instance.ID(first.ID).Cols("is_deleted").Update(&models.UserEmail{IsDeleted: 1}); err != nil { t.Fatal(err) } empty := imap.UIDSet{} for _, set := range []*imap.UIDSet{&empty, &starUID} { if err := session.Expunge(nil, set); err != nil { t.Fatal(err) } if found, err := db.Instance.ID(first.ID).Exist(&models.UserEmail{}); err != nil || !found { t.Fatalf("空集合或尾部通配不得删除其他邮件:found=%v err=%v", found, err) } } // 极大反向区间按快照过滤,不能按数值展开或错误拒绝。 wide := imap.UIDSet{{Start: imap.UID(^uint32(0)), Stop: 1}} if err := session.Expunge(nil, &wide); err != nil { t.Fatal(err) } if found, err := db.Instance.ID(first.ID).Exist(&models.UserEmail{}); err != nil || found { t.Fatalf("反向范围应删除匹配标记:found=%v err=%v", found, err) } if !reflect.DeepEqual(session.mailboxUIDs, []int{last.ID}) { t.Fatalf("只移除已经通知删除的序号:%v", session.mailboxUIDs) } } // 使用现有 TestMain 创建的临时 SQLite 和随机本地 TLS 端口,不连接开发服务。 func TestAuditCopyThenMoveMustKeepMailboxIdentity(t *testing.T) { var user models.User if found, err := db.Instance.Where("account=?", "testCase").Get(&user); err != nil || !found { t.Fatalf("临时测试账户不可用: found=%v err=%v", found, err) } message := models.Email{Subject: "audit-copy-move"} if _, err := db.Instance.Insert(&message); err != nil { t.Fatal(err) } relation := models.UserEmail{UserID: user.ID, EmailID: message.Id, Status: 0} if _, err := db.Instance.Insert(&relation); err != nil { t.Fatal(err) } if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil { t.Fatal(err) } uid := imap.UIDSetNum(imap.UID(relation.ID)) if _, err := clientLogin.Copy(uid, "Junk").Wait(); err != nil { t.Fatalf("复制前置条件失败: %v", err) } count, err := db.Instance.Where("user_id=? AND email_id=?", user.ID, message.Id).Count(&models.UserEmail{}) if err != nil || count != 2 { t.Fatalf("未生成预期副本: count=%d err=%v", count, err) } // 当前服务器未声明 MOVE 扩展,客户端会回退 COPY/STORE/EXPUNGE。 // 另外验证网页移动所用的服务,避免把回退成功误判为原生 MOVE 已通过。 webMoveErr := group.Move2DefaultBox(&context.Context{UserID: user.ID}, []int{message.Id}, "Deleted Messages") t.Logf("web_move_after_imap_copy_error=%v", webMoveErr) if webMoveErr != nil { t.Errorf("IMAP 复制后网页移动不应把自己的邮件判断为不属于自己: %v", webMoveErr) } // 网页移动已经移走原 UID;另建独立样本验证客户端回退,不能重复移动旧 UID。 moveRelation := auditIMAPMessage(t) if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil { t.Fatal(err) } _, err = clientLogin.Move(imap.UIDSetNum(imap.UID(moveRelation.ID)), "Deleted Messages").Wait() t.Logf("copy_relation_count=%d move_error=%v", count, err) if err != nil { t.Fatalf("同一用户复制邮件后,移动原文件夹中的 UID 不应失败: %v", err) } } func auditIMAPMessage(t *testing.T) models.UserEmail { t.Helper() var user models.User if found, err := db.Instance.Where("account=?", "testCase").Get(&user); err != nil || !found { t.Fatalf("测试账户不可用: found=%v err=%v", found, err) } message := models.Email{Subject: t.Name()} if _, err := db.Instance.Insert(&message); err != nil { t.Fatal(err) } relation := models.UserEmail{UserID: user.ID, EmailID: message.Id, Status: 0} if _, err := db.Instance.Insert(&relation); err != nil { t.Fatal(err) } return relation } func TestAuditExamineMustNotPersistStore(t *testing.T) { relation := auditIMAPMessage(t) if _, err := clientLogin.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil { t.Fatal(err) } err := clientLogin.Store(imap.UIDSetNum(imap.UID(relation.ID)), &imap.StoreFlags{ Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagSeen}, Silent: true, }, nil).Close() var updated models.UserEmail if found, lookupErr := db.Instance.ID(relation.ID).Get(&updated); lookupErr != nil || !found { t.Fatalf("读取测试邮件失败: found=%v err=%v", found, lookupErr) } t.Logf("readonly=true store_error=%v persisted_is_read=%d", err, updated.IsRead) if updated.IsRead != relation.IsRead { t.Fatal("EXAMINE 只读打开后,STORE 仍持久化修改已读状态") } } func TestAuditCopiedMessagesMustKeepDistinctUIDs(t *testing.T) { relation := auditIMAPMessage(t) if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil { t.Fatal(err) } if _, err := clientLogin.Copy(imap.UIDSetNum(imap.UID(relation.ID)), "INBOX").Wait(); err != nil { t.Fatal(err) } var relations []models.UserEmail if err := db.Instance.Where("user_id=? AND email_id=?", relation.UserID, relation.EmailID).Find(&relations); err != nil || len(relations) != 2 { t.Fatalf("复制前置条件失败: count=%d err=%v", len(relations), err) } for _, stored := range relations { t.Logf("stored_uid=%d status=%d group=%d", stored.ID, stored.Status, stored.GroupId) } rows, lookupErr := list.GetEmailListByGroup(&context.Context{UserID: relation.UserID}, "INBOX", list.ImapListReq{ UidList: []int{relations[0].ID, relations[1].ID}, }, true) if lookupErr != nil || len(rows) != 2 { t.Fatalf("直接查询副本失败: count=%d err=%v", len(rows), lookupErr) } t.Logf("hydrated_uids=%d,%d same_pointer=%v", rows[0].UeId, rows[1].UeId, rows[0] == rows[1]) if rows[0] == rows[1] || rows[0].UeId == rows[1].UeId { t.Error("不同邮箱关系复用了同一个可变响应对象,UID 被后一个副本覆盖") } uids := imap.UIDSetNum(imap.UID(relations[0].ID), imap.UID(relations[1].ID)) messages, err := clientLogin.Fetch(uids, &imap.FetchOptions{UID: true, Flags: true}).Collect() if err != nil || len(messages) != 2 { t.Fatalf("读取两个副本失败: count=%d err=%v", len(messages), err) } t.Logf("stored_uids=%d,%d fetched_uids=%d,%d", relations[0].ID, relations[1].ID, messages[0].UID, messages[1].UID) if messages[0].UID == messages[1].UID { t.Fatal("不同副本在实际 IMAP FETCH 响应中使用了同一个 UID") } } func TestAuditListPercentMustIncludeInbox(t *testing.T) { mailboxes, err := clientLogin.List("", "%", nil).Collect() if err != nil { t.Fatal(err) } names := make([]string, 0, len(mailboxes)) found := false for _, mailbox := range mailboxes { names = append(names, mailbox.Mailbox) found = found || mailbox.Mailbox == "INBOX" } t.Logf("top_level_mailboxes=%v", names) if !found { t.Fatal("LIST 顶层通配符遗漏 INBOX 和默认文件夹") } } func TestAuditUIDNextMustNotDecrease(t *testing.T) { relation := auditIMAPMessage(t) before, err := clientLogin.Status("INBOX", &imap.StatusOptions{UIDNext: true, UIDValidity: true}).Wait() if err != nil { t.Fatal(err) } if err := group.Move2DefaultBox(&context.Context{UserID: relation.UserID}, []int{relation.EmailID}, "Junk"); err != nil { t.Fatal(err) } after, err := clientLogin.Status("INBOX", &imap.StatusOptions{UIDNext: true, UIDValidity: true}).Wait() if err != nil { t.Fatal(err) } t.Logf("uidnext_before=%d uidnext_after=%d validity_before=%d validity_after=%d", before.UIDNext, after.UIDNext, before.UIDValidity, after.UIDValidity) if after.UIDValidity == before.UIDValidity && after.UIDNext < before.UIDNext { t.Fatal("同一 UIDVALIDITY 下,移走最大 UID 邮件导致 UIDNEXT 倒退") } } func TestAuditFetchWildcardUIDMustReturnLastMessage(t *testing.T) { relation := auditIMAPMessage(t) if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil { t.Fatal(err) } messages, err := clientLogin.Fetch(imap.UIDSetNum(0), &imap.FetchOptions{UID: true}).Collect() if err != nil { t.Fatal(err) } // go-imap/v2 beta.5 的动态 UID 集合 Contains(*) 固定返回 false, // 因而客户端会丢弃合法的动态 UID 响应;静态 UID 用于验证响应解析本身。 if len(messages) != 0 { t.Fatalf("动态 UID 客户端结果异常: count=%d", len(messages)) } staticMessages, err := clientLogin.Fetch(imap.UIDSetNum(imap.UID(relation.ID)), &imap.FetchOptions{UID: true}).Collect() if err != nil || len(staticMessages) != 1 || staticMessages[0].UID != imap.UID(relation.ID) { t.Fatalf("静态 UID FETCH 未正确解析服务端响应: count=%d err=%v", len(staticMessages), err) } } func TestAuditInboxCountMustExcludeLoginBindings(t *testing.T) { relation := auditIMAPMessage(t) bindings, err := db.Instance.Where("user_id=? AND email_id=0 AND group_id=0 AND status=0", relation.UserID).Count(&models.UserEmail{}) if err != nil || bindings < 1 { t.Fatalf("临时登录邮箱绑定前置条件未成立: count=%d err=%v", bindings, err) } want, err := db.Instance.Where("user_id=? AND email_id>0 AND group_id=0 AND status=0", relation.UserID).Count(&models.UserEmail{}) if err != nil { t.Fatal(err) } status, err := clientLogin.Status("INBOX", &imap.StatusOptions{NumMessages: true}).Wait() if err != nil || status.NumMessages == nil { t.Fatalf("查询邮箱数量失败: status=%v err=%v", status, err) } t.Logf("login_bindings=%d real_messages=%d status_messages=%d", bindings, want, *status.NumMessages) if int64(*status.NumMessages) != want { t.Fatal("登录邮箱绑定被当作邮件计入 INBOX,状态数量与可读取邮件不一致") } } func TestAuditSearchKeysMustIntersect(t *testing.T) { first := auditIMAPMessage(t) second := auditIMAPMessage(t) third := auditIMAPMessage(t) if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil { t.Fatal(err) } uidCriterion := func(ids ...int) imap.SearchCriteria { var set imap.UIDSet for _, id := range ids { set.AddNum(imap.UID(id)) } return imap.SearchCriteria{UID: []imap.UIDSet{set}} } cases := map[string]imap.SearchCriteria{ "multiple_or": {Or: [][2]imap.SearchCriteria{ {uidCriterion(first.ID), uidCriterion(second.ID)}, {uidCriterion(second.ID), uidCriterion(third.ID)}, }}, "multiple_uid": {UID: []imap.UIDSet{ imap.UIDSetNum(imap.UID(first.ID), imap.UID(second.ID)), imap.UIDSetNum(imap.UID(second.ID), imap.UID(third.ID)), }}, } for name, criteria := range cases { t.Run(name, func(t *testing.T) { result, err := clientLogin.UIDSearch(&criteria, nil).Wait() if err != nil { t.Fatal(err) } uids := result.AllUIDs() t.Logf("returned=%v expected_only=%d", uids, second.ID) if len(uids) != 1 || uids[0] != imap.UID(second.ID) { t.Fatal("同一个 SEARCH 的多个键应取交集,而不是并集") } }) } } func TestAuditSearchDeletedMustMatchStoredFlag(t *testing.T) { relation := auditIMAPMessage(t) if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil { t.Fatal(err) } uid := imap.UIDSetNum(imap.UID(relation.ID)) if err := clientLogin.Store(uid, &imap.StoreFlags{ Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted}, Silent: true, }, nil).Close(); err != nil { t.Fatal(err) } messages, err := clientLogin.Fetch(uid, &imap.FetchOptions{Flags: true, UID: true}).Collect() if err != nil || len(messages) != 1 { t.Fatalf("读取标记前置条件失败: count=%d err=%v", len(messages), err) } deleted := false for _, flag := range messages[0].Flags { deleted = deleted || flag == imap.FlagDeleted } if !deleted { t.Fatal("STORE 后 FETCH 没有返回删除标记,不能继续归因 SEARCH") } result, err := clientLogin.UIDSearch(&imap.SearchCriteria{ UID: []imap.UIDSet{uid}, Flag: []imap.Flag{imap.FlagDeleted}, }, nil).Wait() if err != nil { t.Fatal(err) } t.Logf("fetch_deleted=%t search_uids=%v expected_uid=%d", deleted, result.AllUIDs(), relation.ID) if len(result.AllUIDs()) != 1 || result.AllUIDs()[0] != imap.UID(relation.ID) { t.Fatal("已通过 STORE 设置且 FETCH 确认的删除标记未被 SEARCH 识别") } }