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

240 lines
7.1 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 (
"crypto/tls"
"fmt"
"log"
"net"
"strconv"
"sync"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/store"
"mail_go/internal/tlsutil"
"github.com/emersion/go-imap/v2"
imapserver "github.com/emersion/go-imap/v2/imapserver"
)
// Pusher 是 IMAP 实时推送接口:SMTP/POP3/Web 在邮件状态变化后调用,
// 更新经 mailboxHub 分发给已选中对应邮箱的其他会话(IDLE 时即时送达)。
type Pusher interface {
// PushNewMessage 推送新邮件(本地投递成功)。
PushNewMessage(userEmail string, msg *db.Message)
// PushFlagsChanged 推送已读/星标等标志变化。
PushFlagsChanged(userEmail, mailbox string, msg *db.Message)
// PushExpunged 推送邮件被删除(seqNums 为删除前序号)。
PushExpunged(userEmail, mailbox string, seqNums []uint32)
}
// IMAPServer 管理 IMAP/IMAPS 监听器、会话注册与跨会话推送。
type IMAPServer struct {
stores *store.Stores
cfg config.IMAPConfig
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
hub *connhub.Hub
// 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
// when TLS is not configured.
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub) *IMAPServer {
return &IMAPServer{
stores: stores,
cfg: cfg,
banCfg: banCfg,
tlsLoader: tlsLoader,
hub: hub,
hubs: make(map[string]*mailboxHub),
sessions: make(map[*imapSession]struct{}),
}
}
// 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
}
hub := s.hubFor(userEmail, "INBOX")
if hub == nil {
return
}
if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, "INBOX"); err == nil {
hub.enqueue(sessionUpdate{exists: ptrU32(uint32(count))}, nil)
}
}
// PushFlagsChanged 推送邮件标志(已读/星标/删除标记)变化给同用户其他会话。
func (s *IMAPServer) PushFlagsChanged(userEmail, mailbox string, msg *db.Message) {
if s == nil || userEmail == "" || mailbox == "" || msg == nil {
return
}
hub := s.hubFor(userEmail, mailbox)
if hub == nil {
return
}
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 推送邮件被删除(每条序号一个 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 {
hub.enqueue(sessionUpdate{expunge: ptrU32(seq)}, nil)
}
}
// DisconnectByAddr 强制断开指定远端地址的连接(管理后台「断开并封禁」)。
// 发送 BYE 并关闭底层连接,触发库的收尾流程(session.Close、协议日志回填)。
func (s *IMAPServer) DisconnectByAddr(remoteAddr string) {
if s == nil || remoteAddr == "" {
return
}
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")
}
}
func (s *IMAPServer) tlsConfig() (*tls.Config, error) {
if s.tlsLoader == nil {
return nil, fmt.Errorf("IMAP TLS certificate or key not configured")
}
// GetCertificate 每次握手按需重载证书,证书更新后无需重启服务
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
}
// 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 {
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)
if err != nil {
return 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
return 0
}
return 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(s.cfg.Addr)
}
// 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(s.cfg.TLSAddr)
}