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:
+116
-152
@@ -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 推送邮件被删除(ExpungeUpdate,seqNums 为删除前序号)。
|
||||
// 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 2177(IDLE)要求新邮件到达
|
||||
// 时服务器发送 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 是服务器支持的能力集:UIDPLUS(RFC 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)
|
||||
Reference in New Issue
Block a user