Files
mailgo/internal/imap_server/notify_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

208 lines
6.6 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.
package imap_server
import (
"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"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// fakeSession 构造一个挂接在推送中心上的裸会话(无网络连接),用于
// 验证 Pusher 的跨会话推送内容与来源排除语义。
func fakeSession() *imapSession {
return &imapSession{notify: make(chan struct{}, 1)}
}
func newTestServer(t *testing.T) (*IMAPServer, *store.Stores) {
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.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)
}
user := &db.User{Username: "alice", DomainID: domain.ID, 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())
return srv, stores
}
// TestPushNewMessage 验证本地投递成功后推送给已选中会话的 EXISTS 更新。
func TestPushNewMessage(t *testing.T) {
srv, stores := newTestServer(t)
// 已有一封旧邮件;新邮件投递后 INBOX 计数应为 2
old := &db.Message{UserID: 1, Folder: "INBOX", FromAddr: "x@y", Subject: "old", Date: time.Now().Add(-time.Hour)}
if err := stores.Mails.Create(old); err != nil {
t.Fatalf("create old message: %v", err)
}
inboxMsg := &db.Message{
UserID: 1,
Folder: "INBOX",
FromAddr: "sender@other.com",
ToAddr: "alice@example.com",
Subject: "新邮件",
Date: time.Now(),
}
if err := stores.Mails.Create(inboxMsg); err != nil {
t.Fatalf("create message: %v", err)
}
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
sess := fakeSession()
hub.add(sess)
srv.PushNewMessage("alice@example.com", inboxMsg)
updates := sess.takeUpdates(true)
if len(updates) != 1 || updates[0].exists == nil {
t.Fatalf("updates = %+v, want 1 条 EXISTS", updates)
}
if *updates[0].exists != 2 {
t.Fatalf("EXISTS = %d, want 2", *updates[0].exists)
}
}
// TestPushNewMessageNoSession 验证无会话选中时推送为 no-op(不 panic)。
func TestPushNewMessageNoSession(t *testing.T) {
srv, _ := newTestServer(t)
srv.PushNewMessage("alice@example.com", &db.Message{ID: 1, UserID: 1, Folder: "INBOX", Date: time.Now()})
}
// TestPushNewMessageNilSafe 验证空参数/空指针安全。
func TestPushNewMessageNilSafe(t *testing.T) {
var srv *IMAPServer
srv.PushNewMessage("a@b", &db.Message{ID: 1}) // 不应 panic
srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil)
srv.PushNewMessage("", &db.Message{ID: 1}) // 空邮箱
srv.PushNewMessage("a@b", nil) // 空消息
srv.PushFlagsChanged("", "", nil)
srv.PushExpunged("", "", nil)
}
// TestPushFlagsChanged 验证标志变化(已读/星标)推送内容正确。
func TestPushFlagsChanged(t *testing.T) {
srv, stores := newTestServer(t)
msg := &db.Message{UserID: 1, Folder: "INBOX", FromAddr: "x@y", Subject: "s", Date: time.Now()}
if err := stores.Mails.Create(msg); err != nil {
t.Fatalf("create message: %v", err)
}
msg.IsRead = true
msg.IsFlagged = true
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
sess := fakeSession()
hub.add(sess)
srv.PushFlagsChanged("alice@example.com", "INBOX", msg)
updates := sess.takeUpdates(true)
if len(updates) != 1 || updates[0].fetch == nil {
t.Fatalf("updates = %+v, want 1 条 FETCH", updates)
}
f := updates[0].fetch
if f.uid != imap.UID(msg.ID) {
t.Fatalf("uid = %d, want %d", f.uid, msg.ID)
}
got := make(map[imap.Flag]bool)
for _, fl := range f.flags {
got[fl] = true
}
if !got[imap.FlagSeen] || !got[imap.FlagFlagged] {
t.Fatalf("flags = %v, want \\Seen and \\Flagged", f.flags)
}
}
// TestPushExpunged 验证删除推送:每条序号一个 EXPUNGE 更新。
func TestPushExpunged(t *testing.T) {
srv, _ := newTestServer(t)
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
sess := fakeSession()
hub.add(sess)
srv.PushExpunged("alice@example.com", "INBOX", []uint32{2, 5})
updates := sess.takeUpdates(true)
if len(updates) != 2 {
t.Fatalf("updates = %d, want 2", len(updates))
}
if updates[0].expunge == nil || updates[1].expunge == nil {
t.Fatalf("updates = %+v, want expunge updates", updates)
}
if *updates[0].expunge != 2 || *updates[1].expunge != 5 {
t.Fatalf("seqs = %d,%d, want 2,5", *updates[0].expunge, *updates[1].expunge)
}
}
// TestHubExcludesSource 验证来源会话不会收到自己动作的回声推送:
// 会话 A 的 STORE/EXPUNGE 只分发给同邮箱的其他会话(本会话的响应已由
// 命令本身写回)。回归:v2 库自带 MailboxTracker 对 EXPUNGE/EXISTS 无法
// 排除来源,曾导致本会话在后续 Poll 时收到重复 EXPUNGE。
func TestHubExcludesSource(t *testing.T) {
srv, _ := newTestServer(t)
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
source := fakeSession()
other := fakeSession()
hub.add(source)
hub.add(other)
hub.enqueue(sessionUpdate{expunge: ptrU32(3)}, source)
if got := source.takeUpdates(true); len(got) != 0 {
t.Fatalf("source 收到 %d 条回声更新,want 0", len(got))
}
got := other.takeUpdates(true)
if len(got) != 1 || got[0].expunge == nil || *got[0].expunge != 3 {
t.Fatalf("other updates = %+v, want 1 条 expunge(3)", got)
}
}
// TestPollAllowExpunge 验证 FETCH/STORE/SEARCH 期间不下发 EXPUNGE
// allowExpunge=false 时遇到 EXPUNGE 停止),与 RFC 一致。
func TestPollAllowExpunge(t *testing.T) {
srv, _ := newTestServer(t)
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
sess := fakeSession()
hub.add(sess)
hub.enqueue(sessionUpdate{fetch: &sessionFetchUpdate{seq: 1, uid: 1, flags: []imap.Flag{imap.FlagSeen}}}, nil)
hub.enqueue(sessionUpdate{expunge: ptrU32(2)}, nil)
hub.enqueue(sessionUpdate{fetch: &sessionFetchUpdate{seq: 3, uid: 3, flags: []imap.Flag{imap.FlagSeen}}}, nil)
// allowExpunge=false:只取到第一条 FETCHEXPUNGE 及其后的留在队列
updates := sess.takeUpdates(false)
if len(updates) != 1 || updates[0].fetch == nil {
t.Fatalf("updates = %+v, want 仅 1 条 FETCH", updates)
}
// allowExpunge=true:剩余全部下发
rest := sess.takeUpdates(true)
if len(rest) != 2 {
t.Fatalf("rest = %d, want 2", len(rest))
}
if rest[0].expunge == nil || *rest[0].expunge != 2 {
t.Fatalf("rest[0] = %+v, want expunge(2)", rest[0])
}
}