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 语义
This commit is contained in:
2026-08-20 00:44:37 +08:00
parent f533fc8feb
commit 962c5f454c
11 changed files with 1857 additions and 1602 deletions
+116 -152
View File
@@ -14,23 +14,22 @@ import (
"mail_go/internal/store"
"mail_go/internal/tlsutil"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend"
imapserver "github.com/emersion/go-imap/server"
"github.com/emersion/go-imap/v2"
imapserver "github.com/emersion/go-imap/v2/imapserver"
)
// Pusher 是 IMAP 实时推送接口:SMTP/POP3/Web 在邮件状态变化后调用,
// 由 go-imap 广播给相关客户端(按用户名+邮箱过滤,IDLE 时即时送达)。
// 更新经 mailboxHub 分发给已选中对应邮箱的其他会话(IDLE 时即时送达)。
type Pusher interface {
// PushNewMessage 推送新邮件(本地投递成功)。
PushNewMessage(userEmail string, msg *db.Message)
// PushFlagsChanged 推送已读/星标等标志变化MessageUpdate
// PushFlagsChanged 推送已读/星标等标志变化。
PushFlagsChanged(userEmail, mailbox string, msg *db.Message)
// PushExpunged 推送邮件被删除(ExpungeUpdateseqNums 为删除前序号)。
// PushExpunged 推送邮件被删除(seqNums 为删除前序号)。
PushExpunged(userEmail, mailbox string, seqNums []uint32)
}
// IMAPServer wraps a go-imap Server and provides mailbox access capability.
// IMAPServer 管理 IMAP/IMAPS 监听器、会话注册与跨会话推送。
type IMAPServer struct {
stores *store.Stores
cfg config.IMAPConfig
@@ -38,9 +37,11 @@ type IMAPServer struct {
tlsLoader *tlsutil.Loader
hub *connhub.Hub
beMu sync.Mutex
bes []*imapBackend // 各监听器(明文/TLS)的 backend,用于新邮件推送
srvs []*imapserver.Server // 各监听器实例,用于强制断开连接
// hubs 按「用户邮箱 + 文件夹」索引的推送中心,会话 SELECT 时加入。
mu sync.Mutex
hubs map[string]*mailboxHub
// sessions 全部活跃会话(DisconnectByAddr 用)。
sessions map[*imapSession]struct{}
}
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
@@ -52,158 +53,107 @@ func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsut
banCfg: banCfg,
tlsLoader: tlsLoader,
hub: hub,
hubs: make(map[string]*mailboxHub),
sessions: make(map[*imapSession]struct{}),
}
}
// NotifyNewMessage 向所有 IMAP 监听器推送新邮件通知(go-imap 广播时按
// 用户名+邮箱过滤,只送达已选中 INBOX 的客户端,IDLE 挂起时实时收到
// FETCH 响应)。由 SMTP/Web 本地投递成功时调用;channel 满时非阻塞丢弃。
// hubKey 生成推送中心索引键。
func hubKey(userEmail, mailbox string) string {
return userEmail + "\x00" + mailbox
}
// hubFor 返回已存在的推送中心,不存在返回 nil。
func (s *IMAPServer) hubFor(userEmail, mailbox string) *mailboxHub {
s.mu.Lock()
defer s.mu.Unlock()
return s.hubs[hubKey(userEmail, mailbox)]
}
// hubForOrCreate 返回推送中心,不存在则创建。
func (s *IMAPServer) hubForOrCreate(userEmail, mailbox string) *mailboxHub {
s.mu.Lock()
defer s.mu.Unlock()
key := hubKey(userEmail, mailbox)
h := s.hubs[key]
if h == nil {
h = newMailboxHub()
s.hubs[key] = h
}
return h
}
func (s *IMAPServer) unregisterSession(sess *imapSession) {
s.mu.Lock()
delete(s.sessions, sess)
s.mu.Unlock()
}
// NotifyNewMessage 推送新邮件到达通知:EXISTS 计数 + 标记更新。
// 由 SMTP/Web 本地投递成功时调用;无会话选中该邮箱时为 no-op。
func (s *IMAPServer) PushNewMessage(userEmail string, msg *db.Message) {
if s == nil || userEmail == "" || msg == nil {
return
}
update := buildNewMessageUpdate(s.stores, userEmail, "INBOX", msg)
if update == nil {
hub := s.hubFor(userEmail, "INBOX")
if hub == nil {
return
}
// 先发 EXISTS 通知再发 FETCH 更新:RFC 2177IDLE)要求新邮件到达
// 时服务器发送 EXISTS,不少客户端(如 Apple Mail)只认 EXISTS 才会
// 唤醒并主动拉取,仅裸 FETCH 更新会被忽略(表现为"必须手动同步")。
if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, "INBOX"); err == nil {
s.pushExists(userEmail, "INBOX", uint32(count))
hub.enqueue(sessionUpdate{exists: ptrU32(uint32(count))}, nil)
}
s.broadcastUpdate(update, userEmail, msg.ID)
}
// PushFlagsChanged 推送邮件标志(已读/星标)变化给同用户其他客户端
// PushFlagsChanged 推送邮件标志(已读/星标/删除标记)变化给同用户其他会话
func (s *IMAPServer) PushFlagsChanged(userEmail, mailbox string, msg *db.Message) {
if s == nil || userEmail == "" || mailbox == "" || msg == nil {
return
}
update := buildFlagsUpdate(s.stores, userEmail, mailbox, msg, false)
if update == nil {
hub := s.hubFor(userEmail, mailbox)
if hub == nil {
return
}
s.broadcastUpdate(update, userEmail, msg.ID)
seq := seqOf(s.stores, msg.UserID, mailbox, msg.ID)
if seq == 0 {
return
}
hub.enqueue(sessionUpdate{fetch: &sessionFetchUpdate{
seq: seq,
uid: imap.UID(msg.ID),
flags: flagsOf(msg.IsRead, msg.IsFlagged, msg.IsDeleted),
}}, nil)
}
// PushExpunged 推送邮件被删除(每条序号一个 ExpungeUpdate)。
// PushExpunged 推送邮件被删除(每条序号一个 EXPUNGE 更新)。
func (s *IMAPServer) PushExpunged(userEmail, mailbox string, seqNums []uint32) {
if s == nil || userEmail == "" || mailbox == "" || len(seqNums) == 0 {
return
}
hub := s.hubFor(userEmail, mailbox)
if hub == nil {
return
}
for _, seq := range seqNums {
update := &backend.ExpungeUpdate{
Update: backend.NewUpdate(userEmail, mailbox),
SeqNum: seq,
}
s.broadcastUpdate(update, userEmail, 0)
hub.enqueue(sessionUpdate{expunge: ptrU32(seq)}, nil)
}
}
// broadcastUpdate 把一条更新非阻塞地投递到所有监听器的推送通道。
// 每个监听器必须收到独立的 Update 对象(各自独立的 Done channel):
// 每个监听器的 listenUpdates 都会对 update.Done() 执行 close,共享
// 同一对象会导致对同一 channel 二次 close 而 panic。
func (s *IMAPServer) broadcastUpdate(update backend.Update, userEmail string, msgID uint) {
s.beMu.Lock()
bes := append([]*imapBackend(nil), s.bes...)
s.beMu.Unlock()
for _, b := range bes {
select {
case b.updates <- cloneUpdate(update):
default:
log.Printf("IMAP: 推送通道已满,丢弃 %s 的更新 (msg=%d)", userEmail, msgID)
}
}
}
// pushExists 向所有监听器中「已登录该用户且已选中该邮箱」的连接直接写入
// 未请求的 "* N EXISTS" 响应(绕过 go-imap 更新通道——其仅支持 FETCH/
// EXPUNGE 类更新,无法表达 EXISTS)。通道满时非阻塞丢弃,与广播一致。
func (s *IMAPServer) pushExists(userEmail, mailbox string, exists uint32) {
s.beMu.Lock()
srvs := append([]*imapserver.Server(nil), s.srvs...)
s.beMu.Unlock()
for _, srv := range srvs {
srv.ForEachConn(func(conn imapserver.Conn) {
ctx := conn.Context()
if ctx == nil || ctx.User == nil || ctx.Mailbox == nil {
return
}
if ctx.User.Username() != userEmail || ctx.Mailbox.Name() != mailbox {
return
}
select {
case ctx.Responses <- existsResponse(exists):
default:
log.Printf("IMAP: EXISTS 推送通道已满,丢弃 (user=%s mailbox=%s)", userEmail, mailbox)
}
})
}
}
// existsResponse 序列化为 "* N EXISTS\r\n"。
type existsResponse uint32
func (n existsResponse) WriteTo(w *imap.Writer) error {
_, err := fmt.Fprintf(w, "* %d EXISTS\r\n", uint32(n))
return err
}
// cloneUpdate 按类型复制一条 backend.Update:载荷(消息/序号)共享,
// 但 Username/Mailbox/Done channel 重置为独立实例。
func cloneUpdate(u backend.Update) backend.Update {
switch u := u.(type) {
case *backend.MessageUpdate:
return &backend.MessageUpdate{
Update: backend.NewUpdate(u.Username(), u.Mailbox()),
Message: u.Message,
}
case *backend.ExpungeUpdate:
return &backend.ExpungeUpdate{
Update: backend.NewUpdate(u.Username(), u.Mailbox()),
SeqNum: u.SeqNum,
}
default:
// 防御:未知类型原样传递(当前不存在此类更新)
return u
}
}
// registerBackend 记录新建的 backend(用于新邮件推送)。
func (s *IMAPServer) registerBackend(be *imapBackend) {
s.beMu.Lock()
s.bes = append(s.bes, be)
s.beMu.Unlock()
}
// registerServer 记录监听器实例(用于强制断开连接)。
func (s *IMAPServer) registerServer(srv *imapserver.Server) {
s.beMu.Lock()
s.srvs = append(s.srvs, srv)
s.beMu.Unlock()
}
// DisconnectByAddr 强制断开指定远端地址的连接(管理后台「断开并封禁」)。
// 关闭连接触发 go-imap 的收尾流程(user.Logout、协议日志回填、hub 注销)。
// 发送 BYE 并关闭底层连接触发的收尾流程(session.Close、协议日志回填)。
func (s *IMAPServer) DisconnectByAddr(remoteAddr string) {
if s == nil || remoteAddr == "" {
return
}
s.beMu.Lock()
srvs := append([]*imapserver.Server(nil), s.srvs...)
s.beMu.Unlock()
for _, srv := range srvs {
srv.ForEachConn(func(conn imapserver.Conn) {
info := conn.Info()
if info != nil && info.RemoteAddr != nil && info.RemoteAddr.String() == remoteAddr {
_ = conn.Close()
}
})
s.mu.Lock()
var targets []*imapSession
for sess := range s.sessions {
if sess.remoteAddr == remoteAddr {
targets = append(targets, sess)
}
}
s.mu.Unlock()
for _, sess := range targets {
_ = sess.conn.Bye("Connection closed by administrator")
}
}
@@ -215,25 +165,42 @@ func (s *IMAPServer) tlsConfig() (*tls.Config, error) {
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
}
// newServer creates a configured imapserver.Server with the given address.
// imapCaps 是服务器支持的能力集:UIDPLUSRFC 4315)提供 UID EXPUNGE、
// COPYUID/APPENDUID 支持;MOVE 由 SessionMove 实现;IDLE/UNSELECT 在
// IMAP4rev1 认证后由库自动广告。
var imapCaps = imap.CapSet{
imap.CapIMAP4rev1: {},
imap.CapUIDPlus: {},
imap.CapMove: {},
imap.CapLiteralPlus: {},
imap.CapChildren: {},
imap.CapSpecialUse: {},
}
// newServer 创建指定监听地址的 IMAP 服务(会话工厂捕获监听端口)。
func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server {
be := &imapBackend{
stores: s.stores,
banCfg: s.banCfg,
port: portOf(addr),
hub: s.hub,
updates: make(chan backend.Update, 256),
disconnectAddr: s.DisconnectByAddr,
}
s.registerBackend(be)
srv := imapserver.New(be)
srv.Addr = addr
srv.TLSConfig = tlsConfig
srv.AllowInsecureAuth = tlsConfig == nil
s.registerServer(srv)
port := portOf(addr)
srv := imapserver.New(&imapserver.Options{
Caps: imapCaps,
NewSession: s.newSessionFactory(port),
TLSConfig: tlsConfig,
InsecureAuth: tlsConfig == nil,
})
return srv
}
// newSessionFactory 构造会话工厂:每个连接一个 imapSession,注册到
// 会话表(DisconnectByAddr 用)。
func (s *IMAPServer) newSessionFactory(port int) func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
sess := newImapSession(s, conn, port)
s.mu.Lock()
s.sessions[sess] = struct{}{}
s.mu.Unlock()
return sess, nil, nil
}
}
// portOf 从监听地址解析端口号,失败返回 0。
func portOf(addr string) int {
_, portStr, err := net.SplitHostPort(addr)
@@ -247,29 +214,26 @@ func portOf(addr string) int {
return port
}
// Start starts the IMAP server on the plain-text port.
// Start starts the IMAP server on the plain-text port (STARTTLS enabled
// when a certificate is configured).
func (s *IMAPServer) Start() error {
tlsConfig, err := s.tlsConfig()
if err != nil {
log.Printf("IMAP STARTTLS 未启用: %v", err)
tlsConfig = nil
}
srv := s.newServer(s.cfg.Addr, tlsConfig)
log.Printf("IMAP server listening on %s", s.cfg.Addr)
return srv.ListenAndServe()
return srv.ListenAndServe(s.cfg.Addr)
}
// StartTLS starts the IMAP server on the TLS port.
// StartTLS starts the IMAP server on the implicit TLS port.
func (s *IMAPServer) StartTLS() error {
tlsConfig, err := s.tlsConfig()
if err != nil {
return err
}
srv := s.newServer(s.cfg.TLSAddr, tlsConfig)
log.Printf("IMAPS server listening on %s", s.cfg.TLSAddr)
return srv.ListenAndServeTLS()
return srv.ListenAndServeTLS(s.cfg.TLSAddr)
}
// ensure imapBackend satisfies backend.Backend at compile time
var _ backend.Backend = (*imapBackend)(nil)