feat(imap): 多客户端状态实时同步 + 当前连接「断开并封禁」
- 推送扩展:IMAP STORE(已读/星标/\Deleted)推送 FETCH 标志更新、 EXPUNGE 推送 ExpungeUpdate(删除前序号)、APPEND/COPY/MOVE 推送 新邮件;POP3 QUIT 删除、Web 标已读/删除同样实时同步到 IMAP 客户端 - Pusher 接口统一 SMTP/POP3/Web 的推送入口,IMAP 内部操作经会话 通道直接入队(非阻塞,满则丢弃) - 当前连接页新增「断开并封禁」:connhub 支持断开回调,SMTP/POP3 关底层连接、IMAP 经 ForEachConn 按地址断开;一键封禁 180 天并 断开该 IP 全部在线连接,黑名单页可随时解封 - 修复:POP3 PASS 成功后保留完整邮箱(此前被裸用户名覆盖) - 新增测试:断开/按 IP 断开、flags/expunge 推送内容、POP3 删除推送、 Web 断开封禁处理器;全量 -race 通过
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/tlsutil"
|
||||
)
|
||||
@@ -26,13 +27,14 @@ type POP3Server struct {
|
||||
banCfg config.BanConfig
|
||||
tlsLoader *tlsutil.Loader
|
||||
hub *connhub.Hub
|
||||
pusher imap_server.Pusher // 邮件删除推送(IMAP 客户端同步),可空
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil
|
||||
// when TLS is not configured.
|
||||
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub) *POP3Server {
|
||||
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub}
|
||||
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, pusher imap_server.Pusher) *POP3Server {
|
||||
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher}
|
||||
}
|
||||
|
||||
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
|
||||
@@ -135,8 +137,12 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) {
|
||||
return
|
||||
}
|
||||
|
||||
// 连接追踪:注册到当前连接中心,连接结束时注销
|
||||
// 连接追踪:注册到当前连接中心,连接结束时注销;
|
||||
// 强制断开:关闭底层连接(STLS 后 conn 变量已指向 tlsConn,同样生效)。
|
||||
activeConn := s.hub.Register("pop3", clientIP, port, false)
|
||||
if activeConn != nil {
|
||||
activeConn.SetDisconnect(func() { _ = conn.Close() })
|
||||
}
|
||||
|
||||
// 会话状态(供协议日志汇总)
|
||||
var (
|
||||
@@ -397,6 +403,9 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用
|
||||
authUser.Username = user.Username
|
||||
|
||||
messages := s.loadMessages(authUser)
|
||||
deleted := make(map[int]bool)
|
||||
sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages)))
|
||||
@@ -532,12 +541,14 @@ func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Messag
|
||||
}
|
||||
|
||||
// expungeDeleted actually deletes messages that were marked for deletion,
|
||||
// returning the number of messages deleted.
|
||||
// returning the number of messages deleted. 删除成功后向 IMAP 客户端推送
|
||||
// Expunge 通知(序号为删除前在 INBOX 中的位置)。
|
||||
func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) int {
|
||||
if deleted == nil || user == nil || user.ID == 0 {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
var seqs []uint32
|
||||
for seqNum, msgDeleted := range deleted {
|
||||
if msgDeleted && seqNum >= 1 && seqNum <= len(messages) {
|
||||
if err := s.stores.Mails.Delete(messages[seqNum-1].id); err != nil {
|
||||
@@ -545,8 +556,12 @@ func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool
|
||||
continue
|
||||
}
|
||||
count++
|
||||
seqs = append(seqs, uint32(seqNum))
|
||||
}
|
||||
}
|
||||
if s.pusher != nil && len(seqs) > 0 && user.Username != "" {
|
||||
s.pusher.PushExpunged(user.Username, "INBOX", seqs)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
|
||||
@@ -160,3 +160,90 @@ func TestPop3CommandDetail(t *testing.T) {
|
||||
t.Fatalf("empty detail = %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// mockPusher 记录推送调用的测试桩。
|
||||
type mockPusher struct {
|
||||
expunged []struct {
|
||||
Email string
|
||||
Mailbox string
|
||||
Seqs []uint32
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPusher) PushNewMessage(string, *db.Message) {}
|
||||
func (m *mockPusher) PushFlagsChanged(string, string, *db.Message) {}
|
||||
func (m *mockPusher) PushExpunged(email, mailbox string, seqs []uint32) {
|
||||
m.expunged = append(m.expunged, struct {
|
||||
Email string
|
||||
Mailbox string
|
||||
Seqs []uint32
|
||||
}{email, mailbox, seqs})
|
||||
}
|
||||
|
||||
// TestExpungePushesIMAPUpdate 验证 POP3 删除邮件后向 IMAP 推送 Expunge。
|
||||
func TestExpungePushesIMAPUpdate(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
pusher := &mockPusher{}
|
||||
s.pusher = pusher
|
||||
|
||||
domain := &db.Domain{Name: "example.com"}
|
||||
if err := s.stores.Domains.Create(domain); err != nil {
|
||||
t.Fatalf("create domain: %v", err)
|
||||
}
|
||||
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true}
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
|
||||
user.PasswordHash = string(hashed)
|
||||
if err := s.stores.Users.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "m", Date: time.Now()}
|
||||
if err := s.stores.Mails.Create(msg); err != nil {
|
||||
t.Fatalf("create message %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
s.handleConn(server, 110)
|
||||
}()
|
||||
|
||||
br := bufio.NewReader(client)
|
||||
br.ReadString('\n')
|
||||
client.Write([]byte("USER alice@example.com\r\n"))
|
||||
br.ReadString('\n')
|
||||
client.Write([]byte("PASS secret123\r\n"))
|
||||
br.ReadString('\n')
|
||||
// 删除第 1 封后退出
|
||||
client.Write([]byte("DELE 1\r\n"))
|
||||
br.ReadString('\n')
|
||||
client.Write([]byte("QUIT\r\n"))
|
||||
br.ReadString('\n')
|
||||
client.Close()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("handleConn did not return")
|
||||
}
|
||||
|
||||
if len(pusher.expunged) != 1 {
|
||||
t.Fatalf("expunge pushes = %d, want 1", len(pusher.expunged))
|
||||
}
|
||||
p := pusher.expunged[0]
|
||||
if p.Email != "alice@example.com" || p.Mailbox != "INBOX" {
|
||||
t.Fatalf("push target = %s/%s", p.Email, p.Mailbox)
|
||||
}
|
||||
if len(p.Seqs) != 1 || p.Seqs[0] != 1 {
|
||||
t.Fatalf("seqs = %v, want [1]", p.Seqs)
|
||||
}
|
||||
// 邮件确实已删除
|
||||
if n, _ := s.stores.Mails.CountByUserAndFolder(user.ID, "INBOX"); n != 2 {
|
||||
t.Fatalf("inbox count = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user