Files
mailgo/internal/imap_server/integration_test.go
T
kevin 962c5f454c fix(imap): 升级 go-imap v2 + \Deleted 落库,修复删除邮件只复制到垃圾桶
删除流程(Apple Mail/iOS 等):UID COPY→Trash + UID STORE \Deleted +
UID EXPUNGE。此前 go-imap v1.2.1 不支持 UID EXPUNGE(回
Command unsupported with UID),且 \Deleted 只存内存会话 map(重选/
重连即丢失),导致 EXPUNGE 不删原邮件——垃圾桶只有副本、INBOX 原封
不动。

- 依赖:go-imap v1.2.1 → go-imap/v2 v2.0.0-beta.8(原生支持 UID
  EXPUNGE,且库为 race-clean,移除集成测试 !race 标签)
- Message 新增 IsDeleted 字段持久化 \Deleted 标记(AutoMigrate 自动
  加列,SQLite/MySQL 通用;migrate 工具随模型字段自动复制)
- IMAP 层重写为 v2 imapserver.Session 架构(session.go 替代
  backend.go):SELECT/STATUS/LIST/FETCH/SEARCH/STORE/COPY/MOVE/
  APPEND/EXPUNGE/Poll/Idle;EXPUNGE 按数据库 IsDeleted 删除,UID
  EXPUNGE 只删集合内已标记消息
- server.go:显式能力集(IMAP4rev1+UIDPLUS+MOVE+LITERAL++CHILDREN+
  SPECIAL-USE);自实现 mailboxHub 跨会话推送(含来源排除,避免 v2
  库 MailboxTracker 对 EXPUNGE/EXISTS 的回声重复响应);Pusher 接口
  签名不变,Web/SMTP/POP3 调用方零改动;DisconnectByAddr 改用会话
  注册表 + conn.Bye()
- 测试:集成测试改用 v2 imapclient,新增删除流程/跨会话持久化/MOVE
  回归用例;notify_test 适配 hub 推送模型并覆盖来源排除与
  allowExpunge 语义
2026-08-20 00:44:37 +08:00

380 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 集成测试:启动真实 IMAP 监听 + 脚本客户端(go-imap v2 imapclient)。
// v2 库为 race-clean(此前 v1.2.1 库内数据竞争导致本文件必须带
// !race 构建标签,升级后已移除)。推送逻辑的单元覆盖由 notify_test.go 承担。
package imap_server
import (
"net"
"path/filepath"
"testing"
"time"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/store"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// startIntegrationServer 启动一个真实的 IMAP 监听(随机端口)供客户端测试。
func startIntegrationServer(t *testing.T) (*store.Stores, string) {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.ProtocolLog{}, &db.BanEntry{}, &db.MailboxState{}); err != nil {
t.Fatalf("migrate: %v", err)
}
stores := store.NewStores(gdb)
domain := &db.Domain{Name: "example.com"}
if err := stores.Domains.Create(domain); err != nil {
t.Fatalf("create domain: %v", err)
}
hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
user := &db.User{Username: "alice", DomainID: domain.ID, PasswordHash: string(hashed), IsActive: true}
if err := stores.Users.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New())
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
imapSrv := srv.newServer(ln.Addr().String(), nil)
go imapSrv.Serve(ln)
return stores, ln.Addr().String()
}
// seedMailbox 创建 n 封按时间递增的邮件(id 与 date 顺序一致时 id ASC == date ASC)。
func seedMailbox(t *testing.T, stores *store.Stores, userID uint, n int) []uint {
t.Helper()
ids := make([]uint, 0, n)
base := time.Now().Add(-time.Duration(n) * time.Hour)
for i := 0; i < n; i++ {
msg := &db.Message{
UserID: userID,
Folder: "INBOX",
FromAddr: "x@y",
ToAddr: "alice@example.com",
Subject: "m",
Date: base.Add(time.Duration(i) * time.Hour), // 时间递增:id 越大日期越新
CreatedAt: time.Now(),
}
if err := stores.Mails.Create(msg); err != nil {
t.Fatalf("create message: %v", err)
}
ids = append(ids, msg.ID)
}
return ids
}
func loginAndSelect(t *testing.T, addr string) *imapclient.Client {
t.Helper()
c, err := imapclient.DialInsecure(addr, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { c.Logout().Wait() })
if err := c.Login("alice@example.com", "secret123").Wait(); err != nil {
t.Fatalf("login: %v", err)
}
if _, err := c.Select("INBOX", nil).Wait(); err != nil {
t.Fatalf("select: %v", err)
}
return c
}
func assertReadState(t *testing.T, stores *store.Stores, msgID uint, want bool) {
t.Helper()
msg, err := stores.Mails.GetByID(msgID)
if err != nil {
t.Fatalf("get msg: %v", err)
}
if msg.IsRead != want {
t.Fatalf("msg %d IsRead = %v, want %v", msgID, msg.IsRead, want)
}
}
// TestUidStorePersists 验证 UID STORE +FLAGS(\Seen) 持久化(RFC 标准流程)。
func TestUidStorePersists(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3)
c := loginAndSelect(t, addr)
cmd := c.Store(imap.UIDSetNum(imap.UID(ids[1])), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
}, nil)
if _, err := cmd.Collect(); err != nil {
t.Fatalf("uid store: %v", err)
}
assertReadState(t, stores, ids[1], true)
assertReadState(t, stores, ids[0], false)
assertReadState(t, stores, ids[2], false)
}
// TestSeqStoreServerIssued 验证客户端用服务器下发的序号(FETCH 结果)做
// seq 式 STORE:任何排序下都应正确持久化。
func TestSeqStoreServerIssued(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3)
c := loginAndSelect(t, addr)
// 拉取全部消息,找到 ids[2](最新一封)的服务器序号
msgs, err := c.Fetch(imap.SeqSetNum(1, 2, 3), &imap.FetchOptions{UID: true}).Collect()
if err != nil {
t.Fatalf("fetch: %v", err)
}
var targetSeq uint32
for _, m := range msgs {
if m.UID == imap.UID(ids[2]) {
targetSeq = m.SeqNum
}
}
if targetSeq == 0 {
t.Fatal("target message not found in fetch")
}
cmd := c.Store(imap.SeqSetNum(targetSeq), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
}, nil)
if _, err := cmd.Collect(); err != nil {
t.Fatalf("store: %v", err)
}
assertReadState(t, stores, ids[2], true)
}
// TestSeqStoreClientSelfNumbered 复现风险场景:客户端不信任服务器序号,
// 按自己的视图(日期倒序,最新在前)自行编号后发 seq 式 STORE。
// 服务器规范排序必须与常见客户端视图一致(date DESC, id DESC),
// 否则会把另一封邮件标为已读、目标邮件永远未读。
func TestSeqStoreClientSelfNumbered(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3) // 3 封,日期递增,最新的是 ids[2]
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)
}
// 客户端意图是标记最新一封(ids[2])为已读
assertReadState(t, stores, ids[2], true)
}
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
// message/rfc822 附件 / 截断的 multipart)时,FETCH BODY/BODYSTRUCTURE
// 不得 panicv2 的 ExtractBodyStructure 对畸形 MIME 返回降级结构,
// WriteBodyStructure 要求 Extended 非 nil,两者都需满足)。
func TestFetchBodyMalformedMIME(t *testing.T) {
stores, addr := startIntegrationServer(t)
// 1) base64 编码的 message/rfc822 附件(转发邮件场景)
rfc822Body := "UmVjZWl2ZWQ6IGZyb20gb3V0Ym91bmQuY2kuaWNsb3VkLmNvbSAodW5rbm93biBbMTI3LjAuMC4yKVxuXHQgYnkgcDAwLWljbG91ZG10YS1hc210cC11cy1jZW50cmFsLTFrLTEwMC1wZXJjZW50LTggKFBvc3RmaXgpIHdpdGggRVNNVFBTIGlkIDIxRTlBMThDQURDRjM4MlxuXHQgZm9yIDxkc2hAbG12ZS5uZXQ+OyBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwIChVVEMpXG5YLUlDTC1SZXBJZDogRURWY1BlQ3RlWG4tZ0Z1T0xxUWhfSjZvcE9fN1B2OEtsOW1mMDg2VUFxZ29zXG5EYXRlOiBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwXG5Gcm9tOiBkYXZpZEB5YW5kZXguY29tXG5UbzogZHNoQGxtdmUubmV0XG5NZXNzYWdlLUlEOiA8QTIxNzBEMTEtMkI1MC00MTQwLTlEQTMtMkI3M0U2RUIwQTc4QHlhbmRleC5jb20+XG5TdWJqZWN0OiB0ZXN0XG5cbmhlbGxvXG4="
msgWithRFC822 := &db.Message{
UserID: 1,
Folder: "INBOX",
FromAddr: "alice@example.com",
ToAddr: "alice@example.com",
Subject: "fwd",
Date: time.Now().Add(-2 * time.Hour),
RawData: "From: alice@example.com\r\n" +
"To: alice@example.com\r\n" +
"Subject: fwd\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"==fwd==\"\r\n\r\n" +
"--==fwd==\r\n" +
"Content-Type: text/plain; charset=\"utf-8\"\r\n" +
"Content-Transfer-Encoding: 8bit\r\n\r\n" +
"正文\r\n\r\n" +
"--==fwd==\r\n" +
"Content-Type: message/rfc822\r\n" +
"Content-Transfer-Encoding: base64\r\n" +
"Content-Disposition: attachment; filename=\"original.eml\"\r\n" +
"MIME-Version: 1.0\r\n\r\n" +
rfc822Body + "\r\n" +
"--==fwd==--\r\n",
}
// 2) 截断的 multipart(缺少结束边界)
msgTruncated := &db.Message{
UserID: 1,
Folder: "INBOX",
FromAddr: "alice@example.com",
ToAddr: "alice@example.com",
Subject: "truncated",
Date: time.Now().Add(-1 * time.Hour),
RawData: "From: alice@example.com\r\n" +
"To: alice@example.com\r\n" +
"Subject: truncated\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/alternative; boundary=\"==trunc==\"\r\n\r\n" +
"--==trunc==\r\n" +
"Content-Type: text/plain\r\n\r\n" +
"hello\r\n",
// 无结束边界
}
if err := stores.Mails.Create(msgWithRFC822); err != nil {
t.Fatalf("create msg: %v", err)
}
if err := stores.Mails.Create(msgTruncated); err != nil {
t.Fatalf("create msg: %v", err)
}
c := loginAndSelect(t, addr)
seqSet := imap.SeqSetNum(1, 2)
// BODY(非扩展)
msgs, err := c.Fetch(seqSet, &imap.FetchOptions{BodyStructure: &imap.FetchItemBodyStructure{}}).Collect()
if err != nil {
t.Fatalf("fetch body: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("FETCH BODY 返回 %d/2 封", len(msgs))
}
// BODYSTRUCTURE(扩展)
msgs2, err := c.Fetch(seqSet, &imap.FetchOptions{BodyStructure: &imap.FetchItemBodyStructure{Extended: true}}).Collect()
if err != nil {
t.Fatalf("fetch bodystructure: %v", err)
}
if len(msgs2) != 2 {
t.Fatalf("FETCH BODYSTRUCTURE 返回 %d/2 封", len(msgs2))
}
}
// TestUidExpungeDeleteFlow 核心回归:模拟 Apple Mail / iOS Mail 的删除流程
// UID COPY → Trash + UID STORE \Deleted + UID EXPUNGE)。修复前:
// \Deleted 只存内存会话、UID EXPUNGE 不被 go-imap v1 支持 → 原邮件留在
// INBOX,垃圾桶只有副本。修复后:原邮件被真正删除,Trash 留有一份副本。
func TestUidExpungeDeleteFlow(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3)
uid := imap.UID(ids[1])
c := loginAndSelect(t, addr)
// 1) UID COPY → Trash
if _, err := c.Copy(imap.UIDSetNum(uid), "Trash").Wait(); err != nil {
t.Fatalf("uid copy: %v", err)
}
// 2) UID STORE +FLAGS.SILENT (\Deleted)
if _, err := c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Silent: true,
Flags: []imap.Flag{imap.FlagDeleted},
}, nil).Collect(); err != nil {
t.Fatalf("uid store deleted: %v", err)
}
// 3) UID EXPUNGE
seqs, err := c.UIDExpunge(imap.UIDSetNum(uid)).Collect()
if err != nil {
t.Fatalf("uid expunge: %v", err)
}
if len(seqs) != 1 {
t.Fatalf("uid expunge seqs = %v, want 1 条", seqs)
}
// 原邮件必须被真正删除
if _, err := stores.Mails.GetByID(ids[1]); err == nil {
t.Fatal("原邮件仍在数据库,UID EXPUNGE 未生效")
}
// 其余邮件保留
assertReadState(t, stores, ids[0], false)
assertReadState(t, stores, ids[2], false)
// Trash 中有一份副本
trashCount, err := stores.Mails.CountByUserAndFolder(1, "Trash")
if err != nil || trashCount != 1 {
t.Fatalf("Trash count = %d, want 1", trashCount)
}
}
// TestDeletedPersistsAcrossSessions 验证 \Deleted 落库:标记后断开重连
// (模拟客户端切换文件夹/重连),普通 EXPUNGE 仍能删除。修复前标记只存
// 内存会话对象,重连后 EXPUNGE 什么都不删。
func TestDeletedPersistsAcrossSessions(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 3)
uid := imap.UID(ids[1])
c := loginAndSelect(t, addr)
if _, err := c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Silent: true,
Flags: []imap.Flag{imap.FlagDeleted},
}, nil).Collect(); err != nil {
t.Fatalf("uid store deleted: %v", err)
}
if err := c.Logout().Wait(); err != nil {
t.Fatalf("logout: %v", err)
}
// 重新连接(新的会话对象,此前标记必须仍在库中)
c2, err := imapclient.DialInsecure(addr, nil)
if err != nil {
t.Fatalf("dial2: %v", err)
}
t.Cleanup(func() { c2.Logout().Wait() })
if err := c2.Login("alice@example.com", "secret123").Wait(); err != nil {
t.Fatalf("login2: %v", err)
}
if _, err := c2.Select("INBOX", nil).Wait(); err != nil {
t.Fatalf("select2: %v", err)
}
seqs, err := c2.Expunge().Collect()
if err != nil {
t.Fatalf("expunge: %v", err)
}
if len(seqs) != 1 {
t.Fatalf("expunge seqs = %v, want 1 条", seqs)
}
if _, err := stores.Mails.GetByID(ids[1]); err == nil {
t.Fatal("标记 \\Deleted 的邮件在重连后未被 EXPUNGE 删除")
}
}
// TestUidMoveFlow 验证 UID MOVE:目标文件夹出现该邮件且源文件夹删除。
func TestUidMoveFlow(t *testing.T) {
stores, addr := startIntegrationServer(t)
ids := seedMailbox(t, stores, 1, 2)
uid := imap.UID(ids[1])
c := loginAndSelect(t, addr)
if _, err := c.Move(imap.UIDSetNum(uid), "Trash").Wait(); err != nil {
t.Fatalf("uid move: %v", err)
}
if _, err := stores.Mails.GetByID(ids[1]); err != nil {
t.Fatalf("moved message missing: %v", err)
}
msg, err := stores.Mails.GetByID(ids[1])
if err != nil {
t.Fatalf("get moved msg: %v", err)
}
if msg.Folder != "Trash" {
t.Fatalf("moved msg folder = %q, want Trash", msg.Folder)
}
}